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:
1 parent
b8a6da6c38
commit
1fbb2a40a8
43 files changed
+2341
-703
No files matched your search
@@ -105,8 +105,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": "b44cdfac5d88b912b92bdc87b2f87cd9afd91a48",
|
"commit": "be8089296767d3e8cf012e07ea31c8716591bad8",
|
||||||
"syncedAt": "2026-09-15T07:53:22Z"
|
"syncedAt": "2026-09-15T16:00:00Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "grill-me",
|
"id": "grill-me",
|
||||||
@@ -114,8 +114,8 @@
|
|||||||
"repo": "https://github.com/mattpocock/skills.git",
|
"repo": "https://github.com/mattpocock/skills.git",
|
||||||
"ref": "main",
|
"ref": "main",
|
||||||
"adapter": "skill-collection",
|
"adapter": "skill-collection",
|
||||||
"commit": "3cca18b368ae95cdbdebbff572ccafa662551015",
|
"commit": "959a8e9f1edc3adbe2f7e3054bb6fbefa6696260",
|
||||||
"syncedAt": "2026-09-04T16:00:00Z"
|
"syncedAt": "2026-09-15T16:00:00Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "updating-gitea-repositories",
|
"id": "updating-gitea-repositories",
|
||||||
@@ -177,8 +177,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": "e29a96a9eea4821af9fecd2edbd73e45e513e80d",
|
"commit": "bfcf687f5ab6f1e0577573eb209476cef802f5d7",
|
||||||
"syncedAt": "2026-09-15T09:16:34Z"
|
"syncedAt": "2026-09-15T16:00:00Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "shuorenhua",
|
"id": "shuorenhua",
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
"sourceId": "grill-me",
|
"sourceId": "grill-me",
|
||||||
"repo": "https://github.com/mattpocock/skills.git",
|
"repo": "https://github.com/mattpocock/skills.git",
|
||||||
"ref": "main",
|
"ref": "main",
|
||||||
"commit": "3cca18b368ae95cdbdebbff572ccafa662551015",
|
"commit": "959a8e9f1edc3adbe2f7e3054bb6fbefa6696260",
|
||||||
"adapter": "skill-collection",
|
"adapter": "skill-collection",
|
||||||
"sourcePath": "skills/productivity",
|
"sourcePath": "skills/productivity",
|
||||||
"syncedAt": "2026-09-04T16:00:00Z"
|
"syncedAt": "2026-09-15T16:00:00Z"
|
||||||
}
|
}
|
||||||
@@ -3,5 +3,5 @@
|
|||||||
"name": "playwright浏览器自动化操作",
|
"name": "playwright浏览器自动化操作",
|
||||||
"version": "20260605",
|
"version": "20260605",
|
||||||
"keySource": "none",
|
"keySource": "none",
|
||||||
"syncedAt": "2026-09-15T09:54:17Z"
|
"syncedAt": "2026-09-15T16:03:31Z"
|
||||||
}
|
}
|
||||||
@@ -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": "e29a96a9eea4821af9fecd2edbd73e45e513e80d",
|
"commit": "bfcf687f5ab6f1e0577573eb209476cef802f5d7",
|
||||||
"adapter": "skill-collection",
|
"adapter": "skill-collection",
|
||||||
"sourcePath": "skills",
|
"sourcePath": "skills",
|
||||||
"syncedAt": "2026-09-15T09:16:34Z"
|
"syncedAt": "2026-09-15T16:00:00Z"
|
||||||
}
|
}
|
||||||
@@ -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": "b44cdfac5d88b912b92bdc87b2f87cd9afd91a48",
|
"commit": "be8089296767d3e8cf012e07ea31c8716591bad8",
|
||||||
"adapter": "claude-skill",
|
"adapter": "claude-skill",
|
||||||
"sourcePath": "skills/ppt-master",
|
"sourcePath": "skills/ppt-master",
|
||||||
"syncedAt": "2026-09-15T07:53:22Z"
|
"syncedAt": "2026-09-15T16:00:00Z"
|
||||||
}
|
}
|
||||||
@@ -40,8 +40,8 @@ Global artifact ownership rules for PPT Master projects. A selected route or pro
|
|||||||
| `<import_workspace>/authoring-svg/authoring_manifest.json` | Tool-only provenance | Per-document hashes and source-ref paths | Generated atomically with the IR; validated by materialization; never loaded into model context |
|
| `<import_workspace>/authoring-svg/authoring_manifest.json` | Tool-only provenance | Per-document hashes and source-ref paths | Generated atomically with the IR; validated by materialization; never loaded into model context |
|
||||||
| `<import_workspace>/authoring-svg-flat/` | Optional verification IR | Self-contained page composition with its own summary/manifest | Generated only from an explicitly requested `svg-flat/`; verification only |
|
| `<import_workspace>/authoring-svg-flat/` | Optional verification IR | Self-contained page composition with its own summary/manifest | Generated only from an explicitly requested `svg-flat/`; verification only |
|
||||||
| `<import_workspace>/icons/imported/` | Imported vector pool | One canonical copy of every factored vector subtree | Referenced as `data-icon="imported/<name>"`; inventories retain source refs |
|
| `<import_workspace>/icons/imported/` | Imported vector pool | One canonical copy of every factored vector subtree | Referenced as `data-icon="imported/<name>"`; inventories retain source refs |
|
||||||
| `confirm_ui/template_options.json`, `template_selection.json`, `template_handoff.json` | Default UI template-selection sidecar | Agent-authored candidates, user-confirmed selection beside the Stage-1 result, installation/free-design handoff | Step 3 writes options without launching UI; the Stage-1 submission writes `template_selection.json`; `--complete-template-selection` writes the handoff; Stage 2 is exposed only after that handoff and a fresh recommendation. Chat/delegated flows keep equivalent state without fabricating receipts; Quick creates none |
|
| `confirm_ui/recommendations.stage1.json` → `template_options`; `confirm_ui/template_selection.json`; `template_install.json` | Default UI template choice and installation | Agent candidates; server-confirmed root/kind snapshots; installer receipt | Stage 1 binds recommendations/options/selection by content; Stage 2 binds `selection_sha256` and verifies actual installation (or `free_design`). Chat keeps equivalent state without UI receipts; Quick uses its direct exact-root authority |
|
||||||
| `confirm_ui/recommendations.stage1.json`, `.stage2.json` | Confirmation proposals | Template-independent communication contract, then template-aware complete solution plus production mechanics | Stage 1 is authored without candidate indexes or workspaces as evidence and confirms communication plus template mode/selection in one submission; Stage 2 is created only after installation or free-design closure; `template_application` decides only how to use installed state; only the active unconfirmed stage may be overwritten |
|
| `confirm_ui/recommendations.stage1.json`, `.stage2.json` | Confirmation proposals | Template-independent communication contract, then template-aware complete solution plus production mechanics | Stage 1 is authored so that no candidate rewrites the communication contract and confirms communication plus template mode/selection in one submission; Stage 2 is created only after installation or free-design closure; `template_application` decides only how to use installed state; only the active unconfirmed stage may be overwritten |
|
||||||
| `confirm_ui/result.json` | Confirmation result | Persisted user-confirmed evidence | Step 4 reads the final object once; Strategist consumes it completely into `design_spec.md`; downstream never reopens it except a fresh recovery with no retained state |
|
| `confirm_ui/result.json` | Confirmation result | Persisted user-confirmed evidence | Step 4 reads the final object once; Strategist consumes it completely into `design_spec.md`; downstream never reopens it except a fresh recovery with no retained state |
|
||||||
| `svg_output/` | Page-design author source | Handwritten SVG pages containing the complete visible design, including formula markers | Canonical source for the checker and native export; templates and locks never add missing visible objects at export |
|
| `svg_output/` | Page-design author source | Handwritten SVG pages containing the complete visible design, including formula markers | Canonical source for the checker and native export; templates and locks never add missing visible objects at export |
|
||||||
| `notes/total.md`, `notes/slide_*.md` | Conditional speaker-note source / split notes | Complete notes; per-slide split by `total_md_split.py` | Written only when Speaker Notes is enabled; Step 7.1 or Quick §4 splits |
|
| `notes/total.md`, `notes/slide_*.md` | Conditional speaker-note source / split notes | Complete notes; per-slide split by `total_md_split.py` | Written only when Speaker Notes is enabled; Step 7.1 or Quick §4 splits |
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ Interpret the instruction semantically — "confirm here", "use the chat window"
|
|||||||
|
|
||||||
**Chat/delegated Stage-1 listing**: author the communication recommendation before reading the four template indexes, then present it together with an explicit free-design/template-mode choice; only template mode expands registered candidates and supplied exact roots and requires at least one selection. Ordinary requests initialize free design; explicit template intent or any supplied exact root initializes template mode, and exactly one supplied root may seed that candidate.
|
**Chat/delegated Stage-1 listing**: author the communication recommendation before reading the four template indexes, then present it together with an explicit free-design/template-mode choice; only template mode expands registered candidates and supplied exact roots and requires at least one selection. Ordinary requests initialize free design; explicit template intent or any supplied exact root initializes template mode, and exactly one supplied root may seed that candidate.
|
||||||
|
|
||||||
**Always-on Stage-1 chat handoff (UI branch)**: after writing `template_options.json` and `recommendations.stage1.json`, launch the daemon, then immediately post its actual URL plus one compact localized summary of the recommendation and template-choice state (audience, communication intent, audience outcome, core message, delivery context, artifact afterlife, `content_divergence`, canvas, free-design vs template default and any sole preselected root; blank prose shown as "not specified"), ending with a localized line that the same items may be confirmed or revised in chat if the page did not open. Only then run `--wait-only --wait-stage stage1`. The handoff is context, not confirmation; silence confirms nothing.
|
**Always-on Stage-1 chat handoff (UI branch)**: after writing `recommendations.stage1.json` with its embedded `template_options`, launch the daemon, then immediately post its actual URL plus one compact localized summary of the recommendation and template-choice state (audience, communication intent, audience outcome, core message, delivery context, artifact afterlife, `content_divergence`, canvas, free-design vs template default and any sole preselected root; blank prose shown as "not specified"), ending with a localized line that the same items may be confirmed or revised in chat if the page did not open. Only then run `--wait-only --wait-stage stage1`. The handoff is context, not confirmation; silence confirms nothing.
|
||||||
|
|
||||||
**In-run UI → chat switch (any stage)**: when the user explicitly selects chat after launch — interrupt an active wait and confirm its process exited (that deliberate interruption is the only expected non-zero return), run `server.py <project_path> --shutdown` and require it to succeed, re-check the active receipt once (Stage 1: `result.json` plus `template_selection.json` from the same submission; Stage 2: `result.json`; an unsubmitted browser draft is not confirmed), then continue everything remaining in chat without relaunching or waiting again. After launch failure/timeout, re-check the receipts once the same way and present the same items as open chat questions.
|
**In-run UI → chat switch (any stage)**: when the user explicitly selects chat after launch — interrupt an active wait and confirm its process exited (that deliberate interruption is the only expected non-zero return), run `server.py <project_path> --shutdown` and require it to succeed, re-check the active receipt once (Stage 1: `result.json` plus `template_selection.json` from the same submission; Stage 2: `result.json`; an unsubmitted browser draft is not confirmed), then continue everything remaining in chat without relaunching or waiting again. After launch failure/timeout, re-check the receipts once the same way and present the same items as open chat questions.
|
||||||
|
|
||||||
@@ -42,7 +42,7 @@ Author before reading any candidate index, spec, prototype, asset, or template c
|
|||||||
|
|
||||||
## 3. `recommendations.stage2.json`
|
## 3. `recommendations.stage2.json`
|
||||||
|
|
||||||
Create only after the Stage-1 receipts and the `--complete-template-selection` handoff exist, leaving Stage 1 unchanged. Required: `recommend.generation_mode`, boolean `refine_spec.value`, `design_spec_depth.value` as `brief` or `complete` (`brief` is rejected with `split` mode or `refine_spec: true`), and `recommend.image_ai_path` (`auto` / `api` / `host-native` / `manual`) whenever `image_usage` includes `ai`. `image_usage` is an array of source ids (`ai`, `web`, `provided`, `placeholder`; `none` is exclusive). `design_directions` carries exactly three complete candidates with unique stable ids; `selected` is the zero-based index chosen after all three are complete. Candidate display text is written once in the confirmed UI language using the plain keys (`name`, `note`, `mode_behavior`, `visual_style_behavior`, `visual`, `mood`, `behavior`; a single locale suffix such as `_zh` is accepted). Typography carries concrete heading/body `primary` plus `english` only for non-English decks and a positive `body_size`; color carries the six-role `palette`. For a confirmed templates-mode handoff, add top-level `template_application.value` — one editable prose paragraph on how to use the installed template. Never write `recommend.template_reuse_scope` or `template_adherence`.
|
Create only after Stage 1 and verified installation (or `free_design`); bind top-level `selection_sha256` to the server-written selection and leave Stage 1 unchanged. Required: `recommend.generation_mode`, boolean `refine_spec.value`, `design_spec_depth.value` as `brief` or `complete` (`brief` is rejected with `split` mode or `refine_spec: true`), and `recommend.image_ai_path` (`auto` / `api` / `host-native` / `manual`) whenever `image_usage` includes `ai`. `image_usage` is an array of source ids (`ai`, `web`, `provided`, `placeholder`; `none` is exclusive). `design_directions` carries exactly three complete candidates with unique stable ids; `selected` is the zero-based index chosen after all three are complete. Candidate display text is written once in the confirmed UI language using the plain keys (`name`, `note`, `mode_behavior`, `visual_style_behavior`, `visual`, `mood`, `behavior`; a single locale suffix such as `_zh` is accepted). Typography carries concrete heading/body `primary` plus `english` only for non-English decks and a positive `body_size`; color carries the six-role `palette`. For a confirmed and installed templates-mode selection, add top-level `template_application.value` — one editable prose paragraph on how to use the installed template. Never write `recommend.template_reuse_scope` or `template_adherence`.
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@@ -105,4 +105,4 @@ The array repeats that candidate shape exactly three times. `template_applicatio
|
|||||||
|
|
||||||
## 4. `result.json`
|
## 4. `result.json`
|
||||||
|
|
||||||
Written by the user's submission; Generate Step 4 reads the final object (`stage: final`, `status: confirmed`) exactly once and retains it through Design Spec authoring. It carries the Stage-1 prose fields, `primary_language`, `canvas`, `page_count`, the confirmed `mode` / `visual_style` with their `_behavior` siblings, `color`, `icons`, `typography` (including `body_size`, optional per-role `sizes`), `delivery_purpose`, `image_usage`, `image_notes`, conditional `image_ai_path` and `image_strategy`, the three flat proactive booleans, `generation_mode`, `refine_spec`, and `design_spec_depth`. It contains no template-selection field — `template_selection.json` and the installed project-local state own that decision. Legacy results without `design_spec_depth` read as `complete`; results that omit the proactive booleans resolve to `true / false / false`.
|
Written by the user's submission; Generate Step 4 reads the final object (`stage: final`, `status: confirmed`) exactly once and retains it through Design Spec authoring. It carries the Stage-1 prose fields, `primary_language`, `canvas`, `page_count`, the confirmed `mode` / `visual_style` with their `_behavior` siblings, `color`, `icons`, `typography` (including `body_size`, optional per-role `sizes`), `delivery_purpose`, `image_usage`, `image_notes`, conditional `image_ai_path` and `image_strategy`, the three flat proactive booleans, `generation_mode`, `refine_spec`, and `design_spec_depth`. Server-written `stage1_sha256` and `selection_sha256` bind the confirmation content; root/kind choices remain in `template_selection.json` and the installed project-local state. Legacy results without `design_spec_depth` read as `complete`; results that omit the proactive booleans resolve to `true / false / false`.
|
||||||
+2
-2
@@ -14,9 +14,9 @@ Conditional Executor authority for `template_reuse_scope: mirror|layout` with `p
|
|||||||
|
|
||||||
| Context | Load policy |
|
| Context | Load policy |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `templates/design_spec.md` | Reuse in a valid context; after invalidation read it once with the planning artifacts |
|
| Installed `templates/design_spec.<kind>.<id>.md` files | Every installed spec, segments owned per [`apply-template-workspace.md`](../workflows/stages/apply-template-workspace.md) §5; reuse in a valid context and after invalidation read them once with the planning artifacts |
|
||||||
| Current page mapping | The retained `spec_lock.md page_layouts` row; a page change needs no file load |
|
| Current page mapping | The retained `spec_lock.md page_layouts` row; a page change needs no file load |
|
||||||
| Selected prototype SVG | Read the complete `templates/<basename>.svg` once per valid context and reuse it until a known change |
|
| Selected prototype SVG | Read the complete `<project_path>/templates/<basename>.svg` (the installed project-local copy, never the source workspace) once per valid context and reuse it until a known change |
|
||||||
|
|
||||||
**Hard rule**: the complete Slide prototype SVG is authoritative and already resolves its Master + Layout; standalone Master/Layout definition SVGs are invalid; never author from a roster, manifest, sidecar, filename, or summary (manifest/text-slot files are tool metadata whose absence neither invalidates a legacy workspace nor permits text-topology changes). Resolve each page's prototype directly from its `page_layouts` row — `mirror` → §1.1 (the workspace must support `replication_mode: mirror`); `layout` → resolve `P<NN>: <basename>`, retain the structure system, apply the re-skin/reflow rules below; a missing row stops (adaptive mode still needs one selected input prototype, and there is no filename/page-type fallback). A mapping change stops and returns to Strategist to update the plan, read back and validate, then load the new prototype.
|
**Hard rule**: the complete Slide prototype SVG is authoritative and already resolves its Master + Layout; standalone Master/Layout definition SVGs are invalid; never author from a roster, manifest, sidecar, filename, or summary (manifest/text-slot files are tool metadata whose absence neither invalidates a legacy workspace nor permits text-topology changes). Resolve each page's prototype directly from its `page_layouts` row — `mirror` → §1.1 (the workspace must support `replication_mode: mirror`); `layout` → resolve `P<NN>: <basename>`, retain the structure system, apply the re-skin/reflow rules below; a missing row stops (adaptive mode still needs one selected input prototype, and there is no filename/page-type fallback). A mapping change stops and returns to Strategist to update the plan, read back and validate, then load the new prototype.
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -25,9 +25,9 @@ Every new SVG project declares one deterministic route:
|
|||||||
|
|
||||||
## 2. Explicit PPTX Master / Layout / Placeholder Metadata
|
## 2. Explicit PPTX Master / Layout / Placeholder Metadata
|
||||||
|
|
||||||
**Trigger**: new pages generated from a current deck/layout workspace with `template_reuse_scope: mirror|layout`. `spec_lock.md` declares `pptx_structure.mode: structured`, complete unique `pptx_masters` / `pptx_layouts` rosters, one `page_pptx_layouts` assignment per generated page, and `page_layouts` as authoring-prototype provenance.
|
**Trigger**: new pages generated from a current deck/layout workspace with `template_reuse_scope: mirror|layout`. `spec_lock.md` declares `pptx_structure.mode: structured`, unique `pptx_masters` and explicitly registered `pptx_layouts` rosters (all used keys; unused prototypes only when deliberately listed), one `page_pptx_layouts` assignment per generated page, and `page_layouts` as authoring-prototype provenance.
|
||||||
|
|
||||||
**Project lock rows**: Master `<master_key>: <PowerPoint picker name>`; unique Layout `<layout_key>: <master_key> | <PowerPoint picker name> | <prototype source>` where the source is a generated `P<NN>` or installed `template:<basename>`; page assignment `P<NN>: <layout_key>` under `page_pptx_layouts`. SVG root values MUST match the assigned definition. A Layout key belongs to exactly one Master and is globally unique; an unused Layout uses a template SVG source and stays registered without a published carrier slide. Every structured route requires numeric `spec_lock.md` typography `title` / `body` rows — they become the Master text styles.
|
**Project lock rows**: Master `<master_key>: <PowerPoint picker name>`; unique Layout `<layout_key>: <master_key> | <PowerPoint picker name> | <prototype source>` where the source is a generated `P<NN>` or installed `template:<basename>`; page assignment `P<NN>: <layout_key>` under `page_pptx_layouts`. SVG root values MUST match the assigned definition. A Layout key belongs to exactly one Master and is globally unique; an unused Layout is compiled only when explicitly listed in `pptx_layouts` with a template SVG source; it has no published carrier slide. Every structured route requires numeric `spec_lock.md` typography `title` / `body` rows — they become the Master text styles.
|
||||||
|
|
||||||
**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 is an analysis fact carried by the two optional root booleans below; authored `standard` / `fidelity` templates normally omit both (see [`conversion.md`](../scripts/docs/conversion.md#import-compatibility-and-recovery-boundary)).
|
**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 is an analysis fact carried by the two optional root booleans below; authored `standard` / `fidelity` templates normally omit both (see [`conversion.md`](../scripts/docs/conversion.md#import-compatibility-and-recovery-boundary)).
|
||||||
|
|
||||||
|
|||||||
+5
-5
@@ -12,7 +12,7 @@ Conditional extension for applying an installed Brand/Style/Layout/Deck workspac
|
|||||||
|
|
||||||
**Inputs**: every installed `<project_path>/templates/design_spec.<kind>.<id>.md` is a template-design source; read all of them. A `design_spec.style.*.md` file marks an active Direction / method segment. A legacy or incomplete Layout/Deck is rejected under [`pptx-structure-interface.md`](./pptx-structure-interface.md) §3; never mutate the input.
|
**Inputs**: every installed `<project_path>/templates/design_spec.<kind>.<id>.md` is a template-design source; read all of them. A `design_spec.style.*.md` file marks an active Direction / method segment. A legacy or incomplete Layout/Deck is rejected under [`pptx-structure-interface.md`](./pptx-structure-interface.md) §3; never mutate the input.
|
||||||
|
|
||||||
**Hard rule — no Stage-1 influence**: never revise a confirmed Stage 1 to match the workspace (Stage-1 evidence boundary: [`generate-pptx.md`](../workflows/generate-pptx.md) Step 4).
|
**Hard rule — no Stage-1 influence**: never revise a confirmed Stage 1 to match the workspace (Stage-1 goal boundary: [`generate-pptx.md`](../workflows/generate-pptx.md) Step 4).
|
||||||
|
|
||||||
**Outputs**:
|
**Outputs**:
|
||||||
|
|
||||||
@@ -38,13 +38,13 @@ Conditional extension for applying an installed Brand/Style/Layout/Deck workspac
|
|||||||
|
|
||||||
**Plan wording**: for Layout/Deck, state prototype use/order, what stays literal, and what may change, naming exact SVG basenames for prototype-specific exceptions rather than roles such as "cover". For Brand/Style, state identity or Direction / method constraints and free composition unless structure comes from another workspace.
|
**Plan wording**: for Layout/Deck, state prototype use/order, what stays literal, and what may change, naming exact SVG basenames for prototype-specific exceptions rather than roles such as "cover". For Brand/Style, state identity or Direction / method constraints and free composition unless structure comes from another workspace.
|
||||||
|
|
||||||
**Two-stage boundary**: an installed template changes the content of final Stage 2, never the confirmation sequence. Run Stage 1 → Stage 2 in order in both Confirm UI and chat fallback; template inspection is not 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 → Stage 2 in order in both Confirm UI and chat fallback; template inspection is not user confirmation.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 2. Scenario Fit and Inherited Design
|
## 2. Scenario Fit and Inherited Design
|
||||||
|
|
||||||
**Mandatory — decide from the §1 inspection**: for `kind: deck`, compare the retained Template Overview with the confirmed audience, intent, outcome, delivery context, afterlife, and source obligations, and its Page Roster and SVG roster with required narrative roles, content shapes, slots, and capacity; Deck application is reusable context, never the current contract or an override. 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. Reopen a resource only when its path + SHA changed.
|
**Mandatory — decide from the §1 inspection**: for `kind: deck`, compare the retained Template Overview with the confirmed audience, intent, outcome, delivery context, afterlife, and source obligations, and its Page Roster and SVG roster with required narrative roles, content shapes, slots, and capacity; Deck application is reusable context, never the current contract or an override. 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 |
|
||||||
|---|---|
|
|---|---|
|
||||||
@@ -70,9 +70,9 @@ When the communication contract conflicts with the workspace, state the best-fit
|
|||||||
A Style installed alongside Layout/Deck changes only Direction / method and never forces flat/structured routing; a literal `mirror` plan is compatible only when the Style segment requires no visual or topology change.
|
A Style installed alongside Layout/Deck changes only Direction / method and never forces flat/structured routing; a literal `mirror` plan is compatible only when the Style segment requires no visual or topology change.
|
||||||
|
|
||||||
- **Master roster**: one `pptx_masters` row per Master as `<master_key>: <picker name>`, copied from the workspace roster. Keys are 1–64 ASCII letters/digits/dots/underscores/hyphens starting with a letter or digit; spaces belong only in the picker name. Master visuals are root-level atoms, never `<g>`.
|
- **Master roster**: one `pptx_masters` row per Master as `<master_key>: <picker name>`, copied from the workspace roster. Keys are 1–64 ASCII letters/digits/dots/underscores/hyphens starting with a letter or digit; spaces belong only in the picker name. Master visuals are root-level atoms, never `<g>`.
|
||||||
- **Reusable Layout roster**: every unique Layout once as `<layout_key>: <master_key> | <PowerPoint layout name> | <prototype source>`. Each installed `template:<basename>` is a complete Slide prototype, including ones not selected for this deck; a new adaptive Layout uses its first generated `P<NN>`. 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. Zero-slot Layouts are valid; do not manufacture an empty `utility` kind or a full-page fake slot.
|
- **Registered Layouts**: list used keys and only deliberately retained unused keys once as `<layout_key>: <master_key> | <PowerPoint layout name> | <prototype source>`. An unused Layout uses `template:<basename>`; a new adaptive Layout uses its first generated `P<NN>`. Reuse keys only for identical fixed atoms and slot ids/types/indices/bounds/bindings. Name authored keys after composition, never topic. Zero-slot Layouts are valid; never invent an empty `utility` kind or full-page fake slot.
|
||||||
- **Page assignment**: 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**: 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 from the safe area, column, panel inset, or media frame — not sample text ink — and exactly one compatible direct carrier. A genuinely composite region uses only the explicit `object` + `proxy` downgrade.
|
- **Slot fit**: consult the advisory [capacity report](../scripts/docs/template-tools.md#advisory-slot-capacity), then measure actual page text at the planned family/anchor size (`text_measure.py`) and check each prototype carrier's baseline stays inside its frame (`top + 0.85×size ≤ baseline ≤ bottom − 0.35×size`). If either fails, revise expression, fix the carrier baseline, choose another prototype, or declare an adaptive Layout; never go below a role band or widen slots in Executor. Slot syntax and `object` + `proxy`: [`pptx-structure-interface.md`](./pptx-structure-interface.md) §2.
|
||||||
- **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 the contract; export only compiles declared structure.
|
- **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 the contract; export only compiles declared structure.
|
||||||
- **Input prototypes**: 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 authored visuals and text-node topology; a JSON-first Chart/Table may regenerate only its derived preview children.
|
- **Input prototypes**: 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 authored visuals and text-node topology; a JSON-first Chart/Table may regenerate only its derived preview children.
|
||||||
|
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ Canvas formats and their typography scale start: [`canvas-formats.md`](canvas-fo
|
|||||||
|
|
||||||
**Default — continuity-aware whole solution (may override when a scene reset communicates better)**: before recommending page count or production mechanics, judge whether adjacent beats can stay within one recognizable mental map while a visible state changes. Where that lowers cognitive switching and motion has a named job, let it shape the spine, rhythm, visual approach, and notes/narration segmentation, and recommend `proactive_custom_animations: true`. Plan such neighbors as visible states of one scene: recognizable anchors kept, the delta legible, each enabled notes/narration segment aligned with its state, every state page still carrying content and an `Audience move`. Reset when the map changes or continuity adds nothing. One positive signal, not the only one: topic or wording repetition alone is insufficient, and a `Motion suggestion` never changes the effective outcome.
|
**Default — continuity-aware whole solution (may override when a scene reset communicates better)**: before recommending page count or production mechanics, judge whether adjacent beats can stay within one recognizable mental map while a visible state changes. Where that lowers cognitive switching and motion has a named job, let it shape the spine, rhythm, visual approach, and notes/narration segmentation, and recommend `proactive_custom_animations: true`. Plan such neighbors as visible states of one scene: recognizable anchors kept, the delta legible, each enabled notes/narration segment aligned with its state, every state page still carrying content and an `Audience move`. Reset when the map changes or continuity adds nothing. One positive signal, not the only one: topic or wording repetition alone is insufficient, and a `Motion suggestion` never changes the effective outcome.
|
||||||
|
|
||||||
**Hard rule — Stage-1 source boundary**: the evidence Stage 1 may use is fixed by [`generate-pptx.md`](../workflows/generate-pptx.md) Step 4; template controls on the same surface are confirmation state, not evidence. Load [`strategist-template.md`](./strategist-template.md) only after Stage 1 is confirmed and the selection installed.
|
**Hard rule — Stage-1 goal boundary**: what a template may inform in Stage 1 is fixed by [`generate-pptx.md`](../workflows/generate-pptx.md) Step 4; template controls on the same surface are confirmation state, never a reason to reshape the contract. Load [`strategist-template.md`](./strategist-template.md) only after Stage 1 is confirmed and the selection installed.
|
||||||
|
|
||||||
> **Execution discipline**: Stage 1 is the first BLOCKING checkpoint; its receipt is intermediate and never ends the task. In the same run, install/fuse the selection, complete the handoff, author fresh Stage 2, and enter the final wait. After final confirmation proceed without another pause unless refinement is enabled; the only opt-in exception is [`refine-spec`](../workflows/stages/refine-spec.md), offered with the split-mode note and never entered unprompted.
|
> **Execution discipline**: Stage 1 is the first BLOCKING checkpoint; its receipt is intermediate and never ends the task. In the same run, install/fuse the selection, complete the handoff, author fresh Stage 2, and enter the final wait. After final confirmation proceed without another pause unless refinement is enabled; the only opt-in exception is [`refine-spec`](../workflows/stages/refine-spec.md), offered with the split-mode note and never entered unprompted.
|
||||||
>
|
>
|
||||||
|
|||||||
+18
-18
@@ -27,7 +27,7 @@ Generate reusable structured page templates inside the workspace selected by Cre
|
|||||||
| Mode | Authoritative inputs | Model-facing inputs |
|
| Mode | Authoritative inputs | Model-facing inputs |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `standard` / `fidelity` | The brief for the newly designed output; `analysis/manifest.json` for canvas/theme/resources | `authoring_summary.json`, every layered source Master/Layout as structural and visual evidence, layered Slides, optional flat spot checks, exported resources — never `authoring_manifest.json`. `standard` authors a compact result, `fidelity` broader source-aligned coverage; neither copies source identities merely because they exist |
|
| `standard` / `fidelity` | The brief for the newly designed output; `analysis/manifest.json` for canvas/theme/resources | `authoring_summary.json`, every layered source Master/Layout as structural and visual evidence, layered Slides, optional flat spot checks, exported resources — never `authoring_manifest.json`. `standard` authors a compact result, `fidelity` broader source-aligned coverage; neither copies source identities merely because they exist |
|
||||||
| `mirror` | Inline native Chart/Table JSON plus `manifest.json`, `native_structure.json`, `svg/inheritance.json`; the publisher validates the tool-only manifest | `authoring_summary.json` plus every reachable layered compact SVG as the editable source; optional `authoring-svg-flat/` for verification; lossless `svg/` only for validation and non-visible payload recovery, never visible-subtree copying |
|
| `mirror` | Unchanged Type-A import workspace, consumed by the [publication tools](../scripts/docs/template-tools.md#mirror-publication) | Source summaries and asset evidence needed for reuse/Design Spec judgment, plus the publication receipt |
|
||||||
|
|
||||||
**Mandatory — authored construction bundle**: as soon as `replication_mode` resolves to `standard` or `fidelity`, and before selecting any contour, read [`native-shape-authoring.md`](./native-shape-authoring.md) and [`preset-shape-vocabulary.md`](./preset-shape-vocabulary.md) completely; never load them for `mirror`.
|
**Mandatory — authored construction bundle**: as soon as `replication_mode` resolves to `standard` or `fidelity`, and before selecting any contour, read [`native-shape-authoring.md`](./native-shape-authoring.md) and [`preset-shape-vocabulary.md`](./preset-shape-vocabulary.md) completely; never load them for `mirror`.
|
||||||
|
|
||||||
@@ -43,13 +43,13 @@ Generate reusable structured page templates inside the workspace selected by Cre
|
|||||||
| Mode | Output structure contract |
|
| Mode | Output structure contract |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `standard` / `fidelity` | Review the complete source Master/Layout inventory, then author complete Slide SVG prototypes and an intentional new Master/Layout/slot system; every retained Layout has at least one prototype; `standard` stays compact, `fidelity` retains broader useful families; source identities never define output topology. Choose page-fit contours from the full native vocabulary before their authoring forms: exact native atoms independent, a Boolean result only where one contour requires it, freeform last |
|
| `standard` / `fidelity` | Review the complete source Master/Layout inventory, then author complete Slide SVG prototypes and an intentional new Master/Layout/slot system; every retained Layout has at least one prototype; `standard` stays compact, `fidelity` retains broader useful families; source identities never define output topology. Choose page-fit contours from the full native vocabulary before their authoring forms: exact native atoms independent, a Boolean result only where one contour requires it, freeform last |
|
||||||
| `mirror` | Review and author one complete compact SVG per validated source Slide with only its referenced Layout and parent Master, keeping reachable identities, parentage, assignment, placeholder facts, inline JSON authority, and meaning; presentation recognizably similar, nodes and code not isomorphic; publication completes inherited context and maps fixed-layer groups into direct atoms without inventing facts |
|
| `mirror` | Publishes unchanged Type-A importer output through the [tool chain](../scripts/docs/template-tools.md#mirror-publication), then completes Design Spec judgment and reports source limitations |
|
||||||
|
|
||||||
Every output is a complete standalone Slide preview resolving Master + Layout + Slide context with explicit layer markers; standalone Master/Layout definition SVGs are not template artifacts.
|
Every output is a complete standalone Slide preview resolving Master + Layout + Slide context with explicit layer markers; standalone Master/Layout definition SVGs are not template artifacts.
|
||||||
|
|
||||||
**Authored preset rule**: in `standard` / `fidelity`, when one registered preset exactly expresses one complete object, use `preset_shape_svg.py` per [`native-shape-authoring.md`](./native-shape-authoring.md). Its compact canonical `<g>` is one semantic atom after validation — Slide-local, the one carrier of an `object` slot, or a Master/Layout fixed layer — and the only `<g>` exception to fixed-layer atomicity. Paint comes from the brief and spec color scheme. Never copy an expanded import carrier/preview/fingerprint bundle into an authored template; `mirror` authors from the compact parsed SVG and never transplants the lossless subtree. When one preset is insufficient, apply the same reference's compound-page gate. Syntax and validation are owned by `shared-standards-core.md` and the native-shape reference.
|
**Authored preset rule**: in `standard` / `fidelity`, when one registered preset exactly expresses one complete object, use `preset_shape_svg.py` per [`native-shape-authoring.md`](./native-shape-authoring.md). Its compact canonical `<g>` is one semantic atom after validation — Slide-local, the one carrier of an `object` slot, or a Master/Layout fixed layer — and the only `<g>` exception to fixed-layer atomicity. Paint comes from the brief and spec color scheme. Never copy an expanded import carrier/preview/fingerprint bundle into an authored template; `mirror` publishes importer output through the tool path. When one preset is insufficient, apply the same reference's compound-page gate. Syntax and validation are owned by `shared-standards-core.md` and the native-shape reference.
|
||||||
|
|
||||||
**Hard rule — reachable mirror graph**: exactly one prototype per source Slide, preserving only the transitive `Slide → Layout → Master` chain; identities outside that closure produce no SVG (re-author useful ones through `standard` / `fidelity`).
|
**Mirror scope**: source Slides and their reachable structure define the published roster; [the publisher](../scripts/docs/template-tools.md#mirror-publication) validates that closure. Re-author other useful identities through `standard` / `fidelity`.
|
||||||
|
|
||||||
**Hard rule — no duplicate authored Layout contracts**: distinct authored keys differ in fixed atoms or slot topology/type/index/bounds/binding; topic, sample wording, or Slide-local content never justifies another key. Mirror keeps distinct reachable source identities even when visibly equivalent.
|
**Hard rule — no duplicate authored Layout contracts**: distinct authored keys differ in fixed atoms or slot topology/type/index/bounds/binding; topic, sample wording, or Slide-local content never justifies another key. Mirror keeps distinct reachable source identities even when visibly equivalent.
|
||||||
|
|
||||||
@@ -65,7 +65,7 @@ Every output is a complete standalone Slide preview resolving Master + Layout +
|
|||||||
| `fidelity` | Broader, source-aligned but newly designed coverage | Canonical roles plus intentionally designed variants covering the useful source composition range |
|
| `fidelity` | Broader, source-aligned but newly designed coverage | Canonical roles plus intentionally designed variants covering the useful source composition range |
|
||||||
| `mirror` | Preserving validated native source facts and a similar presentation | One compact prototype per source slide, `<NNN>_<page_type>.svg` in source order |
|
| `mirror` | Preserving validated native source facts and a similar presentation | One compact prototype per source slide, `<NNN>_<page_type>.svg` in source order |
|
||||||
|
|
||||||
**Hard rule — mode controls authorship**: `standard` / `fidelity` inspect the complete source but create new SVGs and their own Master/Layout system; `mirror` authors compact new SVG from parsed evidence while retaining the validated closure rather than distilling, supplementing, or redesigning it.
|
**Hard rule — mode controls authorship**: `standard` / `fidelity` inspect source evidence and author new SVGs and structure; Type-A `mirror` publishes unchanged importer output. Its model work is reuse judgment, asset adoption and Design Spec completion.
|
||||||
|
|
||||||
### Standard mode
|
### Standard mode
|
||||||
|
|
||||||
@@ -86,21 +86,21 @@ Design a broader roster close to the source's visual language with an independen
|
|||||||
|
|
||||||
### Mirror mode
|
### Mirror mode
|
||||||
|
|
||||||
Author a new compact workspace from validated parsed evidence rather than a different system. Create Layout mirror is legal only when the source contract is already brand-neutral and application-neutral; otherwise return to dispatch (author a Layout through `standard` / `fidelity`, or retain a Deck) — removing, repainting, retyping, or discarding rules is never mirror.
|
Use the Type-A [mirror publication tools](../scripts/docs/template-tools.md#mirror-publication) without editing importer output. Create Layout mirror is legal only when the source contract is already brand-neutral and application-neutral; otherwise return to dispatch (author a Layout through `standard` / `fidelity`, or retain a Deck) — removing, repainting, retyping, or discarding rules is never mirror.
|
||||||
|
|
||||||
**Model-facing source**: `authoring_summary.json`, every reachable layered `authoring-svg/*.svg`, `svg/inheritance.json`, `native_structure.json`; inspect and where needed redraw these before publication; never read `authoring_manifest.json`; lossless `svg/` is immutable evidence.
|
**Model-facing evidence**: source summaries, selected assets and the publication receipt support reuse and Design Spec decisions; tools consume and validate the complete importer workspace.
|
||||||
|
|
||||||
**Precondition**: the evidence identifies every source Slide with its Layout/Master, picker names, placeholder contract, and fixed layers — stop when reachable facts or supported geometry are missing.
|
**Precondition**: the publication chain succeeds; report source-recovery limitations and do not invent missing facts.
|
||||||
|
|
||||||
**Output**: `<template_workspace>/templates/<NNN>_<page_type>.svg` per source Slide (type from `pageTypeCandidates`, fallback `content`), no standalone Master/Layout SVG, each resolving full context with explicit layer markers.
|
**Output**: use the tool-generated roster and Source Preservation Map as facts; complete the Design Spec judgment.
|
||||||
|
|
||||||
| Mirror boundary | Content |
|
| Mirror boundary | Content |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Preserve within the closure | Keys and picker names, parentage, assignments, placeholder type/index/bounds, example meaning, sprite-sheet crop behavior, supported native facts; imported Chart/Table JSON is authoritative with an approximate preview |
|
| Preserve within the closure | Keys and picker names, parentage, assignments, placeholder type/index/bounds, example meaning, sprite-sheet crop behavior, supported native facts; imported Chart/Table JSON is authoritative with an approximate preview |
|
||||||
| Allowed | Redrawing geometry, paint spelling, grouping, root declarations, asset paths, and fixed-layer wrappers while presentation and ownership stay intact |
|
| Allowed | Tool normalization during publication; model-authored reuse judgment, asset adoption and Design Spec prose |
|
||||||
| Forbidden | Commonality extraction, synthesis, promotion/demotion, renaming, re-parenting, placeholder invention, JSON changes without intent, visible redesign |
|
| Forbidden | Commonality extraction, synthesis, promotion/demotion, renaming, re-parenting, placeholder invention, JSON changes without intent, visible redesign |
|
||||||
|
|
||||||
Mirror describes source-to-workspace fidelity and only makes literal reuse possible; Strategist independently decides selection, repetition, order, and reorganization. Mirror is not a recovery mode: charts, SmartArt, OLE, and EMF/WMF that fail to enter the parsed evidence stay gaps — report them before authoring.
|
Mirror describes source-to-workspace fidelity and only makes literal reuse possible; Strategist independently decides selection, repetition, order, and reorganization. Mirror is not a recovery mode: report charts, SmartArt, OLE, and EMF/WMF gaps from the import/publication receipt before completing the Design Spec.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -157,7 +157,7 @@ page_count: <N>
|
|||||||
- Motifs that ARE this template; source-derived grammar — grid/column rhythm, chrome, image zones, crop/clip, scrim/overlay or baked alpha, density rhythm; optional XML for a unique reusable component
|
- Motifs that ARE this template; source-derived grammar — grid/column rhythm, chrome, image zones, crop/clip, scrim/overlay or baked alpha, density rhythm; optional XML for a unique reusable component
|
||||||
|
|
||||||
## V. Page Roster
|
## V. Page Roster
|
||||||
One row per Slide SVG: background, decorative anchors, rhythm, image behavior, density, role, reusable slots, capacity; the authored Layout key and picker name (mirror: the preserved keys). No required/optional/repeatable status or fixed/replaceable/example-only policy. Entries match every SVG on disk. Mirror adds `### Source Preservation Map` (`Source slide | Source Master | Source Layout | Output SVG | Preservation status`) plus one optional sentence for unmaterialized identities.
|
One row per Slide SVG: background, anchors, rhythm, image behavior, density, role, slot bounds/typography/wrapping, and Layout key/picker name (mirror: preserved keys); consult the advisory [capacity report](../scripts/docs/template-tools.md#advisory-slot-capacity) before review. No required/optional/repeatable status or fixed/replaceable/example-only policy. Entries match every SVG on disk. Mirror retains the tool-generated `### Source Preservation Map` and its omitted-identity scope; add judgment only where source limitations require it.
|
||||||
|
|
||||||
## VI. Assets (omit when none)
|
## VI. Assets (omit when none)
|
||||||
## VII. Placeholder Overrides (omit when none)
|
## VII. Placeholder Overrides (omit when none)
|
||||||
@@ -183,7 +183,7 @@ Templates strictly follow the brief and `<design_spec_path>`: root `viewBox` equ
|
|||||||
|---|---|
|
|---|---|
|
||||||
| `standard` | The complete lightweight Master/Layout inventory plus enough page IR to understand direction and assets |
|
| `standard` | The complete lightweight Master/Layout inventory plus enough page IR to understand direction and assets |
|
||||||
| `fidelity` | Every Master/Layout and page |
|
| `fidelity` | Every Master/Layout and page |
|
||||||
| `mirror` | Every Slide and chain verified against the summary, `native_structure.json`, and `inheritance.json`; retained/omitted identities reported before authoring; then only that graph is published |
|
| `mirror` | Run the [publication chain](../scripts/docs/template-tools.md#mirror-publication), retain its factual roster, and report source limitations |
|
||||||
|
|
||||||
#### 2.1 PPTX Import Mode Rule
|
#### 2.1 PPTX Import Mode Rule
|
||||||
|
|
||||||
@@ -191,9 +191,9 @@ Templates strictly follow the brief and `<design_spec_path>`: root `viewBox` equ
|
|||||||
|---|---|
|
|---|---|
|
||||||
| `standard` | Reviews complete evidence, then authors a compact canonical roster and structure |
|
| `standard` | Reviews complete evidence, then authors a compact canonical roster and structure |
|
||||||
| `fidelity` | Authors a broader source-aligned roster matching the visual language without one-to-one identity retention |
|
| `fidelity` | Authors a broader source-aligned roster matching the visual language without one-to-one identity retention |
|
||||||
| `mirror` | Preserves validated Slides, inheritance, placeholders, native facts, meaning, and presentation while authoring a compact workspace — visible SVG may be redrawn, retained structure cannot be renamed, gaps cannot be invented |
|
| `mirror` | Publishes unchanged importer output through the tool path; the model completes reuse judgment, asset adoption and the Design Spec |
|
||||||
|
|
||||||
**Hard rule — mirror publication is mechanical, visual authoring is not**: the materializer validates identity/SHA, refs, graph, assignments, and closure, composes inherited context, strips IR-only refs, and publishes the current tree; it never replaces an unchanged visible subtree with lossless XML.
|
**Mirror publication**: [template-tools.md](../scripts/docs/template-tools.md#mirror-publication) owns validation, normalization and publication.
|
||||||
|
|
||||||
#### 2.2 Native Shape Payload and Authoring IR
|
#### 2.2 Native Shape Payload and Authoring IR
|
||||||
|
|
||||||
@@ -202,9 +202,9 @@ Templates strictly follow the brief and `<design_spec_path>`: root `viewBox` equ
|
|||||||
| Lossless import SVG | Immutable evidence | Retains complete metadata, native boundaries, hidden carriers, scope identity for validation and non-visible recovery; its visible subtree is never copied into templates |
|
| Lossless import SVG | Immutable evidence | Retains complete metadata, native boundaries, hidden carriers, scope identity for validation and non-visible recovery; its visible subtree is never copied into templates |
|
||||||
| Authoring IR bundle | Editable source | Compact SVG from parsed evidence without opaque payload or duplicate carriers; retains visible intent and document-local source refs; models read the summary, tools the manifest |
|
| Authoring IR bundle | Editable source | Compact SVG from parsed evidence without opaque payload or duplicate carriers; retains visible intent and document-local source refs; models read the summary, tools the manifest |
|
||||||
| `standard` / `fidelity` output | Newly authored contract | Editable primitives, compact canonical preset groups for exact matches, `shape_boolean_svg.py` only where one compound contour must become an object, necessary freeform last; paint from the brief/spec; exported assets reused, never opaque payload or source topology |
|
| `standard` / `fidelity` output | Newly authored contract | Editable primitives, compact canonical preset groups for exact matches, `shape_boolean_svg.py` only where one compound contour must become an object, necessary freeform last; paint from the brief/spec; exported assets reused, never opaque payload or source topology |
|
||||||
| `mirror` output | Compact preservation contract | Publishes the reviewed tree, preserves validated structure/native facts, recovers only supported non-visible semantics, normalizes fixed layers into semantic atoms, strips IR-only refs |
|
| `mirror` output | Tool-published preservation contract | [Publication behavior](../scripts/docs/template-tools.md#mirror-publication) |
|
||||||
|
|
||||||
Materialization validates document hashes, refs, and closure and classifies subtree hashes — a changed subtree is a legitimate edit, never permission to copy the old tree back. An object that cannot use supported non-visible metadata keeps its SVG fallback and is reported. `data-pptx-replace-with` stays reserved for Chart/Table replacement markers.
|
Materialization and source-limitation reporting follow [template-tools.md](../scripts/docs/template-tools.md#mirror-publication). `data-pptx-replace-with` stays reserved for Chart/Table replacement markers.
|
||||||
|
|
||||||
Every template SVG satisfies the structured metadata contract of [`pptx-structure-interface.md`](./pptx-structure-interface.md) §2, and a legacy contract (its §3) never enters a package. Template-side additions:
|
Every template SVG satisfies the structured metadata contract of [`pptx-structure-interface.md`](./pptx-structure-interface.md) §2, and a legacy contract (its §3) never enters a package. Template-side additions:
|
||||||
|
|
||||||
@@ -227,7 +227,7 @@ Mirror retains literal source text and placeholder metadata and inserts no `{{..
|
|||||||
</g>
|
</g>
|
||||||
<rect x="40" y="90" width="1200" height="550" fill="#FFFFFF" rx="8"/>
|
<rect x="40" y="90" width="1200" height="550" fill="#FFFFFF" rx="8"/>
|
||||||
<g id="body-slot" data-pptx-placeholder="body" data-pptx-bounds="40 90 1200 550">
|
<g id="body-slot" data-pptx-placeholder="body" data-pptx-bounds="40 90 1200 550">
|
||||||
<text id="body-carrier" data-pptx-carrier="true" x="640" y="365" text-anchor="middle" fill="#CBD5E1" font-size="16">{{CONTENT_AREA}}</text>
|
<text id="body-carrier" data-pptx-carrier="true" x="64" y="122" fill="#CBD5E1" font-size="16">{{CONTENT_AREA}}</text>
|
||||||
</g>
|
</g>
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -22,10 +22,13 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import filecmp
|
import filecmp
|
||||||
|
import hashlib
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
|
import tempfile
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -51,12 +54,13 @@ CHECKER = SCRIPT_DIR / "svg_quality_checker.py"
|
|||||||
KINDS = ("brand", "style", "layout", "deck")
|
KINDS = ("brand", "style", "layout", "deck")
|
||||||
STRUCTURAL_KINDS = ("layout", "deck")
|
STRUCTURAL_KINDS = ("layout", "deck")
|
||||||
ASSET_DIRS = ("images", "icons")
|
ASSET_DIRS = ("images", "icons")
|
||||||
|
INSTALL_RECEIPT_NAME = "template_install.json"
|
||||||
BITMAP_SUFFIXES = {
|
BITMAP_SUFFIXES = {
|
||||||
".bmp", ".gif", ".jpeg", ".jpg", ".png", ".tif", ".tiff", ".webp",
|
".bmp", ".gif", ".jpeg", ".jpg", ".png", ".tif", ".tiff", ".webp",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
class ApplyTemplateError(Exception):
|
class ApplyTemplateError(ValueError):
|
||||||
"""A contract violation that blocks installation."""
|
"""A contract violation that blocks installation."""
|
||||||
|
|
||||||
|
|
||||||
@@ -109,6 +113,8 @@ class InstallPlan:
|
|||||||
owner_kind: str | None
|
owner_kind: str | None
|
||||||
mappings: list[Mapping] = field(default_factory=list)
|
mappings: list[Mapping] = field(default_factory=list)
|
||||||
removals: list[Path] = field(default_factory=list)
|
removals: list[Path] = field(default_factory=list)
|
||||||
|
root_snapshots: list[dict] = field(default_factory=list)
|
||||||
|
selection_sha256: str | None = None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def installed_specs(self) -> list[str]:
|
def installed_specs(self) -> list[str]:
|
||||||
@@ -378,17 +384,202 @@ def _preflight(plan: InstallPlan) -> None:
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def _write(plan: InstallPlan) -> None:
|
def _file_sha256(path: Path) -> str:
|
||||||
for path in plan.removals:
|
with path.open("rb") as stream:
|
||||||
path.unlink()
|
digest = hashlib.sha256()
|
||||||
for mapping in plan.mappings:
|
for chunk in iter(lambda: stream.read(1024 * 1024), b""):
|
||||||
if mapping.status == "identical":
|
digest.update(chunk)
|
||||||
|
return digest.hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def _workspace_files(root: Path) -> dict[str, str]:
|
||||||
|
return {
|
||||||
|
path.relative_to(root).as_posix(): _file_sha256(path)
|
||||||
|
for name in ("templates", *ASSET_DIRS)
|
||||||
|
for path in _iter_files(root / name)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _root_snapshots(roots: list[RootRecord]) -> list[dict]:
|
||||||
|
return [{
|
||||||
|
"workspace_root": str(record.root),
|
||||||
|
"source": record.source,
|
||||||
|
"kinds": sorted(record.kinds),
|
||||||
|
"specs": [{
|
||||||
|
"kind": spec.kind,
|
||||||
|
"id": spec.template_id,
|
||||||
|
"path": spec.path.relative_to(record.root).as_posix(),
|
||||||
|
} for spec in sorted(record.specs, key=lambda spec: spec.kind)],
|
||||||
|
"files": _workspace_files(record.root),
|
||||||
|
} for record in sorted(roots, key=lambda record: str(record.root))]
|
||||||
|
|
||||||
|
|
||||||
|
def snapshot_template_roots(project: Path, root_args: list[str]) -> list[dict]:
|
||||||
|
"""Freeze the selected roots, kinds, spec identities, and portable file bytes."""
|
||||||
|
roots = [_load_root(arg, project) for arg in root_args]
|
||||||
|
return _root_snapshots(roots)
|
||||||
|
|
||||||
|
|
||||||
|
def _install_contract(snapshots: list[dict]) -> tuple[list[str], dict | None, set[str]]:
|
||||||
|
specs = sorted(
|
||||||
|
f"design_spec.{spec['kind']}.{spec['id']}.md"
|
||||||
|
for root in snapshots for spec in root["specs"]
|
||||||
|
)
|
||||||
|
paths = {f"templates/{name}" for name in specs}
|
||||||
|
for root in snapshots:
|
||||||
|
paths.update(name for name in root["files"] if name.startswith(("images/", "icons/")))
|
||||||
|
owner = next((root for root in snapshots if "layout" in root["kinds"]), None)
|
||||||
|
owner_kind = "layout"
|
||||||
|
if owner is None:
|
||||||
|
owner = next((root for root in snapshots if "deck" in root["kinds"]), None)
|
||||||
|
owner_kind = "deck"
|
||||||
|
roster = None
|
||||||
|
if owner is not None:
|
||||||
|
structural = sorted(
|
||||||
|
name for name in owner["files"]
|
||||||
|
if name.startswith("templates/")
|
||||||
|
and not _is_spec_file(Path(name))
|
||||||
|
and Path(name).suffix.lower() not in BITMAP_SUFFIXES
|
||||||
|
)
|
||||||
|
paths.update(structural)
|
||||||
|
roster = {"workspace_root": owner["workspace_root"], "kind": owner_kind, "files": structural}
|
||||||
|
return specs, roster, paths
|
||||||
|
|
||||||
|
|
||||||
|
def read_template_install(
|
||||||
|
project: Path,
|
||||||
|
snapshots: list[dict],
|
||||||
|
selection_sha256: str | None,
|
||||||
|
*,
|
||||||
|
content_root: Path | None = None,
|
||||||
|
) -> dict:
|
||||||
|
"""Verify installer provenance and every installed spec, roster file, and asset.
|
||||||
|
|
||||||
|
Extra project images/icons are allowed; selected assets and the exact active
|
||||||
|
template roster must still match. ``content_root`` validates staging before
|
||||||
|
publication using the same checks as Confirm UI handoff.
|
||||||
|
"""
|
||||||
|
content_root = content_root or project
|
||||||
|
receipt_path = content_root / INSTALL_RECEIPT_NAME
|
||||||
|
try:
|
||||||
|
receipt = json.loads(receipt_path.read_text(encoding="utf-8"))
|
||||||
|
except (OSError, ValueError) as exc:
|
||||||
|
raise ApplyTemplateError(f"Cannot read {receipt_path}; run apply_template.py: {exc}") from exc
|
||||||
|
if not isinstance(receipt, dict) or receipt.get("schema_version") != 1:
|
||||||
|
raise ApplyTemplateError(f"Invalid {INSTALL_RECEIPT_NAME}; rerun apply_template.py")
|
||||||
|
if (receipt.get("project") != str(project.resolve())
|
||||||
|
or receipt.get("roots") != snapshots
|
||||||
|
or receipt.get("selection_sha256") != selection_sha256):
|
||||||
|
raise ApplyTemplateError("Template installation does not match the confirmed roots/snapshot")
|
||||||
|
specs, roster, expected_paths = _install_contract(snapshots)
|
||||||
|
files = receipt.get("files")
|
||||||
|
if (receipt.get("installed_specs") != specs or receipt.get("active_roster") != roster
|
||||||
|
or not isinstance(files, dict) or set(files) != expected_paths):
|
||||||
|
raise ApplyTemplateError("Template installation spec/roster/asset receipt is incomplete")
|
||||||
|
actual_specs = sorted(path.name for path in (content_root / "templates").glob("design_spec*.md"))
|
||||||
|
actual_roster = {
|
||||||
|
path.relative_to(content_root).as_posix()
|
||||||
|
for path in _iter_files(content_root / "templates")
|
||||||
|
if not _is_spec_file(path) and path.suffix.lower() not in BITMAP_SUFFIXES
|
||||||
|
}
|
||||||
|
if actual_specs != specs or actual_roster != set(roster["files"] if roster else []):
|
||||||
|
raise ApplyTemplateError("Installed template specs or active roster differ from the selection")
|
||||||
|
for name, digest in files.items():
|
||||||
|
path = content_root / name
|
||||||
|
if not path.is_file() or _file_sha256(path) != digest:
|
||||||
|
raise ApplyTemplateError(f"Installed template file changed or is missing: {name}")
|
||||||
|
return receipt
|
||||||
|
|
||||||
|
|
||||||
|
def validate_template_snapshot_sources(
|
||||||
|
project: Path, snapshots: list[dict], selection_sha256: str,
|
||||||
|
) -> None:
|
||||||
|
"""Reject source drift, allowing only an installer-proven in-place change."""
|
||||||
|
for root in snapshots:
|
||||||
|
path = Path(root["workspace_root"])
|
||||||
|
if _workspace_files(path) == root["files"]:
|
||||||
continue
|
continue
|
||||||
mapping.dst.parent.mkdir(parents=True, exist_ok=True)
|
if path == project.resolve():
|
||||||
if mapping.content is not None:
|
read_template_install(project, snapshots, selection_sha256)
|
||||||
mapping.dst.write_bytes(mapping.content)
|
|
||||||
else:
|
else:
|
||||||
shutil.copy2(mapping.src, mapping.dst)
|
raise ApplyTemplateError(f"Confirmed template source changed: {path}; confirm Stage 1 again")
|
||||||
|
|
||||||
|
|
||||||
|
def _write(plan: InstallPlan) -> None:
|
||||||
|
"""Stage and verify the full install, then publish with rollback on failure.
|
||||||
|
|
||||||
|
Directory renames are atomic individually. The journal restores all earlier
|
||||||
|
renames if any publication fails, including removed Deck files and the old
|
||||||
|
receipt. Backups survive if rollback itself fails so recovery remains possible.
|
||||||
|
"""
|
||||||
|
transaction = Path(tempfile.mkdtemp(prefix=".template-install-", dir=plan.project))
|
||||||
|
staged = transaction / "staged"
|
||||||
|
backup = transaction / "backup"
|
||||||
|
moved: list[str] = []
|
||||||
|
published: list[str] = []
|
||||||
|
cleanup = True
|
||||||
|
try:
|
||||||
|
staged.mkdir()
|
||||||
|
backup.mkdir()
|
||||||
|
names = ["templates", *ASSET_DIRS]
|
||||||
|
for name in names:
|
||||||
|
current = plan.project / name
|
||||||
|
if current.exists():
|
||||||
|
shutil.copytree(current, staged / name)
|
||||||
|
for path in plan.removals:
|
||||||
|
(staged / path.relative_to(plan.project)).unlink()
|
||||||
|
for mapping in plan.mappings:
|
||||||
|
destination = staged / mapping.dst.relative_to(plan.project)
|
||||||
|
if mapping.status != "identical":
|
||||||
|
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
if mapping.content is not None:
|
||||||
|
destination.write_bytes(mapping.content)
|
||||||
|
else:
|
||||||
|
shutil.copy2(mapping.src, destination)
|
||||||
|
expected = (
|
||||||
|
hashlib.sha256(mapping.content).hexdigest()
|
||||||
|
if mapping.content is not None else _file_sha256(mapping.src)
|
||||||
|
)
|
||||||
|
if _file_sha256(destination) != expected:
|
||||||
|
raise ApplyTemplateError(f"Staged file does not match its source: {mapping.dst}")
|
||||||
|
if _root_snapshots(plan.roots) != plan.root_snapshots:
|
||||||
|
raise ApplyTemplateError("Template source changed during staging; retry the installation")
|
||||||
|
specs, roster, paths = _install_contract(plan.root_snapshots)
|
||||||
|
receipt = {
|
||||||
|
"schema_version": 1,
|
||||||
|
"project": str(plan.project),
|
||||||
|
"selection_sha256": plan.selection_sha256,
|
||||||
|
"roots": plan.root_snapshots,
|
||||||
|
"installed_specs": specs,
|
||||||
|
"active_roster": roster,
|
||||||
|
"files": {name: _file_sha256(staged / name) for name in sorted(paths)},
|
||||||
|
}
|
||||||
|
(staged / INSTALL_RECEIPT_NAME).write_text(
|
||||||
|
json.dumps(receipt, ensure_ascii=False, indent=2) + "\n", encoding="utf-8",
|
||||||
|
)
|
||||||
|
read_template_install(plan.project, plan.root_snapshots, plan.selection_sha256, content_root=staged)
|
||||||
|
for name in [*names, INSTALL_RECEIPT_NAME]:
|
||||||
|
if not (staged / name).exists():
|
||||||
|
continue
|
||||||
|
current = plan.project / name
|
||||||
|
if current.exists():
|
||||||
|
os.replace(current, backup / name)
|
||||||
|
moved.append(name)
|
||||||
|
os.replace(staged / name, current)
|
||||||
|
published.append(name)
|
||||||
|
except BaseException:
|
||||||
|
try:
|
||||||
|
for name in reversed(published):
|
||||||
|
os.replace(plan.project / name, staged / name)
|
||||||
|
for name in reversed(moved):
|
||||||
|
os.replace(backup / name, plan.project / name)
|
||||||
|
except OSError as exc:
|
||||||
|
cleanup = False
|
||||||
|
raise ApplyTemplateError(f"Installation rollback failed; recover backups in {transaction}: {exc}") from exc
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
if cleanup:
|
||||||
|
shutil.rmtree(transaction)
|
||||||
|
|
||||||
|
|
||||||
def _receipt(plan: InstallPlan) -> str:
|
def _receipt(plan: InstallPlan) -> str:
|
||||||
@@ -439,6 +630,13 @@ def apply_templates(
|
|||||||
for record in roots:
|
for record in roots:
|
||||||
_validate_root(record)
|
_validate_root(record)
|
||||||
plan = _plan(project, roots)
|
plan = _plan(project, roots)
|
||||||
|
plan.root_snapshots = _root_snapshots(roots)
|
||||||
|
selection_file = project / "confirm_ui" / "template_selection.json"
|
||||||
|
if selection_file.is_file():
|
||||||
|
selection = json.loads(selection_file.read_text(encoding="utf-8"))
|
||||||
|
if selection.get("mode") != "templates" or selection.get("root_snapshots") != plan.root_snapshots:
|
||||||
|
raise ApplyTemplateError("Selected roots or source bytes differ from the Stage-1 confirmation")
|
||||||
|
plan.selection_sha256 = selection["selection_sha256"]
|
||||||
_preflight(plan)
|
_preflight(plan)
|
||||||
if not dry_run:
|
if not dry_run:
|
||||||
_write(plan)
|
_write(plan)
|
||||||
@@ -479,7 +677,7 @@ def main(argv: list[str] | None = None) -> int:
|
|||||||
dry_run=args.dry_run,
|
dry_run=args.dry_run,
|
||||||
validate=not args.skip_validation,
|
validate=not args.skip_validation,
|
||||||
)
|
)
|
||||||
except ApplyTemplateError as exc:
|
except (OSError, ValueError) as exc:
|
||||||
print(f"[ERROR] {exc}", file=sys.stderr)
|
print(f"[ERROR] {exc}", file=sys.stderr)
|
||||||
return 1
|
return 1
|
||||||
verb = "would install" if args.dry_run else "installed"
|
verb = "would install" if args.dry_run else "installed"
|
||||||
@@ -494,6 +692,8 @@ def main(argv: list[str] | None = None) -> int:
|
|||||||
copied = len(plan.mappings) - same
|
copied = len(plan.mappings) - same
|
||||||
note = f", {same} already present" if same else ""
|
note = f", {same} already present" if same else ""
|
||||||
print(f"[OK] {verb} {copied} file(s) into {plan.project}{note}")
|
print(f"[OK] {verb} {copied} file(s) into {plan.project}{note}")
|
||||||
|
if not args.dry_run:
|
||||||
|
print(f"[receipt] {plan.project / INSTALL_RECEIPT_NAME}")
|
||||||
print(_receipt(plan))
|
print(_receipt(plan))
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ Lightweight Flask backend for the Default template choice and interactive
|
|||||||
Strategist confirmation page. Stage 1 combines template selection with the
|
Strategist confirmation page. Stage 1 combines template selection with the
|
||||||
communication contract; its submit writes ``template_selection.json`` and the
|
communication contract; its submit writes ``template_selection.json`` and the
|
||||||
stage1-confirmed ``result.json`` in one request. After the agent applies the
|
stage1-confirmed ``result.json`` in one request. After the agent applies the
|
||||||
choice and completes ``template_handoff.json``, final Stage 2 confirms the deck
|
choice, the server verifies ``template_install.json``, final Stage 2 confirms the deck
|
||||||
solution and production plan.
|
solution and production plan.
|
||||||
|
|
||||||
This is the default confirmation surface. The chat fallback is used only when
|
This is the default confirmation surface. The chat fallback is used only when
|
||||||
@@ -24,8 +24,6 @@ Examples:
|
|||||||
python3 scripts/confirm_ui/server.py projects/my-project --no-browser
|
python3 scripts/confirm_ui/server.py projects/my-project --no-browser
|
||||||
python3 scripts/confirm_ui/server.py projects/my-project --daemon
|
python3 scripts/confirm_ui/server.py projects/my-project --daemon
|
||||||
python3 scripts/confirm_ui/server.py projects/my-project --wait-only --wait-stage stage1
|
python3 scripts/confirm_ui/server.py projects/my-project --wait-only --wait-stage stage1
|
||||||
python3 scripts/confirm_ui/server.py projects/my-project --complete-template-selection
|
|
||||||
python3 scripts/confirm_ui/server.py projects/my-project --reset-template-selection
|
|
||||||
|
|
||||||
Dependencies:
|
Dependencies:
|
||||||
flask>=3.0.0
|
flask>=3.0.0
|
||||||
@@ -92,9 +90,8 @@ RECOMMENDATION_STAGE_NAMES = {
|
|||||||
}
|
}
|
||||||
RESULT_NAME = 'result.json'
|
RESULT_NAME = 'result.json'
|
||||||
SESSION_NAME = 'session.json'
|
SESSION_NAME = 'session.json'
|
||||||
TEMPLATE_OPTIONS_NAME = 'template_options.json'
|
TEMPLATE_OPTIONS_LABEL = 'recommendations.stage1.json template_options'
|
||||||
TEMPLATE_SELECTION_NAME = 'template_selection.json'
|
TEMPLATE_SELECTION_NAME = 'template_selection.json'
|
||||||
TEMPLATE_HANDOFF_NAME = 'template_handoff.json'
|
|
||||||
TEMPLATE_SCHEMA_VERSION = 1
|
TEMPLATE_SCHEMA_VERSION = 1
|
||||||
|
|
||||||
_PALETTE_ROLES = (
|
_PALETTE_ROLES = (
|
||||||
@@ -309,36 +306,41 @@ def _template_kind_from_spec(spec_path: Path) -> str:
|
|||||||
return declared_kind
|
return declared_kind
|
||||||
|
|
||||||
|
|
||||||
def _read_template_options_input(confirm_dir: Path) -> tuple[dict, list[Path]]:
|
def _read_template_options_input(
|
||||||
|
confirm_dir: Path, recommendation: dict | None = None,
|
||||||
|
) -> tuple[dict, list[Path]]:
|
||||||
"""Read and validate the agent-authored Step-3 template input."""
|
"""Read and validate the agent-authored Step-3 template input."""
|
||||||
options_file = confirm_dir / TEMPLATE_OPTIONS_NAME
|
if recommendation is None:
|
||||||
data = _read_json_object(options_file)
|
recommendation = _read_json_object(confirm_dir / RECOMMENDATION_STAGE_NAMES[1])
|
||||||
|
data = recommendation.get('template_options')
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
raise ValueError(f'{TEMPLATE_OPTIONS_LABEL} must be an object')
|
||||||
if type(data.get('schema_version')) is not int or data['schema_version'] != TEMPLATE_SCHEMA_VERSION:
|
if type(data.get('schema_version')) is not int or data['schema_version'] != TEMPLATE_SCHEMA_VERSION:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f'{TEMPLATE_OPTIONS_NAME} schema_version must be {TEMPLATE_SCHEMA_VERSION}'
|
f'{TEMPLATE_OPTIONS_LABEL} schema_version must be {TEMPLATE_SCHEMA_VERSION}'
|
||||||
)
|
)
|
||||||
if data.get('phase') != 'template':
|
if data.get('phase') != 'template':
|
||||||
raise ValueError(f'{TEMPLATE_OPTIONS_NAME} phase must be template')
|
raise ValueError(f'{TEMPLATE_OPTIONS_LABEL} phase must be template')
|
||||||
if data.get('default_mode') not in {'free_design', 'templates'}:
|
if data.get('default_mode') not in {'free_design', 'templates'}:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f'{TEMPLATE_OPTIONS_NAME} default_mode must be free_design or templates'
|
f'{TEMPLATE_OPTIONS_LABEL} default_mode must be free_design or templates'
|
||||||
)
|
)
|
||||||
if 'lang' in data and (
|
if 'lang' in data and (
|
||||||
not isinstance(data['lang'], str) or not data['lang'].strip()
|
not isinstance(data['lang'], str) or not data['lang'].strip()
|
||||||
):
|
):
|
||||||
raise ValueError(f'{TEMPLATE_OPTIONS_NAME} lang must be a non-empty string')
|
raise ValueError(f'{TEMPLATE_OPTIONS_LABEL} lang must be a non-empty string')
|
||||||
if 'explicit_workspace_roots' not in data:
|
if 'explicit_workspace_roots' not in data:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f'{TEMPLATE_OPTIONS_NAME} must include explicit_workspace_roots'
|
f'{TEMPLATE_OPTIONS_LABEL} must include explicit_workspace_roots'
|
||||||
)
|
)
|
||||||
raw_roots = data['explicit_workspace_roots']
|
raw_roots = data['explicit_workspace_roots']
|
||||||
if not isinstance(raw_roots, list):
|
if not isinstance(raw_roots, list):
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f'{TEMPLATE_OPTIONS_NAME} explicit_workspace_roots must be an array'
|
f'{TEMPLATE_OPTIONS_LABEL} explicit_workspace_roots must be an array'
|
||||||
)
|
)
|
||||||
if raw_roots and data['default_mode'] != 'templates':
|
if raw_roots and data['default_mode'] != 'templates':
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f'{TEMPLATE_OPTIONS_NAME} default_mode must be templates when '
|
f'{TEMPLATE_OPTIONS_LABEL} default_mode must be templates when '
|
||||||
'explicit_workspace_roots is non-empty'
|
'explicit_workspace_roots is non-empty'
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -347,13 +349,13 @@ def _read_template_options_input(confirm_dir: Path) -> tuple[dict, list[Path]]:
|
|||||||
for index, raw_root in enumerate(raw_roots):
|
for index, raw_root in enumerate(raw_roots):
|
||||||
if not isinstance(raw_root, str) or not raw_root.strip():
|
if not isinstance(raw_root, str) or not raw_root.strip():
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f'{TEMPLATE_OPTIONS_NAME} explicit_workspace_roots[{index}] '
|
f'{TEMPLATE_OPTIONS_LABEL} explicit_workspace_roots[{index}] '
|
||||||
'must be a non-empty string'
|
'must be a non-empty string'
|
||||||
)
|
)
|
||||||
candidate = Path(raw_root)
|
candidate = Path(raw_root)
|
||||||
if not candidate.is_absolute():
|
if not candidate.is_absolute():
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f'{TEMPLATE_OPTIONS_NAME} explicit_workspace_roots[{index}] '
|
f'{TEMPLATE_OPTIONS_LABEL} explicit_workspace_roots[{index}] '
|
||||||
'must be an absolute path'
|
'must be an absolute path'
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
@@ -365,7 +367,7 @@ def _read_template_options_input(confirm_dir: Path) -> tuple[dict, list[Path]]:
|
|||||||
canonical = str(root)
|
canonical = str(root)
|
||||||
if canonical in seen:
|
if canonical in seen:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f'{TEMPLATE_OPTIONS_NAME} contains duplicate workspace root: {canonical}'
|
f'{TEMPLATE_OPTIONS_LABEL} contains duplicate workspace root: {canonical}'
|
||||||
)
|
)
|
||||||
if not root.is_dir():
|
if not root.is_dir():
|
||||||
raise ValueError(f'explicit workspace root is not a directory: {canonical}')
|
raise ValueError(f'explicit workspace root is not a directory: {canonical}')
|
||||||
@@ -447,12 +449,18 @@ def _build_template_library() -> tuple[dict, dict[str, dict], dict[str, dict], d
|
|||||||
return library, candidates, registered_roots, index_contracts
|
return library, candidates, registered_roots, index_contracts
|
||||||
|
|
||||||
|
|
||||||
def _build_template_options(confirm_dir: Path) -> tuple[dict, dict[str, dict]]:
|
def _build_template_options(
|
||||||
|
confirm_dir: Path, root_snapshots: list[dict] | None = None,
|
||||||
|
*, recommendation: dict | None = None,
|
||||||
|
) -> tuple[dict, dict[str, dict]]:
|
||||||
"""Return the browser contract and its server-owned candidate whitelist."""
|
"""Return the browser contract and its server-owned candidate whitelist."""
|
||||||
source, explicit_roots = _read_template_options_input(confirm_dir)
|
if recommendation is None:
|
||||||
|
recommendation = _read_json_object(confirm_dir / RECOMMENDATION_STAGE_NAMES[1])
|
||||||
|
source, explicit_roots = _read_template_options_input(confirm_dir, recommendation)
|
||||||
library, candidates, registered_roots, index_contracts = _build_template_library()
|
library, candidates, registered_roots, index_contracts = _build_template_library()
|
||||||
explicit = []
|
explicit = []
|
||||||
suggested_keys = []
|
suggested_keys = []
|
||||||
|
snapshots = {item['workspace_root']: item for item in root_snapshots or []}
|
||||||
for root in explicit_roots:
|
for root in explicit_roots:
|
||||||
canonical_root = str(root)
|
canonical_root = str(root)
|
||||||
registered = registered_roots.get(canonical_root)
|
registered = registered_roots.get(canonical_root)
|
||||||
@@ -460,7 +468,10 @@ def _build_template_options(confirm_dir: Path) -> tuple[dict, dict[str, dict]]:
|
|||||||
suggested_keys.append(registered['key'])
|
suggested_keys.append(registered['key'])
|
||||||
continue
|
continue
|
||||||
digest = hashlib.sha256(canonical_root.encode('utf-8')).hexdigest()
|
digest = hashlib.sha256(canonical_root.encode('utf-8')).hexdigest()
|
||||||
|
snapshot = snapshots.get(canonical_root)
|
||||||
root_specs = [
|
root_specs = [
|
||||||
|
(root / spec['path'], spec['kind']) for spec in snapshot['specs']
|
||||||
|
] if snapshot is not None else [
|
||||||
(spec, _template_kind_from_spec(spec))
|
(spec, _template_kind_from_spec(spec))
|
||||||
for spec in _template_design_specs(root)
|
for spec in _template_design_specs(root)
|
||||||
]
|
]
|
||||||
@@ -490,8 +501,7 @@ def _build_template_options(confirm_dir: Path) -> tuple[dict, dict[str, dict]]:
|
|||||||
|
|
||||||
# One supplied exact root is an unambiguous convenience default, including
|
# One supplied exact root is an unambiguous convenience default, including
|
||||||
# a multi-kind root whose kinds compose rather than compete. Several roots
|
# a multi-kind root whose kinds compose rather than compete. Several roots
|
||||||
# are candidates for the single-select controls, not an instruction to
|
# remain unselected candidates until the user chooses a combination.
|
||||||
# select all of them.
|
|
||||||
preselected_keys = suggested_keys if len(explicit_roots) == 1 else []
|
preselected_keys = suggested_keys if len(explicit_roots) == 1 else []
|
||||||
|
|
||||||
response = {
|
response = {
|
||||||
@@ -510,6 +520,7 @@ def _build_template_options(confirm_dir: Path) -> tuple[dict, dict[str, dict]]:
|
|||||||
'default_mode': response['default_mode'],
|
'default_mode': response['default_mode'],
|
||||||
'lang': response.get('lang'),
|
'lang': response.get('lang'),
|
||||||
'explicit_workspace_roots': [str(root) for root in explicit_roots],
|
'explicit_workspace_roots': [str(root) for root in explicit_roots],
|
||||||
|
'stage1_sha256': _json_sha256(recommendation),
|
||||||
'library_indexes': index_contracts,
|
'library_indexes': index_contracts,
|
||||||
'library': library,
|
'library': library,
|
||||||
'explicit': explicit,
|
'explicit': explicit,
|
||||||
@@ -534,12 +545,14 @@ def _template_selection_sha256(
|
|||||||
mode: str,
|
mode: str,
|
||||||
selections: list[dict],
|
selections: list[dict],
|
||||||
options_sha256: str,
|
options_sha256: str,
|
||||||
|
root_snapshots: list[dict],
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Bind one resolved choice to the candidate/options contract it used."""
|
"""Bind one resolved choice to the candidate/options contract it used."""
|
||||||
return _json_sha256({
|
return _json_sha256({
|
||||||
'mode': mode,
|
'mode': mode,
|
||||||
'selections': selections,
|
'selections': selections,
|
||||||
'options_sha256': options_sha256,
|
'options_sha256': options_sha256,
|
||||||
|
'root_snapshots': root_snapshots,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@@ -554,6 +567,7 @@ def _validate_template_selection(data: dict) -> None:
|
|||||||
'options_sha256',
|
'options_sha256',
|
||||||
'selection_sha256',
|
'selection_sha256',
|
||||||
'confirmed_at',
|
'confirmed_at',
|
||||||
|
'root_snapshots',
|
||||||
}
|
}
|
||||||
if set(data) != expected_fields:
|
if set(data) != expected_fields:
|
||||||
raise ValueError(f'{TEMPLATE_SELECTION_NAME} has invalid fields')
|
raise ValueError(f'{TEMPLATE_SELECTION_NAME} has invalid fields')
|
||||||
@@ -587,7 +601,6 @@ def _validate_template_selection(data: dict) -> None:
|
|||||||
|
|
||||||
seen_root_kinds = set()
|
seen_root_kinds = set()
|
||||||
seen_kinds: set[str] = set()
|
seen_kinds: set[str] = set()
|
||||||
explicit_roots_seen: dict[str, set[str]] = {}
|
|
||||||
for index, selection in enumerate(selections):
|
for index, selection in enumerate(selections):
|
||||||
if not isinstance(selection, dict):
|
if not isinstance(selection, dict):
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
@@ -632,20 +645,31 @@ def _validate_template_selection(data: dict) -> None:
|
|||||||
f'{TEMPLATE_SELECTION_NAME} selections[{index}] '
|
f'{TEMPLATE_SELECTION_NAME} selections[{index}] '
|
||||||
'workspace_root must be a canonical absolute path'
|
'workspace_root must be a canonical absolute path'
|
||||||
)
|
)
|
||||||
if source == 'explicit':
|
|
||||||
# One explicit workspace root may contribute several kinds, so the
|
|
||||||
# cap counts roots rather than selections.
|
|
||||||
explicit_roots_seen.setdefault(workspace_root, set()).add(kind)
|
|
||||||
if len(explicit_roots_seen) > 1:
|
|
||||||
raise ValueError(
|
|
||||||
'template selection allows at most one explicit workspace'
|
|
||||||
)
|
|
||||||
if (workspace_root, kind) in seen_root_kinds:
|
if (workspace_root, kind) in seen_root_kinds:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f'{TEMPLATE_SELECTION_NAME} contains duplicate workspace root '
|
f'{TEMPLATE_SELECTION_NAME} contains duplicate workspace root '
|
||||||
f'for kind {kind!r}: {workspace_root}'
|
f'for kind {kind!r}: {workspace_root}'
|
||||||
)
|
)
|
||||||
seen_root_kinds.add((workspace_root, kind))
|
seen_root_kinds.add((workspace_root, kind))
|
||||||
|
snapshots = data.get('root_snapshots')
|
||||||
|
if not isinstance(snapshots, list):
|
||||||
|
raise ValueError(f'{TEMPLATE_SELECTION_NAME} requires root_snapshots; confirm Stage 1 again')
|
||||||
|
snapshot_selections = []
|
||||||
|
try:
|
||||||
|
for root in snapshots:
|
||||||
|
if sorted(spec['kind'] for spec in root['specs']) != root['kinds']:
|
||||||
|
raise ValueError('snapshot kinds do not match specs')
|
||||||
|
if not isinstance(root['files'], dict) or any(spec['path'] not in root['files'] for spec in root['specs']):
|
||||||
|
raise ValueError('snapshot is missing spec fingerprints')
|
||||||
|
for spec in root['specs']:
|
||||||
|
item = {'source': root['source'], 'kind': spec['kind'], 'workspace_root': root['workspace_root']}
|
||||||
|
if root['source'] == 'library':
|
||||||
|
item['id'] = spec['id']
|
||||||
|
snapshot_selections.append(item)
|
||||||
|
except (KeyError, TypeError) as exc:
|
||||||
|
raise ValueError(f'{TEMPLATE_SELECTION_NAME} has invalid root_snapshots') from exc
|
||||||
|
if sorted(map(_json_sha256, snapshot_selections)) != sorted(map(_json_sha256, selections)):
|
||||||
|
raise ValueError(f'{TEMPLATE_SELECTION_NAME} root_snapshots do not match selections')
|
||||||
if not isinstance(data.get('confirmed_at'), str) or not data['confirmed_at']:
|
if not isinstance(data.get('confirmed_at'), str) or not data['confirmed_at']:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f'{TEMPLATE_SELECTION_NAME} confirmed_at must be a non-empty string'
|
f'{TEMPLATE_SELECTION_NAME} confirmed_at must be a non-empty string'
|
||||||
@@ -654,6 +678,7 @@ def _validate_template_selection(data: dict) -> None:
|
|||||||
mode,
|
mode,
|
||||||
selections,
|
selections,
|
||||||
options_sha256,
|
options_sha256,
|
||||||
|
snapshots,
|
||||||
)
|
)
|
||||||
if selection_sha256 != expected_selection_sha256:
|
if selection_sha256 != expected_selection_sha256:
|
||||||
raise ValueError(f'{TEMPLATE_SELECTION_NAME} selection_sha256 does not match')
|
raise ValueError(f'{TEMPLATE_SELECTION_NAME} selection_sha256 does not match')
|
||||||
@@ -686,16 +711,15 @@ def _validate_explicit_root_closure(
|
|||||||
|
|
||||||
|
|
||||||
def _read_template_selection(selection_file: Path) -> dict:
|
def _read_template_selection(selection_file: Path) -> dict:
|
||||||
"""Read a selection and revalidate it against current indexed options."""
|
"""Check the frozen choice, source bytes, and still-current options/indexes."""
|
||||||
|
from apply_template import validate_template_snapshot_sources
|
||||||
|
|
||||||
data = _read_json_object(selection_file)
|
data = _read_json_object(selection_file)
|
||||||
_validate_template_selection(data)
|
_validate_template_selection(data)
|
||||||
options_file = selection_file.parent / TEMPLATE_OPTIONS_NAME
|
validate_template_snapshot_sources(
|
||||||
if not _is_newer(selection_file, options_file):
|
selection_file.parent.parent, data['root_snapshots'], data['selection_sha256'],
|
||||||
raise ValueError(
|
)
|
||||||
f'{TEMPLATE_SELECTION_NAME} must be confirmed after the current '
|
options, candidates = _build_template_options(selection_file.parent, data['root_snapshots'])
|
||||||
f'{TEMPLATE_OPTIONS_NAME}'
|
|
||||||
)
|
|
||||||
options, candidates = _build_template_options(selection_file.parent)
|
|
||||||
if data['options_sha256'] != options['options_sha256']:
|
if data['options_sha256'] != options['options_sha256']:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f'{TEMPLATE_SELECTION_NAME} options_sha256 no longer matches current options'
|
f'{TEMPLATE_SELECTION_NAME} options_sha256 no longer matches current options'
|
||||||
@@ -714,188 +738,27 @@ def _read_template_selection(selection_file: Path) -> dict:
|
|||||||
return data
|
return data
|
||||||
|
|
||||||
|
|
||||||
def _validate_template_handoff(data: dict) -> None:
|
def _read_template_installation(project_path: Path) -> dict:
|
||||||
"""Validate the agent-owned Step-3 completion receipt shape."""
|
"""Verify the current selection and the installer's actual output."""
|
||||||
expected_fields = {
|
from apply_template import read_template_install
|
||||||
'schema_version',
|
|
||||||
'phase',
|
|
||||||
'status',
|
|
||||||
'mode',
|
|
||||||
'selection_sha256',
|
|
||||||
'completed_at',
|
|
||||||
}
|
|
||||||
if set(data) != expected_fields:
|
|
||||||
raise ValueError(f'{TEMPLATE_HANDOFF_NAME} has invalid fields')
|
|
||||||
if type(data.get('schema_version')) is not int or data['schema_version'] != TEMPLATE_SCHEMA_VERSION:
|
|
||||||
raise ValueError(
|
|
||||||
f'{TEMPLATE_HANDOFF_NAME} schema_version must be {TEMPLATE_SCHEMA_VERSION}'
|
|
||||||
)
|
|
||||||
if data.get('phase') != 'template':
|
|
||||||
raise ValueError(f'{TEMPLATE_HANDOFF_NAME} phase must be template')
|
|
||||||
if data.get('status') != 'ready':
|
|
||||||
raise ValueError(f'{TEMPLATE_HANDOFF_NAME} status must be ready')
|
|
||||||
if data.get('mode') not in {'free_design', 'templates'}:
|
|
||||||
raise ValueError(
|
|
||||||
f'{TEMPLATE_HANDOFF_NAME} mode must be free_design or templates'
|
|
||||||
)
|
|
||||||
selection_sha256 = data.get('selection_sha256')
|
|
||||||
if not isinstance(selection_sha256, str) or not re.fullmatch(r'[0-9a-f]{64}', selection_sha256):
|
|
||||||
raise ValueError(f'{TEMPLATE_HANDOFF_NAME} selection_sha256 is invalid')
|
|
||||||
if not isinstance(data.get('completed_at'), str) or not data['completed_at']:
|
|
||||||
raise ValueError(
|
|
||||||
f'{TEMPLATE_HANDOFF_NAME} completed_at must be a non-empty string'
|
|
||||||
)
|
|
||||||
|
|
||||||
|
selection = _read_template_selection(
|
||||||
def _read_template_handoff(project_path: Path, handoff_file: Path) -> dict:
|
project_path / CONFIRM_DIR_NAME / TEMPLATE_SELECTION_NAME,
|
||||||
"""Read a handoff and bind it to the current selection and installed state."""
|
|
||||||
data = _read_json_object(handoff_file)
|
|
||||||
_validate_template_handoff(data)
|
|
||||||
selection_file = handoff_file.parent / TEMPLATE_SELECTION_NAME
|
|
||||||
selection = _read_template_selection(selection_file)
|
|
||||||
if not _is_newer(handoff_file, selection_file):
|
|
||||||
raise ValueError(
|
|
||||||
f'{TEMPLATE_HANDOFF_NAME} must be completed after the current '
|
|
||||||
f'{TEMPLATE_SELECTION_NAME}'
|
|
||||||
)
|
|
||||||
if data['mode'] != selection['mode']:
|
|
||||||
raise ValueError(f'{TEMPLATE_HANDOFF_NAME} mode does not match selection')
|
|
||||||
if data['selection_sha256'] != selection['selection_sha256']:
|
|
||||||
raise ValueError(
|
|
||||||
f'{TEMPLATE_HANDOFF_NAME} selection_sha256 does not match selection'
|
|
||||||
)
|
|
||||||
if data['mode'] == 'templates':
|
|
||||||
if not _installed_template_specs(project_path):
|
|
||||||
raise ValueError(
|
|
||||||
f'{TEMPLATE_HANDOFF_NAME} requires at least one installed '
|
|
||||||
f'template spec: {project_path / "templates"}/'
|
|
||||||
'design_spec.<kind>.<id>.md'
|
|
||||||
)
|
|
||||||
return data
|
|
||||||
|
|
||||||
|
|
||||||
def _installed_template_specs(project_path: Path) -> list[Path]:
|
|
||||||
"""Return every template spec installed into the project by the apply stage.
|
|
||||||
|
|
||||||
The apply stage installs one file per selected workspace, named
|
|
||||||
``design_spec.<kind>.<id>.md``. A bare ``design_spec.md`` under
|
|
||||||
``templates/`` means the project is itself a Create Template workspace and
|
|
||||||
is deliberately excluded here.
|
|
||||||
"""
|
|
||||||
return sorted(
|
|
||||||
path
|
|
||||||
for path in (project_path / 'templates').glob('design_spec.*.md')
|
|
||||||
if path.is_file() and _TEMPLATE_SPEC_NAME_RE.fullmatch(path.name)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _complete_template_selection(project_path: Path) -> int:
|
|
||||||
"""Write the agent-owned handoff after Stage 1 and template application."""
|
|
||||||
confirm_dir = project_path / CONFIRM_DIR_NAME
|
|
||||||
selection_file = confirm_dir / TEMPLATE_SELECTION_NAME
|
|
||||||
if not selection_file.exists():
|
|
||||||
logger.error('%s not found — the user must confirm Stage 1 first', selection_file)
|
|
||||||
return 1
|
|
||||||
try:
|
|
||||||
selection = _read_template_selection(selection_file)
|
|
||||||
except (OSError, json.JSONDecodeError, ValueError) as exc:
|
|
||||||
logger.error('cannot complete template selection: %s', exc)
|
|
||||||
return 1
|
|
||||||
|
|
||||||
result_file = confirm_dir / RESULT_NAME
|
|
||||||
if _result_stage(result_file) != 'stage1':
|
|
||||||
logger.error(
|
|
||||||
'cannot complete template selection before Stage 1 writes a '
|
|
||||||
'stage1-confirmed result'
|
|
||||||
)
|
|
||||||
return 1
|
|
||||||
if selection['mode'] == 'templates':
|
if selection['mode'] == 'templates':
|
||||||
if not _installed_template_specs(project_path):
|
read_template_install(project_path, selection['root_snapshots'], selection['selection_sha256'])
|
||||||
logger.error(
|
return selection
|
||||||
'cannot complete template selection before template apply '
|
|
||||||
'writes %s/design_spec.<kind>.<id>.md',
|
|
||||||
project_path / 'templates',
|
|
||||||
)
|
|
||||||
return 1
|
|
||||||
|
|
||||||
handoff_file = confirm_dir / TEMPLATE_HANDOFF_NAME
|
|
||||||
if handoff_file.exists():
|
|
||||||
try:
|
|
||||||
existing = _read_template_handoff(project_path, handoff_file)
|
|
||||||
except (OSError, json.JSONDecodeError, ValueError):
|
|
||||||
existing = None
|
|
||||||
if (
|
|
||||||
existing is not None
|
|
||||||
and existing['selection_sha256'] == selection['selection_sha256']
|
|
||||||
and _is_newer(handoff_file, result_file)
|
|
||||||
):
|
|
||||||
logger.info('template selection already complete: %s', handoff_file)
|
|
||||||
return 0
|
|
||||||
|
|
||||||
handoff = {
|
|
||||||
'schema_version': TEMPLATE_SCHEMA_VERSION,
|
|
||||||
'phase': 'template',
|
|
||||||
'status': 'ready',
|
|
||||||
'mode': selection['mode'],
|
|
||||||
'selection_sha256': selection['selection_sha256'],
|
|
||||||
'completed_at': time.strftime('%Y-%m-%dT%H:%M:%S'),
|
|
||||||
}
|
|
||||||
_validate_template_handoff(handoff)
|
|
||||||
_write_json_atomic(handoff_file, handoff)
|
|
||||||
logger.info('template selection handoff written to %s', handoff_file)
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
def _reset_template_selection(confirm_dir: Path) -> int:
|
|
||||||
"""Remove one-run template-selection artifacts before a fresh UI lifecycle."""
|
|
||||||
removed = []
|
|
||||||
for filename in (
|
|
||||||
TEMPLATE_HANDOFF_NAME,
|
|
||||||
TEMPLATE_SELECTION_NAME,
|
|
||||||
TEMPLATE_OPTIONS_NAME,
|
|
||||||
):
|
|
||||||
path = confirm_dir / filename
|
|
||||||
try:
|
|
||||||
path.unlink()
|
|
||||||
removed.append(str(path))
|
|
||||||
except FileNotFoundError:
|
|
||||||
continue
|
|
||||||
except OSError as exc:
|
|
||||||
logger.error('cannot remove %s: %s', path, exc)
|
|
||||||
return 1
|
|
||||||
if removed:
|
|
||||||
logger.info('reset template selection artifacts: %s', ', '.join(removed))
|
|
||||||
else:
|
|
||||||
logger.info('template selection artifacts already absent')
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
def _stage1_ready_error(confirm_dir: Path) -> Optional[str]:
|
def _stage1_ready_error(confirm_dir: Path) -> Optional[str]:
|
||||||
"""Return why the combined template/Stage-1 page cannot be exposed."""
|
"""Return why the combined template/Stage-1 page cannot be exposed."""
|
||||||
options_file = confirm_dir / TEMPLATE_OPTIONS_NAME
|
|
||||||
if not options_file.exists():
|
|
||||||
return f'{TEMPLATE_OPTIONS_NAME} not found'
|
|
||||||
try:
|
try:
|
||||||
|
data = _read_json_object(confirm_dir / RECOMMENDATION_STAGE_NAMES[1])
|
||||||
|
if _recommendation_stage(data) != 1:
|
||||||
|
return 'recommendations.stage1.json must declare stage1'
|
||||||
_build_template_options(confirm_dir)
|
_build_template_options(confirm_dir)
|
||||||
except (OSError, json.JSONDecodeError, ValueError) as exc:
|
except (OSError, json.JSONDecodeError, ValueError) as exc:
|
||||||
return f'invalid template options: {exc}'
|
return f'invalid Stage 1 recommendations: {exc}'
|
||||||
|
|
||||||
stage1_file = confirm_dir / RECOMMENDATION_STAGE_NAMES[1]
|
|
||||||
if not stage1_file.exists():
|
|
||||||
return f'{stage1_file.name} not found'
|
|
||||||
try:
|
|
||||||
stage1_data = _read_json_object(stage1_file)
|
|
||||||
if _recommendation_stage(stage1_data) != 1:
|
|
||||||
return f'{stage1_file.name} does not declare stage1'
|
|
||||||
except (OSError, json.JSONDecodeError, ValueError) as exc:
|
|
||||||
return f'cannot validate Stage 1 recommendations: {exc}'
|
|
||||||
|
|
||||||
result_file = confirm_dir / RESULT_NAME
|
|
||||||
if result_file.exists():
|
|
||||||
if not _is_newer(options_file, result_file):
|
|
||||||
return f'{TEMPLATE_OPTIONS_NAME} must be newer than the prior {RESULT_NAME}'
|
|
||||||
if not _is_newer(stage1_file, result_file):
|
|
||||||
return f'{stage1_file.name} must be newer than the prior {RESULT_NAME}'
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
@@ -904,34 +767,21 @@ def _stage2_ready_error(
|
|||||||
confirm_dir: Path,
|
confirm_dir: Path,
|
||||||
recommendations_file: Optional[Path] = None,
|
recommendations_file: Optional[Path] = None,
|
||||||
) -> Optional[str]:
|
) -> Optional[str]:
|
||||||
"""Return why final Stage 2 cannot follow the confirmed template choice."""
|
"""Bind Stage 2 to the confirmed choice and verified installation."""
|
||||||
selection_file = confirm_dir / TEMPLATE_SELECTION_NAME
|
|
||||||
if not selection_file.exists():
|
|
||||||
return f'{TEMPLATE_SELECTION_NAME} not found after Stage 1 confirmation'
|
|
||||||
try:
|
try:
|
||||||
_read_template_selection(selection_file)
|
selection = _read_template_installation(project_path)
|
||||||
|
result_file = confirm_dir / RESULT_NAME
|
||||||
|
if _fresh_template_restart(confirm_dir) or _result_stage(result_file) != 'stage1':
|
||||||
|
return f'{RESULT_NAME} does not contain the current stage1-confirmed result'
|
||||||
|
result = _read_result_object(result_file)
|
||||||
|
if result.get('selection_sha256') != selection['selection_sha256']:
|
||||||
|
return f'{RESULT_NAME} selection_sha256 does not match the current selection'
|
||||||
|
if recommendations_file is not None:
|
||||||
|
recommendations = _read_json_object(recommendations_file)
|
||||||
|
if recommendations.get('selection_sha256') != selection['selection_sha256']:
|
||||||
|
return f'{recommendations_file.name} selection_sha256 must match template_selection.json'
|
||||||
except (OSError, json.JSONDecodeError, ValueError) as exc:
|
except (OSError, json.JSONDecodeError, ValueError) as exc:
|
||||||
return f'invalid template selection: {exc}'
|
return f'template selection or installation is not ready: {exc}'
|
||||||
|
|
||||||
result_file = confirm_dir / RESULT_NAME
|
|
||||||
if _result_stage(result_file) != 'stage1':
|
|
||||||
return f'{RESULT_NAME} does not contain a stage1-confirmed result'
|
|
||||||
|
|
||||||
handoff_file = confirm_dir / TEMPLATE_HANDOFF_NAME
|
|
||||||
if not handoff_file.exists():
|
|
||||||
return f'{TEMPLATE_HANDOFF_NAME} not found after template application'
|
|
||||||
try:
|
|
||||||
_read_template_handoff(project_path, handoff_file)
|
|
||||||
except (OSError, json.JSONDecodeError, ValueError) as exc:
|
|
||||||
return f'invalid template handoff: {exc}'
|
|
||||||
if not _is_newer(handoff_file, result_file):
|
|
||||||
return f'{TEMPLATE_HANDOFF_NAME} must be newer than the Stage 1 {RESULT_NAME}'
|
|
||||||
|
|
||||||
if recommendations_file is not None and not _is_newer(
|
|
||||||
recommendations_file,
|
|
||||||
handoff_file,
|
|
||||||
):
|
|
||||||
return f'{recommendations_file.name} must be newer than {TEMPLATE_HANDOFF_NAME}'
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
@@ -939,6 +789,7 @@ def _resolve_template_confirmation(
|
|||||||
payload: dict,
|
payload: dict,
|
||||||
candidates: dict[str, dict],
|
candidates: dict[str, dict],
|
||||||
options_sha256: str,
|
options_sha256: str,
|
||||||
|
project_path: Path,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Resolve browser keys against the current server-owned candidate set."""
|
"""Resolve browser keys against the current server-owned candidate set."""
|
||||||
required_fields = {'mode', 'selection_keys'}
|
required_fields = {'mode', 'selection_keys'}
|
||||||
@@ -974,10 +825,17 @@ def _resolve_template_confirmation(
|
|||||||
item.get('id', ''),
|
item.get('id', ''),
|
||||||
item['workspace_root'],
|
item['workspace_root'],
|
||||||
))
|
))
|
||||||
|
from apply_template import snapshot_template_roots
|
||||||
|
|
||||||
|
_validate_explicit_root_closure(selections, candidates)
|
||||||
|
root_snapshots = snapshot_template_roots(
|
||||||
|
project_path, sorted({item['workspace_root'] for item in selections}),
|
||||||
|
)
|
||||||
selection_sha256 = _template_selection_sha256(
|
selection_sha256 = _template_selection_sha256(
|
||||||
mode,
|
mode,
|
||||||
selections,
|
selections,
|
||||||
options_sha256,
|
options_sha256,
|
||||||
|
root_snapshots,
|
||||||
)
|
)
|
||||||
receipt = {
|
receipt = {
|
||||||
'schema_version': TEMPLATE_SCHEMA_VERSION,
|
'schema_version': TEMPLATE_SCHEMA_VERSION,
|
||||||
@@ -988,55 +846,27 @@ def _resolve_template_confirmation(
|
|||||||
'options_sha256': options_sha256,
|
'options_sha256': options_sha256,
|
||||||
'selection_sha256': selection_sha256,
|
'selection_sha256': selection_sha256,
|
||||||
'confirmed_at': time.strftime('%Y-%m-%dT%H:%M:%S'),
|
'confirmed_at': time.strftime('%Y-%m-%dT%H:%M:%S'),
|
||||||
|
'root_snapshots': root_snapshots,
|
||||||
}
|
}
|
||||||
_validate_template_selection(receipt)
|
_validate_template_selection(receipt)
|
||||||
_validate_explicit_root_closure(selections, candidates)
|
|
||||||
return receipt
|
return receipt
|
||||||
|
|
||||||
|
|
||||||
def _confirmation_launch_error(confirm_dir: Path) -> Optional[str]:
|
def _confirmation_launch_error(confirm_dir: Path) -> Optional[str]:
|
||||||
"""Return why the current Default UI lifecycle cannot launch."""
|
"""Allow a new recommendation revision or resume its existing selection."""
|
||||||
options_file = confirm_dir / TEMPLATE_OPTIONS_NAME
|
error = _stage1_ready_error(confirm_dir)
|
||||||
|
if error:
|
||||||
|
return error
|
||||||
result_file = confirm_dir / RESULT_NAME
|
result_file = confirm_dir / RESULT_NAME
|
||||||
result_stage = _result_stage(result_file)
|
if _fresh_template_restart(confirm_dir) or not result_file.exists():
|
||||||
fresh_template_options = _fresh_template_restart(confirm_dir)
|
|
||||||
if (
|
|
||||||
result_file.exists()
|
|
||||||
and result_stage is None
|
|
||||||
and not fresh_template_options
|
|
||||||
):
|
|
||||||
return (
|
|
||||||
f'{RESULT_NAME} is not a current stage1/final receipt — reset the '
|
|
||||||
f'template selection and write fresh {TEMPLATE_OPTIONS_NAME} before '
|
|
||||||
'starting a new run'
|
|
||||||
)
|
|
||||||
if (
|
|
||||||
result_stage == 'final'
|
|
||||||
and not fresh_template_options
|
|
||||||
):
|
|
||||||
return (
|
|
||||||
'the previous Confirm UI run is complete — reset the template '
|
|
||||||
f'selection and write fresh {TEMPLATE_OPTIONS_NAME} before starting a new one'
|
|
||||||
)
|
|
||||||
if options_file.exists():
|
|
||||||
try:
|
|
||||||
_build_template_options(confirm_dir)
|
|
||||||
selection_file = confirm_dir / TEMPLATE_SELECTION_NAME
|
|
||||||
if result_stage == 'stage1' and not selection_file.exists():
|
|
||||||
return (
|
|
||||||
f'{TEMPLATE_SELECTION_NAME} not found for the confirmed '
|
|
||||||
'Stage 1 result'
|
|
||||||
)
|
|
||||||
if selection_file.exists():
|
|
||||||
_read_template_selection(selection_file)
|
|
||||||
except (OSError, json.JSONDecodeError, ValueError) as exc:
|
|
||||||
return f'invalid template selection input: {exc}'
|
|
||||||
if result_stage is None or fresh_template_options:
|
|
||||||
stage1_error = _stage1_ready_error(confirm_dir)
|
|
||||||
if stage1_error:
|
|
||||||
return f'Stage 1 is not ready: {stage1_error}'
|
|
||||||
return None
|
return None
|
||||||
return f'{options_file} not found — write template options before launch'
|
if _result_stage(result_file) == 'final':
|
||||||
|
return 'the previous run is complete; revise recommendations.stage1.json (revision for a new round)'
|
||||||
|
try:
|
||||||
|
_read_template_selection(confirm_dir / TEMPLATE_SELECTION_NAME)
|
||||||
|
except (OSError, json.JSONDecodeError, ValueError) as exc:
|
||||||
|
return f'invalid template selection: {exc}'
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _server_url(port: int, path: str = '') -> str:
|
def _server_url(port: int, path: str = '') -> str:
|
||||||
@@ -1170,7 +1000,6 @@ def _open_browser_async(url: str, delay: float = 0.4) -> None:
|
|||||||
def _wait_for_result(
|
def _wait_for_result(
|
||||||
result_file: Path,
|
result_file: Path,
|
||||||
proc: subprocess.Popen,
|
proc: subprocess.Popen,
|
||||||
started_at: float,
|
|
||||||
timeout: int,
|
timeout: int,
|
||||||
expected_stage: str,
|
expected_stage: str,
|
||||||
) -> int:
|
) -> int:
|
||||||
@@ -1178,25 +1007,9 @@ def _wait_for_result(
|
|||||||
logger.info('waiting for browser confirmation...')
|
logger.info('waiting for browser confirmation...')
|
||||||
deadline = None if timeout <= 0 else time.time() + timeout
|
deadline = None if timeout <= 0 else time.time() + timeout
|
||||||
while True:
|
while True:
|
||||||
if result_file.exists():
|
status = _wait_result_status(result_file, expected_stage)
|
||||||
try:
|
if status is not None:
|
||||||
if result_file.stat().st_mtime >= started_at:
|
return status
|
||||||
actual_stage = _result_stage(result_file)
|
|
||||||
if actual_stage != expected_stage:
|
|
||||||
logger.error(
|
|
||||||
'confirmation stage mismatch: expected %s, found %s',
|
|
||||||
expected_stage,
|
|
||||||
actual_stage or 'invalid/absent',
|
|
||||||
)
|
|
||||||
return 2
|
|
||||||
logger.info('confirmation received: %s', result_file)
|
|
||||||
try:
|
|
||||||
proc.wait(timeout=3)
|
|
||||||
except subprocess.TimeoutExpired:
|
|
||||||
pass
|
|
||||||
return 0
|
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
returncode = proc.poll()
|
returncode = proc.poll()
|
||||||
if returncode is not None:
|
if returncode is not None:
|
||||||
@@ -1284,20 +1097,18 @@ def _stage_recommendations_path(confirm_dir: Path, stage_number: int) -> Path:
|
|||||||
return confirm_dir / RECOMMENDATION_STAGE_NAMES[stage_number]
|
return confirm_dir / RECOMMENDATION_STAGE_NAMES[stage_number]
|
||||||
|
|
||||||
|
|
||||||
def _is_newer(path: Path, baseline: Path) -> bool:
|
def _stage1_sha256(confirm_dir: Path) -> str:
|
||||||
"""Return whether ``path`` was authored after ``baseline``."""
|
"""Hash the complete authored recommendation, independent of timestamps."""
|
||||||
try:
|
return _json_sha256(_read_json_object(confirm_dir / RECOMMENDATION_STAGE_NAMES[1]))
|
||||||
return path.stat().st_mtime_ns > baseline.stat().st_mtime_ns
|
|
||||||
except OSError:
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def _fresh_template_restart(confirm_dir: Path) -> bool:
|
def _fresh_template_restart(confirm_dir: Path) -> bool:
|
||||||
"""Return whether fresh template options start a new UI session."""
|
"""A changed Stage-1 recommendation invalidates every prior receipt."""
|
||||||
return _is_newer(
|
try:
|
||||||
confirm_dir / TEMPLATE_OPTIONS_NAME,
|
result = _read_result_object(confirm_dir / RESULT_NAME)
|
||||||
confirm_dir / RESULT_NAME,
|
return result.get('stage1_sha256') != _stage1_sha256(confirm_dir)
|
||||||
)
|
except (OSError, json.JSONDecodeError, ValueError):
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
def _active_recommendations_path(confirm_dir: Path) -> Path:
|
def _active_recommendations_path(confirm_dir: Path) -> Path:
|
||||||
@@ -1327,13 +1138,9 @@ def _read_active_recommendations(
|
|||||||
f'found {_stage_name(actual_stage) or "absent"}'
|
f'found {_stage_name(actual_stage) or "absent"}'
|
||||||
)
|
)
|
||||||
if stage_number == 2:
|
if stage_number == 2:
|
||||||
result_file = confirm_dir / RESULT_NAME
|
selection = _read_template_selection(confirm_dir / TEMPLATE_SELECTION_NAME)
|
||||||
if _result_stage(result_file) == 'stage1':
|
if data.get('selection_sha256') != selection['selection_sha256']:
|
||||||
if not _is_newer(rec_file, result_file):
|
raise ValueError(f'{filename} selection_sha256 must match template_selection.json')
|
||||||
raise ValueError(
|
|
||||||
f'{filename} must be authored after the current '
|
|
||||||
'Stage-1 confirmation'
|
|
||||||
)
|
|
||||||
production_error = _stage2_production_recommendations_error(data)
|
production_error = _stage2_production_recommendations_error(data)
|
||||||
if production_error:
|
if production_error:
|
||||||
raise ValueError(production_error)
|
raise ValueError(production_error)
|
||||||
@@ -1344,11 +1151,7 @@ def _read_active_recommendations(
|
|||||||
def _template_confirmation_required(project_path: Path) -> bool:
|
def _template_confirmation_required(project_path: Path) -> bool:
|
||||||
"""Return whether the confirmed project state has an active template."""
|
"""Return whether the confirmed project state has an active template."""
|
||||||
confirm_dir = project_path / CONFIRM_DIR_NAME
|
confirm_dir = project_path / CONFIRM_DIR_NAME
|
||||||
handoff = _read_template_handoff(
|
return _read_template_installation(project_path)['mode'] == 'templates'
|
||||||
project_path,
|
|
||||||
confirm_dir / TEMPLATE_HANDOFF_NAME,
|
|
||||||
)
|
|
||||||
return handoff['mode'] == 'templates'
|
|
||||||
|
|
||||||
|
|
||||||
def _template_stage2_error(
|
def _template_stage2_error(
|
||||||
@@ -1981,10 +1784,10 @@ def _expected_result_stage(confirm_dir: Path) -> str:
|
|||||||
}.get(_recommendation_stage(recommendations), 'final')
|
}.get(_recommendation_stage(recommendations), 'final')
|
||||||
|
|
||||||
|
|
||||||
def _file_version(path: Path) -> Optional[float]:
|
def _file_version(path: Path) -> Optional[str]:
|
||||||
"""Return a cheap file version for polling state, or None when absent."""
|
"""Return a cheap file version for polling state, or None when absent."""
|
||||||
try:
|
try:
|
||||||
return path.stat().st_mtime
|
return hashlib.sha256(path.read_bytes()).hexdigest()
|
||||||
except OSError:
|
except OSError:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -2059,16 +1862,9 @@ def _build_session_state(
|
|||||||
'event': event or previous.get('event') or 'derived',
|
'event': event or previous.get('event') or 'derived',
|
||||||
}
|
}
|
||||||
|
|
||||||
options_file = confirm_dir / TEMPLATE_OPTIONS_NAME
|
|
||||||
selection_file = confirm_dir / TEMPLATE_SELECTION_NAME
|
|
||||||
handoff_file = confirm_dir / TEMPLATE_HANDOFF_NAME
|
|
||||||
session.update({
|
session.update({
|
||||||
'template_options_file': TEMPLATE_OPTIONS_NAME,
|
'template_selection_version': _file_version(confirm_dir / TEMPLATE_SELECTION_NAME),
|
||||||
'template_options_version': _file_version(options_file),
|
'template_install_version': _file_version(confirm_dir.parent / 'template_install.json'),
|
||||||
'template_selection_file': TEMPLATE_SELECTION_NAME,
|
|
||||||
'template_selection_version': _file_version(selection_file),
|
|
||||||
'template_handoff_file': TEMPLATE_HANDOFF_NAME,
|
|
||||||
'template_handoff_version': _file_version(handoff_file),
|
|
||||||
})
|
})
|
||||||
|
|
||||||
if result_stage == 'final':
|
if result_stage == 'final':
|
||||||
@@ -2356,7 +2152,7 @@ def _wait_result_status(
|
|||||||
logger.info('confirmation stage=%s received: %s', target_stage, result_file)
|
logger.info('confirmation stage=%s received: %s', target_stage, result_file)
|
||||||
if target_stage == 'stage1':
|
if target_stage == 'stage1':
|
||||||
logger.info(
|
logger.info(
|
||||||
'[NEXT] Stage 1 is intermediate: complete the template handoff, '
|
'[NEXT] Stage 1 is intermediate: install the selected roots (if any), '
|
||||||
'author fresh Stage 2, then wait for final confirmation.'
|
'author fresh Stage 2, then wait for final confirmation.'
|
||||||
)
|
)
|
||||||
return 0
|
return 0
|
||||||
@@ -2668,9 +2464,8 @@ def create_app(
|
|||||||
):
|
):
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'error': (
|
'error': (
|
||||||
'the current Confirm UI run is complete; reset the template '
|
'the current run is complete; revise recommendations.stage1.json '
|
||||||
f'selection and write fresh {TEMPLATE_OPTIONS_NAME} before '
|
'(revision for a new round) before starting another'
|
||||||
'starting another'
|
|
||||||
),
|
),
|
||||||
}), 409
|
}), 409
|
||||||
rec_file = _active_recommendations_path(confirm_dir)
|
rec_file = _active_recommendations_path(confirm_dir)
|
||||||
@@ -2690,7 +2485,7 @@ def create_app(
|
|||||||
'error': f'Stage 1 is not ready: {stage1_error}',
|
'error': f'Stage 1 is not ready: {stage1_error}',
|
||||||
}), 409
|
}), 409
|
||||||
try:
|
try:
|
||||||
template_options, _ = _build_template_options(confirm_dir)
|
template_options, _ = _build_template_options(confirm_dir, recommendation=data)
|
||||||
except (OSError, json.JSONDecodeError, ValueError) as exc:
|
except (OSError, json.JSONDecodeError, ValueError) as exc:
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'error': f'invalid template options: {exc}',
|
'error': f'invalid template options: {exc}',
|
||||||
@@ -2705,7 +2500,7 @@ def create_app(
|
|||||||
)
|
)
|
||||||
if stage2_error:
|
if stage2_error:
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'error': f'Stage 2 is waiting for template handoff: {stage2_error}',
|
'error': f'Stage 2 is waiting for the confirmed installation: {stage2_error}',
|
||||||
}), 409
|
}), 409
|
||||||
try:
|
try:
|
||||||
template_required = _template_confirmation_required(project_path)
|
template_required = _template_confirmation_required(project_path)
|
||||||
@@ -2788,6 +2583,10 @@ def create_app(
|
|||||||
),
|
),
|
||||||
}), 409
|
}), 409
|
||||||
rec_stage_number = _recommendation_stage(current_recommendations)
|
rec_stage_number = _recommendation_stage(current_recommendations)
|
||||||
|
stage1_sha256 = (
|
||||||
|
_json_sha256(current_recommendations) if rec_stage_number == 1
|
||||||
|
else _stage1_sha256(confirm_dir)
|
||||||
|
)
|
||||||
selection_receipt = None
|
selection_receipt = None
|
||||||
selection_file = confirm_dir / TEMPLATE_SELECTION_NAME
|
selection_file = confirm_dir / TEMPLATE_SELECTION_NAME
|
||||||
write_selection = False
|
write_selection = False
|
||||||
@@ -2807,39 +2606,23 @@ def create_app(
|
|||||||
try:
|
try:
|
||||||
template_options, template_candidates = _build_template_options(
|
template_options, template_candidates = _build_template_options(
|
||||||
confirm_dir,
|
confirm_dir,
|
||||||
|
recommendation=current_recommendations,
|
||||||
)
|
)
|
||||||
|
if result.pop('options_sha256', None) != template_options['options_sha256']:
|
||||||
|
return jsonify({'error': 'Stage 1 options changed; reload the page'}), 409
|
||||||
selection_receipt = _resolve_template_confirmation(
|
selection_receipt = _resolve_template_confirmation(
|
||||||
template_selection_payload,
|
template_selection_payload,
|
||||||
template_candidates,
|
template_candidates,
|
||||||
template_options['options_sha256'],
|
template_options['options_sha256'],
|
||||||
|
project_path,
|
||||||
)
|
)
|
||||||
except (OSError, json.JSONDecodeError, ValueError) as exc:
|
except (OSError, json.JSONDecodeError, ValueError) as exc:
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'error': f'invalid Stage 1 template selection: {exc}',
|
'error': f'invalid Stage 1 template selection: {exc}',
|
||||||
}), 400
|
}), 400
|
||||||
template_required = selection_receipt['mode'] == 'templates'
|
template_required = selection_receipt['mode'] == 'templates'
|
||||||
if selection_file.exists():
|
write_selection = True
|
||||||
try:
|
|
||||||
existing_selection = _read_template_selection(selection_file)
|
|
||||||
except (OSError, json.JSONDecodeError, ValueError) as exc:
|
|
||||||
return jsonify({
|
|
||||||
'error': (
|
|
||||||
f'existing template selection is invalid: {exc}; '
|
|
||||||
'the agent must run --reset-template-selection'
|
|
||||||
),
|
|
||||||
}), 409
|
|
||||||
if (
|
|
||||||
existing_selection['selection_sha256']
|
|
||||||
!= selection_receipt['selection_sha256']
|
|
||||||
):
|
|
||||||
return jsonify({
|
|
||||||
'error': (
|
|
||||||
'Stage 1 already has a different template selection; '
|
|
||||||
'the agent must run --reset-template-selection'
|
|
||||||
),
|
|
||||||
}), 409
|
|
||||||
else:
|
|
||||||
write_selection = True
|
|
||||||
else:
|
else:
|
||||||
if template_selection_payload is not None:
|
if template_selection_payload is not None:
|
||||||
return jsonify({
|
return jsonify({
|
||||||
@@ -2852,7 +2635,7 @@ def create_app(
|
|||||||
)
|
)
|
||||||
if stage2_error:
|
if stage2_error:
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'error': f'Stage 2 is waiting for template handoff: {stage2_error}',
|
'error': f'Stage 2 is waiting for the confirmed installation: {stage2_error}',
|
||||||
}), 409
|
}), 409
|
||||||
try:
|
try:
|
||||||
template_required = _template_confirmation_required(project_path)
|
template_required = _template_confirmation_required(project_path)
|
||||||
@@ -2944,6 +2727,13 @@ def create_app(
|
|||||||
result.pop(_LOCKED_RECOMMENDATIONS_KEY, None)
|
result.pop(_LOCKED_RECOMMENDATIONS_KEY, None)
|
||||||
result['stage'] = 'final'
|
result['stage'] = 'final'
|
||||||
result['status'] = 'confirmed'
|
result['status'] = 'confirmed'
|
||||||
|
if _stage1_sha256(confirm_dir) != stage1_sha256:
|
||||||
|
return jsonify({'error': 'Stage 1 recommendations changed; reload the page'}), 409
|
||||||
|
result['stage1_sha256'] = stage1_sha256
|
||||||
|
result['selection_sha256'] = (
|
||||||
|
selection_receipt['selection_sha256'] if selection_receipt is not None
|
||||||
|
else previous_result['selection_sha256']
|
||||||
|
)
|
||||||
result['confirmed_at'] = time.strftime('%Y-%m-%dT%H:%M:%S')
|
result['confirmed_at'] = time.strftime('%Y-%m-%dT%H:%M:%S')
|
||||||
if write_selection and selection_receipt is not None:
|
if write_selection and selection_receipt is not None:
|
||||||
_write_json_atomic(selection_file, selection_receipt)
|
_write_json_atomic(selection_file, selection_receipt)
|
||||||
@@ -3005,18 +2795,6 @@ def build_parser() -> argparse.ArgumentParser:
|
|||||||
'(idempotent). Run at the end of Step 4 so the page never lingers '
|
'(idempotent). Run at the end of Step 4 so the page never lingers '
|
||||||
'on its selected port before live preview starts.',
|
'on its selected port before live preview starts.',
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
|
||||||
'--complete-template-selection', action='store_true',
|
|
||||||
help='Agent-only: after Stage 1, bind its template selection to a ready '
|
|
||||||
'handoff. Template mode requires at least one '
|
|
||||||
'<project>/templates/design_spec.<kind>.<id>.md.',
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
'--reset-template-selection', action='store_true',
|
|
||||||
help='Agent-only: remove exactly template_options.json, '
|
|
||||||
'template_selection.json, and template_handoff.json before a '
|
|
||||||
'fresh one-run UI lifecycle.',
|
|
||||||
)
|
|
||||||
return parser
|
return parser
|
||||||
|
|
||||||
|
|
||||||
@@ -3046,29 +2824,6 @@ def main(argv: Optional[list[str]] = None) -> int:
|
|||||||
logger.error('--wait-stage must be stage1 or final')
|
logger.error('--wait-stage must be stage1 or final')
|
||||||
return 2
|
return 2
|
||||||
|
|
||||||
template_control = (
|
|
||||||
args.complete_template_selection
|
|
||||||
or args.reset_template_selection
|
|
||||||
)
|
|
||||||
if template_control and (
|
|
||||||
args.daemon or args.wait or args.wait_only or args.shutdown
|
|
||||||
):
|
|
||||||
logger.error(
|
|
||||||
'--complete-template-selection/--reset-template-selection cannot be combined '
|
|
||||||
'with server, wait, or shutdown actions'
|
|
||||||
)
|
|
||||||
return 2
|
|
||||||
if args.complete_template_selection and args.reset_template_selection:
|
|
||||||
logger.error(
|
|
||||||
'--complete-template-selection and --reset-template-selection are '
|
|
||||||
'mutually exclusive'
|
|
||||||
)
|
|
||||||
return 2
|
|
||||||
if args.complete_template_selection:
|
|
||||||
return _complete_template_selection(project_path)
|
|
||||||
if args.reset_template_selection:
|
|
||||||
return _reset_template_selection(project_path / CONFIRM_DIR_NAME)
|
|
||||||
|
|
||||||
# Step 4 cleanup: stop any lingering confirm server and exit. Independent of
|
# Step 4 cleanup: stop any lingering confirm server and exit. Independent of
|
||||||
# recommendation files (the page may never have been confirmed).
|
# recommendation files (the page may never have been confirmed).
|
||||||
if args.shutdown:
|
if args.shutdown:
|
||||||
@@ -3156,7 +2911,6 @@ def main(argv: Optional[list[str]] = None) -> int:
|
|||||||
confirm_dir = project_path / CONFIRM_DIR_NAME
|
confirm_dir = project_path / CONFIRM_DIR_NAME
|
||||||
result_file = confirm_dir / RESULT_NAME
|
result_file = confirm_dir / RESULT_NAME
|
||||||
expected_stage = _expected_result_stage(confirm_dir)
|
expected_stage = _expected_result_stage(confirm_dir)
|
||||||
started_at = time.time()
|
|
||||||
try:
|
try:
|
||||||
proc, port, _ = _launch_background_server(
|
proc, port, _ = _launch_background_server(
|
||||||
project_path,
|
project_path,
|
||||||
@@ -3172,7 +2926,6 @@ def main(argv: Optional[list[str]] = None) -> int:
|
|||||||
return _wait_for_result(
|
return _wait_for_result(
|
||||||
result_file,
|
result_file,
|
||||||
proc,
|
proc,
|
||||||
started_at,
|
|
||||||
args.wait_timeout,
|
args.wait_timeout,
|
||||||
expected_stage,
|
expected_stage,
|
||||||
)
|
)
|
||||||
|
|||||||
+61
-45
@@ -22,7 +22,7 @@
|
|||||||
btn_confirm: "Confirm",
|
btn_confirm: "Confirm",
|
||||||
btn_confirm_contract: "Confirm contract & continue →",
|
btn_confirm_contract: "Confirm contract & continue →",
|
||||||
btn_confirm_final_plan: "Confirm final plan →",
|
btn_confirm_final_plan: "Confirm final plan →",
|
||||||
deriving: "Generating the downstream options from your choices…",
|
deriving: "Preparing the final plan and verifying your selected templates…",
|
||||||
template_selection_required: "Choose free design or use templates. When using templates, select at least one workspace.",
|
template_selection_required: "Choose free design or use templates. When using templates, select at least one workspace.",
|
||||||
template_selection_conflict: "Choose at most one workspace per kind.",
|
template_selection_conflict: "Choose at most one workspace per kind.",
|
||||||
connection_lost: "Connection to the confirm server was interrupted; retrying. If this keeps failing, return to the chat for confirmation.",
|
connection_lost: "Connection to the confirm server was interrupted; retrying. If this keeps failing, return to the chat for confirmation.",
|
||||||
@@ -38,7 +38,7 @@
|
|||||||
sec_template_library: "Template combination",
|
sec_template_library: "Template combination",
|
||||||
template_library_hint: "Choose at most one workspace per kind. All four kinds can combine; Layout takes structural precedence over Deck.",
|
template_library_hint: "Choose at most one workspace per kind. All four kinds can combine; Layout takes structural precedence over Deck.",
|
||||||
sec_template_explicit: "Specified templates",
|
sec_template_explicit: "Specified templates",
|
||||||
template_explicit_hint: "Choose at most one exact workspace supplied for this run; every kind it contains is applied. Its source path is shown for verification.",
|
template_explicit_hint: "Choose exact workspaces supplied for this run with non-overlapping kinds; every kind in each selected root is applied. Source paths are shown for verification.",
|
||||||
template_kind_brand: "Brand",
|
template_kind_brand: "Brand",
|
||||||
template_kind_style: "Style",
|
template_kind_style: "Style",
|
||||||
template_kind_layout: "Layout",
|
template_kind_layout: "Layout",
|
||||||
@@ -211,7 +211,7 @@
|
|||||||
btn_confirm: "確定",
|
btn_confirm: "確定",
|
||||||
btn_confirm_contract: "契約内容を確定して次へ →",
|
btn_confirm_contract: "契約内容を確定して次へ →",
|
||||||
btn_confirm_final_plan: "最終プランを確定 →",
|
btn_confirm_final_plan: "最終プランを確定 →",
|
||||||
deriving: "選択内容をもとに後続の選択肢を生成しています…",
|
deriving: "最終プランを準備し、選択したテンプレートを確認しています…",
|
||||||
template_selection_required: "自由デザインまたはテンプレート利用を選んでください。テンプレート利用時は、1つ以上のワークスペースを選択してください。",
|
template_selection_required: "自由デザインまたはテンプレート利用を選んでください。テンプレート利用時は、1つ以上のワークスペースを選択してください。",
|
||||||
template_selection_conflict: "種類ごとにワークスペースを1件まで選択してください。",
|
template_selection_conflict: "種類ごとにワークスペースを1件まで選択してください。",
|
||||||
connection_lost: "確認ページのサーバー接続が中断されました。再試行しています。失敗が続く場合はチャットで確認してください。",
|
connection_lost: "確認ページのサーバー接続が中断されました。再試行しています。失敗が続く場合はチャットで確認してください。",
|
||||||
@@ -227,7 +227,7 @@
|
|||||||
sec_template_library: "テンプレートの組み合わせ",
|
sec_template_library: "テンプレートの組み合わせ",
|
||||||
template_library_hint: "種類ごとにワークスペースを1件まで選択できます。4種類はすべて組み合わせ可能で、構造は Layout が Deck より優先されます。",
|
template_library_hint: "種類ごとにワークスペースを1件まで選択できます。4種類はすべて組み合わせ可能で、構造は Layout が Deck より優先されます。",
|
||||||
sec_template_explicit: "指定テンプレート",
|
sec_template_explicit: "指定テンプレート",
|
||||||
template_explicit_hint: "この実行で指定された正確なワークスペースを1件まで選択でき、そこに含まれる種別はすべて適用されます。確認用に参照元パスを表示します。",
|
template_explicit_hint: "この実行で指定されたワークスペースを、種別が重複しない範囲で複数選択できます。各ルートに含まれる全種別が適用され、確認用に参照元パスを表示します。",
|
||||||
template_kind_brand: "Brand",
|
template_kind_brand: "Brand",
|
||||||
template_kind_style: "Style",
|
template_kind_style: "Style",
|
||||||
template_kind_layout: "Layout",
|
template_kind_layout: "Layout",
|
||||||
@@ -400,7 +400,7 @@
|
|||||||
btn_confirm: "确认",
|
btn_confirm: "确认",
|
||||||
btn_confirm_contract: "确认沟通契约并继续 →",
|
btn_confirm_contract: "确认沟通契约并继续 →",
|
||||||
btn_confirm_final_plan: "确认最终方案 →",
|
btn_confirm_final_plan: "确认最终方案 →",
|
||||||
deriving: "正在根据你的选择生成下游选项…",
|
deriving: "正在准备最终方案并核验所选模板…",
|
||||||
template_selection_required: "请选择自由设计或使用模板;选择使用模板时,至少选择一个工作区。",
|
template_selection_required: "请选择自由设计或使用模板;选择使用模板时,至少选择一个工作区。",
|
||||||
template_selection_conflict: "每种模板最多选择一个工作区。",
|
template_selection_conflict: "每种模板最多选择一个工作区。",
|
||||||
connection_lost: "确认页服务连接中断,正在重试;如果持续失败,请回到聊天窗口走聊天确认。",
|
connection_lost: "确认页服务连接中断,正在重试;如果持续失败,请回到聊天窗口走聊天确认。",
|
||||||
@@ -416,7 +416,7 @@
|
|||||||
sec_template_library: "模板组合",
|
sec_template_library: "模板组合",
|
||||||
template_library_hint: "每种模板最多选择一个工作区;四种模板均可组合,结构由 Layout 优先于 Deck。",
|
template_library_hint: "每种模板最多选择一个工作区;四种模板均可组合,结构由 Layout 优先于 Deck。",
|
||||||
sec_template_explicit: "指定模板",
|
sec_template_explicit: "指定模板",
|
||||||
template_explicit_hint: "本次运行明确提供的精确工作区最多选择一个,它包含的每一类都会被采用;显示来源路径供你核对。",
|
template_explicit_hint: "可选择本次运行提供的多个精确工作区,各根的模板类型不得重叠;选中的根会采用其全部类型,来源路径供你核对。",
|
||||||
template_kind_brand: "Brand",
|
template_kind_brand: "Brand",
|
||||||
template_kind_style: "Style",
|
template_kind_style: "Style",
|
||||||
template_kind_layout: "Layout",
|
template_kind_layout: "Layout",
|
||||||
@@ -589,7 +589,7 @@
|
|||||||
btn_confirm: "確認",
|
btn_confirm: "確認",
|
||||||
btn_confirm_contract: "確認溝通契約並繼續 →",
|
btn_confirm_contract: "確認溝通契約並繼續 →",
|
||||||
btn_confirm_final_plan: "確認最終方案 →",
|
btn_confirm_final_plan: "確認最終方案 →",
|
||||||
deriving: "正在根據你的選擇生成下游選項…",
|
deriving: "正在準備最終方案並核驗所選範本…",
|
||||||
template_selection_required: "請選擇自由設計或使用範本;選擇使用範本時,至少選擇一個工作區。",
|
template_selection_required: "請選擇自由設計或使用範本;選擇使用範本時,至少選擇一個工作區。",
|
||||||
template_selection_conflict: "每種範本最多選擇一個工作區。",
|
template_selection_conflict: "每種範本最多選擇一個工作區。",
|
||||||
connection_lost: "確認頁服務連線中斷,正在重試;如果持續失敗,請回到聊天視窗走聊天確認。",
|
connection_lost: "確認頁服務連線中斷,正在重試;如果持續失敗,請回到聊天視窗走聊天確認。",
|
||||||
@@ -605,7 +605,7 @@
|
|||||||
sec_template_library: "範本組合",
|
sec_template_library: "範本組合",
|
||||||
template_library_hint: "每種範本最多選擇一個工作區;四種範本均可組合,結構由 Layout 優先於 Deck。",
|
template_library_hint: "每種範本最多選擇一個工作區;四種範本均可組合,結構由 Layout 優先於 Deck。",
|
||||||
sec_template_explicit: "指定範本",
|
sec_template_explicit: "指定範本",
|
||||||
template_explicit_hint: "本次執行明確提供的精確工作區最多選擇一個,它包含的每一類都會被採用;顯示來源路徑供你核對。",
|
template_explicit_hint: "可選擇本次執行提供的多個精確工作區,各根的範本類型不得重疊;選中的根會採用其全部類型,來源路徑供你核對。",
|
||||||
template_kind_brand: "Brand",
|
template_kind_brand: "Brand",
|
||||||
template_kind_style: "Style",
|
template_kind_style: "Style",
|
||||||
template_kind_layout: "Layout",
|
template_kind_layout: "Layout",
|
||||||
@@ -1112,6 +1112,7 @@
|
|||||||
throw new Error("template_options.default_mode must be free_design or templates");
|
throw new Error("template_options.default_mode must be free_design or templates");
|
||||||
}
|
}
|
||||||
var normalized = {
|
var normalized = {
|
||||||
|
options_sha256: data && data.options_sha256,
|
||||||
lang: data && data.lang,
|
lang: data && data.lang,
|
||||||
default_mode: defaultMode,
|
default_mode: defaultMode,
|
||||||
library: {},
|
library: {},
|
||||||
@@ -1143,11 +1144,16 @@
|
|||||||
if (!candidate || !slot) {
|
if (!candidate || !slot) {
|
||||||
throw new Error("Invalid preselected template key: " + key);
|
throw new Error("Invalid preselected template key: " + key);
|
||||||
}
|
}
|
||||||
var value = slot === "explicit" ? (candidate.workspace_root || "") : key;
|
if (slot === "explicit") {
|
||||||
if (TEMPLATE_SELECTIONS[slot] && TEMPLATE_SELECTIONS[slot] !== value) {
|
if (TEMPLATE_SELECTIONS.explicit.indexOf(candidate.workspace_root) < 0) {
|
||||||
|
TEMPLATE_SELECTIONS.explicit.push(candidate.workspace_root);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (TEMPLATE_SELECTIONS[slot] && TEMPLATE_SELECTIONS[slot] !== key) {
|
||||||
throw new Error("Multiple preselected templates for slot: " + slot);
|
throw new Error("Multiple preselected templates for slot: " + slot);
|
||||||
}
|
}
|
||||||
TEMPLATE_SELECTIONS[slot] = value;
|
TEMPLATE_SELECTIONS[slot] = key;
|
||||||
});
|
});
|
||||||
// Publish options before syncing: expanding an explicit root into its
|
// Publish options before syncing: expanding an explicit root into its
|
||||||
// kinds reads TEMPLATE_OPTIONS, so a preselected root would otherwise
|
// kinds reads TEMPLATE_OPTIONS, so a preselected root would otherwise
|
||||||
@@ -1158,7 +1164,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function emptyTemplateSelections() {
|
function emptyTemplateSelections() {
|
||||||
return { brand: "", style: "", layout: "", deck: "", explicit: "" };
|
return { brand: "", style: "", layout: "", deck: "", explicit: [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
function templateSelectionSlot(candidate) {
|
function templateSelectionSlot(candidate) {
|
||||||
@@ -1211,8 +1217,10 @@
|
|||||||
TEMPLATE_SELECTED_KEYS = TEMPLATE_KINDS.map(function (kind) {
|
TEMPLATE_SELECTED_KEYS = TEMPLATE_KINDS.map(function (kind) {
|
||||||
return TEMPLATE_SELECTIONS[kind];
|
return TEMPLATE_SELECTIONS[kind];
|
||||||
});
|
});
|
||||||
explicitCandidatesForRoot(TEMPLATE_SELECTIONS.explicit).forEach(function (candidate) {
|
TEMPLATE_SELECTIONS.explicit.forEach(function (root) {
|
||||||
TEMPLATE_SELECTED_KEYS.push(candidate.key);
|
explicitCandidatesForRoot(root).forEach(function (candidate) {
|
||||||
|
TEMPLATE_SELECTED_KEYS.push(candidate.key);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
TEMPLATE_SELECTED_KEYS = TEMPLATE_SELECTED_KEYS.filter(Boolean);
|
TEMPLATE_SELECTED_KEYS = TEMPLATE_SELECTED_KEYS.filter(Boolean);
|
||||||
}
|
}
|
||||||
@@ -1248,6 +1256,16 @@
|
|||||||
updateTemplateSelectionControls();
|
updateTemplateSelectionControls();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function chooseExplicitTemplateRoot(root, selected) {
|
||||||
|
TEMPLATE_SELECTIONS.explicit = TEMPLATE_SELECTIONS.explicit.filter(function (value) {
|
||||||
|
return value !== root;
|
||||||
|
});
|
||||||
|
if (selected) TEMPLATE_SELECTIONS.explicit.push(root);
|
||||||
|
syncTemplateSelectionState();
|
||||||
|
TEMPLATE_MODE = "templates";
|
||||||
|
updateTemplateSelectionControls();
|
||||||
|
}
|
||||||
|
|
||||||
function templateOptionTitle(candidate) {
|
function templateOptionTitle(candidate) {
|
||||||
var details = [];
|
var details = [];
|
||||||
var summary = templateCandidateSummary(candidate);
|
var summary = templateCandidateSummary(candidate);
|
||||||
@@ -1283,20 +1301,29 @@
|
|||||||
});
|
});
|
||||||
field.appendChild(select);
|
field.appendChild(select);
|
||||||
|
|
||||||
if (slot === "explicit") {
|
return field;
|
||||||
field.appendChild(el(
|
}
|
||||||
"div",
|
|
||||||
"template-select-help",
|
function renderExplicitTemplateChoices() {
|
||||||
candidates.length ? t("template_explicit_hint") : t("template_none_explicit")
|
var field = el("div", "template-select-field template-select-field-explicit");
|
||||||
));
|
field.appendChild(el("div", "template-select-label", t("template_source_explicit")));
|
||||||
var path = el("div", "template-selected-path");
|
var roots = explicitRootOptions();
|
||||||
path.id = "template-explicit-path";
|
field.appendChild(el("div", "template-select-help",
|
||||||
path.appendChild(el("span", "template-selected-path-label", t("template_source_path") + ":"));
|
roots.length ? t("template_explicit_hint") : t("template_none_explicit")));
|
||||||
var code = el("code", "template-selected-path-value");
|
roots.forEach(function (candidate) {
|
||||||
code.id = "template-explicit-path-value";
|
var label = el("label", "template-select-help");
|
||||||
path.appendChild(code);
|
var checkbox = el("input", "template-explicit-choice");
|
||||||
field.appendChild(path);
|
checkbox.type = "checkbox";
|
||||||
}
|
checkbox.value = candidate.workspace_root;
|
||||||
|
checkbox.checked = TEMPLATE_SELECTIONS.explicit.indexOf(checkbox.value) >= 0;
|
||||||
|
checkbox.addEventListener("change", function () {
|
||||||
|
chooseExplicitTemplateRoot(checkbox.value, checkbox.checked);
|
||||||
|
});
|
||||||
|
label.appendChild(checkbox);
|
||||||
|
label.appendChild(el("span", "", templateCandidateTitle(candidate) + " · " + candidate.summary));
|
||||||
|
label.appendChild(el("code", "template-selected-path-value", candidate.workspace_root));
|
||||||
|
field.appendChild(label);
|
||||||
|
});
|
||||||
return field;
|
return field;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1339,27 +1366,13 @@
|
|||||||
var candidates = TEMPLATE_OPTIONS.library[kind] || [];
|
var candidates = TEMPLATE_OPTIONS.library[kind] || [];
|
||||||
grid.appendChild(renderTemplateSelectField(kind, templateKindLabel(kind), candidates));
|
grid.appendChild(renderTemplateSelectField(kind, templateKindLabel(kind), candidates));
|
||||||
});
|
});
|
||||||
grid.appendChild(renderTemplateSelectField(
|
grid.appendChild(renderExplicitTemplateChoices());
|
||||||
"explicit",
|
|
||||||
t("template_source_explicit"),
|
|
||||||
explicitRootOptions()
|
|
||||||
));
|
|
||||||
panel.appendChild(grid);
|
panel.appendChild(grid);
|
||||||
sec.appendChild(panel);
|
sec.appendChild(panel);
|
||||||
host.appendChild(sec);
|
host.appendChild(sec);
|
||||||
updateTemplateSelectionControls();
|
updateTemplateSelectionControls();
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateTemplateExplicitPath() {
|
|
||||||
var path = document.getElementById("template-explicit-path");
|
|
||||||
var value = document.getElementById("template-explicit-path-value");
|
|
||||||
if (!path || !value) return;
|
|
||||||
var workspaceRoot = TEMPLATE_SELECTIONS.explicit || "";
|
|
||||||
path.hidden = !workspaceRoot;
|
|
||||||
value.textContent = workspaceRoot;
|
|
||||||
value.title = workspaceRoot;
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateTemplateSelectionControls() {
|
function updateTemplateSelectionControls() {
|
||||||
var freeChoice = document.getElementById("template-free-choice");
|
var freeChoice = document.getElementById("template-free-choice");
|
||||||
var useChoice = document.getElementById("template-use-choice");
|
var useChoice = document.getElementById("template-use-choice");
|
||||||
@@ -1376,11 +1389,13 @@
|
|||||||
useChoice.setAttribute("aria-expanded", templatesSelected ? "true" : "false");
|
useChoice.setAttribute("aria-expanded", templatesSelected ? "true" : "false");
|
||||||
}
|
}
|
||||||
if (selectorPanel) selectorPanel.hidden = !templatesSelected;
|
if (selectorPanel) selectorPanel.hidden = !templatesSelected;
|
||||||
TEMPLATE_KINDS.concat(["explicit"]).forEach(function (slot) {
|
TEMPLATE_KINDS.forEach(function (slot) {
|
||||||
var select = document.getElementById("template-select-" + slot);
|
var select = document.getElementById("template-select-" + slot);
|
||||||
if (select) select.value = TEMPLATE_SELECTIONS[slot] || "";
|
if (select) select.value = TEMPLATE_SELECTIONS[slot] || "";
|
||||||
});
|
});
|
||||||
updateTemplateExplicitPath();
|
document.querySelectorAll(".template-explicit-choice").forEach(function (checkbox) {
|
||||||
|
checkbox.checked = TEMPLATE_SELECTIONS.explicit.indexOf(checkbox.value) >= 0;
|
||||||
|
});
|
||||||
var status = document.getElementById("confirm-status");
|
var status = document.getElementById("confirm-status");
|
||||||
if (status) status.textContent = "";
|
if (status) status.textContent = "";
|
||||||
}
|
}
|
||||||
@@ -4237,6 +4252,7 @@
|
|||||||
function stage1Payload() {
|
function stage1Payload() {
|
||||||
var payload = communicationPayload();
|
var payload = communicationPayload();
|
||||||
payload.stage = "stage1";
|
payload.stage = "stage1";
|
||||||
|
payload.options_sha256 = TEMPLATE_OPTIONS.options_sha256;
|
||||||
payload.template_selection = {
|
payload.template_selection = {
|
||||||
mode: TEMPLATE_MODE,
|
mode: TEMPLATE_MODE,
|
||||||
selection_keys: TEMPLATE_MODE === "templates"
|
selection_keys: TEMPLATE_MODE === "templates"
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
# Confirm UI — Strategist and Template Confirmation Page
|
# Confirm UI — Strategist and Template Confirmation Page
|
||||||
|
|
||||||
> The interactive surface for [`generate-pptx`](../../workflows/generate-pptx.md)
|
> The interactive surface for [`generate-pptx`](../../workflows/generate-pptx.md)
|
||||||
> Step 4. Stage 1 shows the template-independent communication recommendation
|
> Step 4. Stage 1 shows the communication recommendation
|
||||||
> and the template/free-design choice on one page and confirms both with one
|
> and the template/free-design choice on one page and confirms both with one
|
||||||
> submission. That submission writes the Strategist contract to `result.json`
|
> submission. That submission writes the Strategist contract to `result.json`
|
||||||
> and the selection sidecar to `template_selection.json`. The agent then installs
|
> and the selection sidecar to `template_selection.json`. The agent then installs
|
||||||
> any selected workspace and writes `template_handoff.json`; only afterward does
|
> any selected workspace; the server verifies its `template_install.json` before
|
||||||
> final Stage 2 read installed template state and confirm the coordinated deck
|
> exposing final Stage 2, which reads installed template state and confirms the coordinated deck
|
||||||
> solution plus production mechanics. The chat path mirrors these boundaries
|
> solution plus production mechanics. The chat path mirrors these boundaries
|
||||||
> without fabricating UI receipts.
|
> without fabricating UI receipts.
|
||||||
|
|
||||||
@@ -35,12 +35,10 @@ The following launch and wait commands belong to the **UI branch only**:
|
|||||||
```bash
|
```bash
|
||||||
python3 scripts/confirm_ui/server.py <project_path> --daemon # launch combined Stage 1
|
python3 scripts/confirm_ui/server.py <project_path> --daemon # launch combined Stage 1
|
||||||
python3 scripts/confirm_ui/server.py <project_path> --wait-only --wait-stage stage1 # communication + template selection
|
python3 scripts/confirm_ui/server.py <project_path> --wait-only --wait-stage stage1 # communication + template selection
|
||||||
python3 scripts/confirm_ui/server.py <project_path> --complete-template-selection # after Stage-1 free-design closure / template install
|
|
||||||
python3 scripts/confirm_ui/server.py <project_path> --wait-only # current final Stage 2
|
python3 scripts/confirm_ui/server.py <project_path> --wait-only # current final Stage 2
|
||||||
python3 scripts/confirm_ui/server.py <project_path> --daemon --port 5051
|
python3 scripts/confirm_ui/server.py <project_path> --daemon --port 5051
|
||||||
python3 scripts/confirm_ui/server.py <project_path> --no-browser
|
python3 scripts/confirm_ui/server.py <project_path> --no-browser
|
||||||
python3 scripts/confirm_ui/server.py <project_path> --timeout 0 # disable idle auto-shutdown
|
python3 scripts/confirm_ui/server.py <project_path> --timeout 0 # disable idle auto-shutdown
|
||||||
python3 scripts/confirm_ui/server.py <project_path> --reset-template-selection # clear prior template sidecars before a fresh UI run
|
|
||||||
python3 scripts/confirm_ui/server.py <project_path> --shutdown # Step 4 cleanup (idempotent)
|
python3 scripts/confirm_ui/server.py <project_path> --shutdown # Step 4 cleanup (idempotent)
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -49,14 +47,14 @@ python3 scripts/confirm_ui/server.py <project_path> --shutdown # Step 4 clean
|
|||||||
- 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.
|
- 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.
|
- Stage 2 checks the selected roots and the installer's receipt directly. There is no manual completion command or handoff sidecar.
|
||||||
- `--shutdown` stops a confirm server left running for this project and exits — **idempotent** (a no-op when nothing is running). Tries a graceful `/api/shutdown`, falls back to killing the recorded pid, then clears the lock. Generate Step 4 runs this on every path so the selected port is released before live preview starts.
|
- `--shutdown` stops a confirm server left running for this project and exits — **idempotent** (a no-op when nothing is running). Tries a graceful `/api/shutdown`, falls back to killing the recorded pid, then clears the lock. Generate Step 4 runs this on every path so the selected port is released before live preview starts.
|
||||||
- Every fresh UI run starts with `--reset-template-selection`, then writes valid `<project_path>/confirm_ui/template_options.json` and a newer `recommendations.stage1.json`; `explicit_workspace_roots` is an empty array when no exact root was supplied. Stage 1 writes the bound selection and communication result together. Stage 2 is exposed only when the matching handoff is newer than that selection and its recommendation is newer than the handoff. `--shutdown` needs neither input.
|
- A run starts with `recommendations.stage1.json` containing `template_options`. The complete recommendation's canonical JSON SHA-256 participates in the option digest. Changing its content starts a new round and invalidates old selections, results, installation receipts and Stage-2 recommendations. To deliberately repeat identical choices in a new round, change a top-level `revision` value; touching timestamps or reformatting JSON does not start a round. Legacy standalone options/handoff files are ignored. Old results without the content binding require Stage 1 again.
|
||||||
- Per-project lock at `<project_path>/.confirm_ui.lock` — duplicate launches are refused; stale locks (dead pid) are overwritten.
|
- Per-project lock at `<project_path>/.confirm_ui.lock` — duplicate launches are refused; stale locks (dead pid) are overwritten.
|
||||||
- Idle auto-shutdown after 900 s by default; `/api/shutdown` exits gracefully and releases the lock.
|
- Idle auto-shutdown after 900 s by default; `/api/shutdown` exits gracefully and releases the lock.
|
||||||
- Stage-1 `GET /api/recommendations` embeds the server-built candidate catalog
|
- Stage-1 `GET /api/recommendations` embeds the server-built candidate catalog
|
||||||
as top-level `template_options`. Its `/api/confirm` submission validates
|
as top-level `template_options`. Its `/api/confirm` submission validates
|
||||||
current candidate keys and writes `template_selection.json` beside the pure
|
the rendered options digest and current candidate keys, then writes `template_selection.json` beside the pure
|
||||||
Strategist `result.json`; there is no independent template-options endpoint,
|
Strategist `result.json`; there is no independent template-options endpoint,
|
||||||
template-confirm endpoint, or template wait stage. The same APIs later serve
|
template-confirm endpoint, or template wait stage. The same APIs later serve
|
||||||
Stage 2 and strip legacy `template_reuse_scope` / `template_adherence`
|
Stage 2 and strip legacy `template_reuse_scope` / `template_adherence`
|
||||||
@@ -76,17 +74,20 @@ Template selection shares the Stage-1 page and submit action but remains a
|
|||||||
separate artifact from the Strategist contract. Its files live under
|
separate artifact from the Strategist contract. Its files live under
|
||||||
`<project_path>/confirm_ui/`; selection keys never enter `result.json`.
|
`<project_path>/confirm_ui/`; selection keys never enter `result.json`.
|
||||||
|
|
||||||
### Input — `template_options.json` (created before launch)
|
### Input — `recommendations.stage1.json.template_options`
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"schema_version": 1,
|
"stage": "stage1",
|
||||||
"phase": "template",
|
"primary_language": "en",
|
||||||
"lang": "zh",
|
"revision": 1,
|
||||||
"default_mode": "free_design",
|
"template_options": {
|
||||||
"explicit_workspace_roots": [
|
"schema_version": 1,
|
||||||
"/absolute/path/to/a/project-or-template-workspace"
|
"phase": "template",
|
||||||
]
|
"lang": "en",
|
||||||
|
"default_mode": "templates",
|
||||||
|
"explicit_workspace_roots": ["/absolute/path/to/template-workspace"]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -100,9 +101,9 @@ separate artifact from the Strategist contract. Its files live under
|
|||||||
`templates/design_spec.md`, one or more project-qualified
|
`templates/design_spec.md`, one or more project-qualified
|
||||||
`templates/design_spec.<kind>.<id>.md`, or compatible legacy
|
`templates/design_spec.<kind>.<id>.md`, or compatible legacy
|
||||||
`design_spec.md`.
|
`design_spec.md`.
|
||||||
- The array supplies candidates for the one specified-root dropdown; it does
|
- The array supplies exact-root candidates. The user may select multiple roots
|
||||||
not authorize selecting several explicit roots in one confirmation.
|
when their kind sets do not overlap; each root is selected atomically.
|
||||||
- Do not write library entries into this file. The server reads only
|
- Do not write library entries into this block. The server reads only
|
||||||
`templates/brands/brands_index.json`,
|
`templates/brands/brands_index.json`,
|
||||||
`templates/styles/styles_index.json`,
|
`templates/styles/styles_index.json`,
|
||||||
`templates/layouts/layouts_index.json`, and
|
`templates/layouts/layouts_index.json`, and
|
||||||
@@ -144,22 +145,21 @@ to the sidecar rather than copying keys into `result.json`.
|
|||||||
When the input supplies exactly one root, `preselected_keys` contains its
|
When the input supplies exactly one root, `preselected_keys` contains its
|
||||||
resolved candidate key as a convenience default, including when exact equality
|
resolved candidate key as a convenience default, including when exact equality
|
||||||
reclassifies it as library. When several roots are supplied, all remain
|
reclassifies it as library. When several roots are supplied, all remain
|
||||||
candidates but none is preselected; one specified-root dropdown cannot encode
|
candidates but none is preselected; supplying paths does not select all of them.
|
||||||
an instruction to use all of them.
|
|
||||||
|
|
||||||
**Page selection model**: Stage 1 first asks the user to choose `Free design` or
|
**Page selection model**: Stage 1 first asks the user to choose `Free design` or
|
||||||
`Use templates`, initialized from `default_mode` but always switchable. Exactly
|
`Use templates`, initialized from `default_mode` but always switchable. Exactly
|
||||||
one supplied root may initialize its candidate as an editable convenience
|
one supplied root may initialize its candidate as an editable convenience
|
||||||
default; multiple roots remain unselected. Only `Use templates` expands the
|
default; multiple roots remain unselected. Only `Use templates` expands the
|
||||||
candidate controls: Brand, Style, Layout, and Deck each have one registered
|
candidate controls: Brand, Style, Layout, and Deck each have one registered
|
||||||
single-select dropdown, and Specified has one explicit-root single-select
|
single-select dropdown, and Specified has one checkbox per exact root with its
|
||||||
dropdown. Every dropdown includes `None`; template mode cannot submit until at
|
path and kinds visible. Every dropdown includes `None`; template mode cannot submit until at
|
||||||
least one is non-empty. Free design clears all dropdowns. Registered kinds may
|
least one root is selected. Free design clears all selections. Registered kinds may
|
||||||
be combined, and the complete selection contains at most one contribution per
|
be combined, and the complete selection contains at most one contribution per
|
||||||
kind. Layout and Deck may coexist; Layout takes structural precedence. The specified channel contributes at
|
kind. Layout and Deck may coexist; Layout takes structural precedence. Each specified
|
||||||
most one root, selected atomically with every kind it exposes; it can coexist
|
root contributes every kind it exposes and may coexist with other explicit or
|
||||||
only with registered roots of non-overlapping kinds. Source provenance never
|
registered roots of non-overlapping kinds. Conflicts block submission; path order
|
||||||
grants priority.
|
and source provenance never grant priority.
|
||||||
|
|
||||||
### Output — `template_selection.json` (written with Stage 1)
|
### Output — `template_selection.json` (written with Stage 1)
|
||||||
|
|
||||||
@@ -182,6 +182,22 @@ grants priority.
|
|||||||
"workspace_root": "/canonical/unregistered/workspace/root"
|
"workspace_root": "/canonical/unregistered/workspace/root"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"root_snapshots": [
|
||||||
|
{
|
||||||
|
"workspace_root": "/canonical/library/root/example_style",
|
||||||
|
"source": "library",
|
||||||
|
"kinds": ["style"],
|
||||||
|
"specs": [{"kind": "style", "id": "example_style", "path": "templates/design_spec.md"}],
|
||||||
|
"files": {"templates/design_spec.md": "<source SHA-256>"}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"workspace_root": "/canonical/unregistered/workspace/root",
|
||||||
|
"source": "explicit",
|
||||||
|
"kinds": ["deck"],
|
||||||
|
"specs": [{"kind": "deck", "id": "example_deck", "path": "templates/design_spec.deck.example_deck.md"}],
|
||||||
|
"files": {"templates/design_spec.deck.example_deck.md": "<source SHA-256>", "templates/01_cover.svg": "<source SHA-256>"}
|
||||||
|
}
|
||||||
|
],
|
||||||
"options_sha256": "<64 lowercase hex characters>",
|
"options_sha256": "<64 lowercase hex characters>",
|
||||||
"selection_sha256": "<64 lowercase hex characters>",
|
"selection_sha256": "<64 lowercase hex characters>",
|
||||||
"confirmed_at": "2026-08-04T12:00:00"
|
"confirmed_at": "2026-08-04T12:00:00"
|
||||||
@@ -192,53 +208,55 @@ grants priority.
|
|||||||
at least one selection. Roots are unique canonical absolute paths. A library
|
at least one selection. Roots are unique canonical absolute paths. A library
|
||||||
selection contains exactly `source`, `kind`, `id`, and `workspace_root`; an
|
selection contains exactly `source`, `kind`, `id`, and `workspace_root`; an
|
||||||
explicit selection contains exactly `source`, `kind`, and `workspace_root`.
|
explicit selection contains exactly `source`, `kind`, and `workspace_root`.
|
||||||
There is at most one explicit workspace **root** overall. The unit of choice is
|
Multiple explicit workspace roots may coexist. The unit of choice is
|
||||||
the root, not the kind: the browser's specified-path control lists roots, and
|
the root: the browser's specified-path control lists roots, and
|
||||||
selecting one emits a selection for every kind that root exposes. Across those
|
selecting one emits a selection for every kind that root exposes. Across those
|
||||||
emitted selections and all library choices, each kind appears at most once.
|
emitted selections and all library choices, each kind appears at most once.
|
||||||
Layout and Deck may coexist; downstream installation gives Layout structural
|
Layout and Deck may coexist; downstream installation gives Layout structural
|
||||||
precedence. The browser cannot submit arbitrary paths
|
precedence. The browser cannot submit arbitrary paths
|
||||||
because the server resolves posted keys against the catalog it just built.
|
because the server resolves posted keys against the catalog it just built.
|
||||||
`options_sha256` binds the receipt to the current input, four index files, and
|
`root_snapshots` freezes each selected root's source label, kinds, spec identities,
|
||||||
resolved candidates. `selection_sha256` binds the mode and canonical sorted
|
and SHA-256 hashes for all files under `templates/`, `images/`, and `icons/`.
|
||||||
selections to that option hash. Every receipt read rebuilds the catalog and
|
`options_sha256` binds the receipt to the entire Stage-1 recommendation, four index files, and
|
||||||
rejects option/index drift or an incomplete explicit-root bundle.
|
resolved candidates. `selection_sha256` binds the mode, canonical sorted
|
||||||
|
selections, and snapshots to that option hash. Every receipt read checks source
|
||||||
|
bytes and rebuilds the catalog using the frozen selected-root kinds, while
|
||||||
|
retaining option/index and unselected-candidate drift checks. A changed in-place
|
||||||
|
project root is accepted only when a matching installer receipt proves the
|
||||||
|
expected installed specs, active roster, and required assets. External source
|
||||||
|
changes still require confirmation again. Old selections without snapshots must
|
||||||
|
be confirmed again; the server does not reconstruct historical authorization.
|
||||||
|
|
||||||
The Stage-1 submission writes this receipt and the Stage-1 `result.json`
|
The Stage-1 submission writes this receipt and the Stage-1 `result.json`
|
||||||
together. Generate reads both exactly once after `--wait-only --wait-stage
|
together. Generate reads both exactly once after `--wait-only --wait-stage
|
||||||
stage1` returns. Free design skips installation. Template mode runs
|
stage1` returns. Free design skips installation. Template mode runs
|
||||||
`apply-template-workspace` against all selected roots and waits for complete
|
`apply-template-workspace` against all selected roots and waits for complete
|
||||||
project-local installation. Only then does the agent complete the
|
project-local installation, which the server checks directly. Installation validates and maps each distinct root once while
|
||||||
handoff below. Installation validates and maps each distinct root once while
|
|
||||||
preserving its separate specs; Stage 2 resolves segment ownership and
|
preserving its separate specs; Stage 2 resolves segment ownership and
|
||||||
current-project fit from the installed set. Strategist never reads the source
|
current-project fit from the installed set. Strategist never reads the source
|
||||||
roots.
|
roots.
|
||||||
|
|
||||||
### Agent handoff — `template_handoff.json`
|
### Installation and Stage-2 readiness
|
||||||
|
|
||||||
After free design closes or template installation succeeds, run:
|
Stage 2 reads the current selection and the machine receipt from
|
||||||
|
[apply_template.py](template-tools.md#apply_templatepy). The receipt's roots,
|
||||||
|
kinds, selection digest, complete installed spec set, active roster and required
|
||||||
|
file hashes must match. Missing, unrelated or modified installation blocks both
|
||||||
|
GET recommendations and POST confirmation. Additional project images/icons are
|
||||||
|
allowed; free design requires no installation receipt.
|
||||||
|
|
||||||
```bash
|
Write top-level `selection_sha256` in `recommendations.stage2.json`, copied from
|
||||||
python3 scripts/confirm_ui/server.py <project_path> --complete-template-selection
|
the current `template_selection.json`. The server also binds `result.json` to
|
||||||
```
|
the complete Stage-1 input (`stage1_sha256`) and the user selection
|
||||||
|
(`selection_sha256`). No readiness decision depends on cross-file timestamps.
|
||||||
|
Stage-1 POST includes top-level `options_sha256` from the catalog the browser
|
||||||
|
rendered; a stale page must reload before confirming. The browser performs this
|
||||||
|
transport step automatically.
|
||||||
|
|
||||||
The command writes, and agents must not hand-author:
|
`--complete-template-selection` and `--reset-template-selection` were removed.
|
||||||
|
Keep the confirmed Stage-1 file unchanged while authoring Stage 2. A new
|
||||||
```json
|
Stage-1 content revision starts another round without deleting any prior files;
|
||||||
{
|
old files cannot authorize Stage 2 for that new input.
|
||||||
"schema_version": 1,
|
|
||||||
"phase": "template",
|
|
||||||
"status": "ready",
|
|
||||||
"mode": "templates",
|
|
||||||
"selection_sha256": "<64 lowercase hex characters>",
|
|
||||||
"completed_at": "2026-08-04T12:01:00"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
The handoff must match the current valid selection. Template mode also requires
|
|
||||||
at least one `<project_path>/templates/design_spec.<kind>.<id>.md`; free design
|
|
||||||
requires no installed spec. Write `recommendations.stage2.json` only after this
|
|
||||||
command succeeds, so its file time is newer than the handoff.
|
|
||||||
|
|
||||||
## Field shapes
|
## Field shapes
|
||||||
|
|
||||||
@@ -267,12 +285,11 @@ The front-end loads `/api/catalogs` (served by the confirm server) and falls bac
|
|||||||
|
|
||||||
## Round-trip data contract
|
## Round-trip data contract
|
||||||
|
|
||||||
In the UI branch, round-trip and session files live under
|
UI recommendations, selection and result files live under
|
||||||
`<project_path>/confirm_ui/`. `template_options.json` is prepared beside the
|
`<project_path>/confirm_ui/`. Stage 1 embeds `template_options` and submits
|
||||||
Stage-1 recommendation. The Stage-1 submit writes `result.json` and
|
`result.json` with `template_selection.json`. Stage 2 requires matching selection
|
||||||
`template_selection.json` together; after installation/free-design closure,
|
and installation content (or free design); see the protocol above. Chat uses the
|
||||||
`template_handoff.json` unlocks Stage 2. The chat/delegated branch preserves the
|
same logical order without fabricating UI receipts.
|
||||||
same logical order without fabricating these UI receipts.
|
|
||||||
|
|
||||||
### Current two-stage flow
|
### Current two-stage flow
|
||||||
|
|
||||||
@@ -283,24 +300,16 @@ unconfirmed stage may be overwritten any number of times when the user asks for
|
|||||||
a better recommendation; refresh the page to load the replacement. Once the
|
a better recommendation; refresh the page to load the replacement. Once the
|
||||||
user confirms it, normal progression writes the next stage file rather than
|
user confirms it, normal progression writes the next stage file rather than
|
||||||
repurposing the previous one. The server derives the active Strategist filename
|
repurposing the previous one. The server derives the active Strategist filename
|
||||||
from `result.json`; the bound template handoff is the prerequisite for Stage 2.
|
from `result.json` and its Stage-1 content binding; verified installation is the prerequisite for Stage 2.
|
||||||
|
|
||||||
Confirm UI is a one-run surface, not a migration layer. It accepts only the
|
A changed Stage-1 recommendation starts a new round; unchanged content resumes
|
||||||
current Stage-1/Stage-2 files. If a project starts UI confirmation again, run
|
the current round. A new `revision` value can request another round with the same
|
||||||
`--reset-template-selection`, write fresh `template_options.json`, then author a
|
choices. Stale selection/results and Stage-2 files cannot satisfy the new content
|
||||||
fresh `recommendations.stage1.json`. Those two inputs start the new UI
|
binding. Completed results stay closed until a new round starts.
|
||||||
lifecycle; neither a standalone newer Stage-1 file nor a standalone option file
|
|
||||||
does so.
|
|
||||||
The completed result cannot be reopened or overwritten, and a standalone newer
|
|
||||||
Stage-1 file cannot start its replacement. After Stage 1 writes both receipts,
|
|
||||||
the agent completes a newer bound handoff; `recommendations.stage2.json` must be
|
|
||||||
newer than that handoff and the Stage-1 result. A Stage-2 file left by an earlier
|
|
||||||
run remains inactive. An existing `result.json` outside the current `stage1` /
|
|
||||||
`final` contract also fails closed unless fresh paired inputs start a new run.
|
|
||||||
|
|
||||||
| 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` (embedded `template_options`) | `"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 and the server verifies the selected roots |
|
||||||
| `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 |
|
| `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,
|
||||||
@@ -320,30 +329,35 @@ the final Stage-2 payload so an in-run refresh preserves the confirmed Stage-1
|
|||||||
communication contract. Unsubmitted Stage-2 edits are browser-local and a
|
communication contract. Unsubmitted Stage-2 edits are browser-local and a
|
||||||
completed final result is never reopened.
|
completed final result is never reopened.
|
||||||
|
|
||||||
**Progression guard.** Stage 1 requires current `template_options.json` and a
|
**Progression guard.** Stage 1 requires valid recommendations with embedded
|
||||||
fresh `recommendations.stage1.json`; it does not require a prior selection or
|
template options. Its submit binds the result and selection to that content.
|
||||||
handoff. Its one submit must persist a valid Stage-1 result and selection bound
|
Stage 2 requires the current confirmed result, the selected roots' verified
|
||||||
to those options. Stage 2 requires a newer `template_handoff.json` bound to that
|
installation (or free design), and a recommendation with matching
|
||||||
selection and a fresh Stage-2 recommendation. This ordering prevents receipts
|
`selection_sha256`. File timestamps do not authorize progression.
|
||||||
from an earlier one-run UI from satisfying a new run. Strategist confirms Stage
|
|
||||||
1 → installation/free-design handoff → final Stage 2.
|
|
||||||
`/api/confirm` accepts only the submit stage matching the
|
`/api/confirm` accepts only the submit stage matching the
|
||||||
active filename and its required predecessor; the declared `stage` must also
|
active filename and its required predecessor; the declared `stage` must also
|
||||||
match the filename. A confirmed templates-mode workspace does not exempt final
|
match the filename. A confirmed templates-mode workspace does not exempt final
|
||||||
Stage 2: its recommendations must include `template_application.value`.
|
Stage 2: its recommendations must include `template_application.value`.
|
||||||
|
|
||||||
### Input — `recommendations.stage1.json` (created beside template options)
|
### Input — `recommendations.stage1.json` (includes template options)
|
||||||
|
|
||||||
Author this file before reading candidate index summaries in chat and without
|
Author the Stage-1 communication recommendation with embedded `template_options`.
|
||||||
reading any template spec, prototype, asset, segment owner, or template
|
Template facts may inform canvas/page recommendations but never rewrite the
|
||||||
canvas. `template_options.json` supplies display state only and never changes
|
confirmed communication goal. The template block supplies candidate display
|
||||||
the communication recommendation.
|
state and does not confirm a choice.
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"stage": "stage1",
|
"stage": "stage1",
|
||||||
"lang": "zh",
|
"lang": "zh",
|
||||||
"primary_language": "zh-CN",
|
"primary_language": "zh-CN",
|
||||||
|
"template_options": {
|
||||||
|
"schema_version": 1,
|
||||||
|
"phase": "template",
|
||||||
|
"lang": "zh",
|
||||||
|
"default_mode": "free_design",
|
||||||
|
"explicit_workspace_roots": []
|
||||||
|
},
|
||||||
"recommend": {
|
"recommend": {
|
||||||
"canvas": "ppt169"
|
"canvas": "ppt169"
|
||||||
},
|
},
|
||||||
@@ -391,6 +405,7 @@ persist it in a new receipt.
|
|||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"stage": "stage2",
|
"stage": "stage2",
|
||||||
|
"selection_sha256": "<copy from the current template_selection.json>",
|
||||||
"lang": "zh",
|
"lang": "zh",
|
||||||
"recommend": {
|
"recommend": {
|
||||||
"delivery_purpose": "balanced",
|
"delivery_purpose": "balanced",
|
||||||
@@ -539,7 +554,7 @@ by the server.
|
|||||||
|
|
||||||
- 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 does not outlive Step 4.
|
- 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 writes Stage 2 bound to the selection after installation 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
|
||||||
|
|
||||||
|
|||||||
+4
-11
@@ -82,6 +82,8 @@ with TemporaryDirectory(prefix="ppt-master-multilingual-smoke-") as tmp:
|
|||||||
confirm.mkdir(parents=True)
|
confirm.mkdir(parents=True)
|
||||||
recommendation = {
|
recommendation = {
|
||||||
"stage": "stage1",
|
"stage": "stage1",
|
||||||
|
"template_options": {"schema_version": 1, "phase": "template",
|
||||||
|
"default_mode": "free_design", "explicit_workspace_roots": []},
|
||||||
"lang": "en",
|
"lang": "en",
|
||||||
"primary_language": "AR_sa",
|
"primary_language": "AR_sa",
|
||||||
"audience": {"value": "Team"},
|
"audience": {"value": "Team"},
|
||||||
@@ -97,27 +99,18 @@ with TemporaryDirectory(prefix="ppt-master-multilingual-smoke-") as tmp:
|
|||||||
json.dumps(recommendation),
|
json.dumps(recommendation),
|
||||||
encoding="utf-8",
|
encoding="utf-8",
|
||||||
)
|
)
|
||||||
(confirm / "template_options.json").write_text(
|
|
||||||
json.dumps(
|
|
||||||
{
|
|
||||||
"schema_version": 1,
|
|
||||||
"phase": "template",
|
|
||||||
"default_mode": "free_design",
|
|
||||||
"explicit_workspace_roots": [],
|
|
||||||
}
|
|
||||||
),
|
|
||||||
encoding="utf-8",
|
|
||||||
)
|
|
||||||
app = create_app(str(project), idle_timeout=0)
|
app = create_app(str(project), idle_timeout=0)
|
||||||
app.testing = True
|
app.testing = True
|
||||||
client = app.test_client()
|
client = app.test_client()
|
||||||
response = client.get("/api/recommendations")
|
response = client.get("/api/recommendations")
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert response.get_json()["primary_language"] == "ar-SA"
|
assert response.get_json()["primary_language"] == "ar-SA"
|
||||||
|
options_sha256 = response.get_json()["template_options"]["options_sha256"]
|
||||||
response = client.post(
|
response = client.post(
|
||||||
"/api/confirm",
|
"/api/confirm",
|
||||||
json={
|
json={
|
||||||
"stage": "stage1",
|
"stage": "stage1",
|
||||||
|
"options_sha256": options_sha256,
|
||||||
"template_selection": {
|
"template_selection": {
|
||||||
"mode": "free_design",
|
"mode": "free_design",
|
||||||
"selection_keys": [],
|
"selection_keys": [],
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ Main entry point for project setup and validation.
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
python3 scripts/project_manager.py init <project_name> [--format <registered_format>]
|
python3 scripts/project_manager.py init <project_name> [--format <registered_format>]
|
||||||
python3 scripts/project_manager.py import-sources <project_path> <source1_or_dir> [<source2_or_dir> ...]
|
python3 scripts/project_manager.py import-sources <project_path> <source1_or_dir> [<source2_or_dir> ...] [--no-image-propagation]
|
||||||
python3 scripts/project_manager.py scaffold-spec <project_path> # optional manual helper
|
python3 scripts/project_manager.py scaffold-spec <project_path> # optional manual helper
|
||||||
python3 scripts/project_manager.py scaffold-lock <project_path> # optional manual helper
|
python3 scripts/project_manager.py scaffold-lock <project_path> # optional manual helper
|
||||||
python3 scripts/project_manager.py validate <project_path>
|
python3 scripts/project_manager.py validate <project_path>
|
||||||
@@ -22,6 +22,14 @@ python3 scripts/project_manager.py page-context-report <project_path>
|
|||||||
```
|
```
|
||||||
|
|
||||||
Notes:
|
Notes:
|
||||||
|
|
||||||
|
- `import-sources --no-image-propagation` keeps extracted companion assets and
|
||||||
|
their Markdown references in `sources/`, skipping their copy/manifest merge
|
||||||
|
into the runtime `images/` pool. Create Template project-scope reference
|
||||||
|
intake requires this switch; see [Create Template §1A](../../workflows/create-template.md#1a-pptx-reference).
|
||||||
|
Generate keeps the default propagation. It covers
|
||||||
|
new conversions and existing companion Markdown. Explicit bitmap inputs
|
||||||
|
still enter `images/`; the switch does not clean up images imported earlier.
|
||||||
- A moved topic-research pair `projects/<slug>.md` takes its sibling
|
- A moved topic-research pair `projects/<slug>.md` takes its sibling
|
||||||
`projects/<slug>_web_sources/` along to `analysis/research_web_sources/`.
|
`projects/<slug>_web_sources/` along to `analysis/research_web_sources/`.
|
||||||
- Local sources under `projects/` are moved into the target project unless
|
- Local sources under `projects/` are moved into the target project unless
|
||||||
|
|||||||
@@ -576,12 +576,12 @@ detects later visible edits; it is not a semantic-equivalence proof.
|
|||||||
## `mirror_template_materialize.py`
|
## `mirror_template_materialize.py`
|
||||||
|
|
||||||
Validate and publish one Type A PPTX import workspace as a deterministic
|
Validate and publish one Type A PPTX import workspace as a deterministic
|
||||||
structured mirror after Template_Designer has reviewed/authored the new compact
|
structured mirror directly from the importer's layered IR; no per-page edit or
|
||||||
layered SVG:
|
summary refresh is needed:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python3 scripts/mirror_template_materialize.py \
|
python3 scripts/mirror_template_materialize.py \
|
||||||
<import_workspace> <template_workspace>
|
<import_workspace> <template_workspace> [--kind deck|layout]
|
||||||
```
|
```
|
||||||
|
|
||||||
The command treats `<import_workspace>/authoring-svg/` as the sole visible
|
The command treats `<import_workspace>/authoring-svg/` as the sole visible
|
||||||
@@ -649,9 +649,15 @@ The output routes reusable decoration vectors once to `icons/imported/`, image m
|
|||||||
`images/`, audio and video to their semantic directories, and opaque referenced
|
`images/`, audio and video to their semantic directories, and opaque referenced
|
||||||
files to `native-payloads/imported/`. The JSON report
|
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. It also writes a factual Design Spec
|
||||||
`templates/design_spec.md`; Template_Designer writes the package-specific rules
|
skeleton by default (kind `deck`; choose `--kind layout` for a neutral Layout
|
||||||
and page roster after publication. This validator/publisher is for Type A mirror,
|
publication). The skeleton records canvas, source slide count, Master/Layout keys,
|
||||||
|
picker names, slots, and a Source Preservation Map. Identity and design prose
|
||||||
|
remain TODOs; finish them before registration. Resolved workspaces under
|
||||||
|
`skills/ppt-master/templates/<kind_dir>/` use `templates/design_spec.md`;
|
||||||
|
all other workspaces use `templates/design_spec.<kind>.TODO.md`, even when their
|
||||||
|
`templates/` is empty. The `spec_skeleton` receipt records that path.
|
||||||
|
See [template-tools.md](template-tools.md#mirror-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.
|
||||||
|
|
||||||
@@ -891,7 +897,7 @@ Behavior:
|
|||||||
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. 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.
|
- 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.
|
||||||
- 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.
|
- 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 explicit registered `pptx_masters` / `pptx_layouts` sets and one `page_pptx_layouts` assignment per page. Only Layouts listed in `pptx_layouts` register; this may be a subset of installed prototypes. Every page assignment must reference that set. An unused prototype registers only when explicitly listed; Quick without a lock registers only Layouts used by its pages. The checker uses the same dependency set for typed chart/table export hints.
|
||||||
- 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 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.
|
- 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.
|
||||||
|
|||||||
@@ -30,6 +30,45 @@ Produces one import workspace (default `<pptx_stem>_template_import/` beside the
|
|||||||
|
|
||||||
**Artifact roles**: `analysis/manifest.json` is the truth for source-deck facts (slide size, theme, fonts, background inheritance, resource inventory, declared structure, reuse relationships); `analysis/native_structure.json` for source PowerPoint identity (keys, picker names, parents, placeholder types/indices, package hash); `svg/inheritance.json` for consumption and visibility. The three overlap only at contract boundaries so materialization can cross-check identity, ownership, and visibility — never collapse or substitute them. `authoring_summary.json` is the model-facing roster index; `authoring_manifest.json` is machine-only provenance validated by the mirror compiler. Exported `images/` is the canonical reusable image pool; `icons/imported/*.svg` is the canonical decoration pool but not part of the default read set — use `authoring_summary.json` `icon_refs` and cleaned SVGs first, query `*_vector_asset_inventory.json` by exact asset id only when source-ref or fingerprint detail is needed, and open an individual asset only when it affects a design decision.
|
**Artifact roles**: `analysis/manifest.json` is the truth for source-deck facts (slide size, theme, fonts, background inheritance, resource inventory, declared structure, reuse relationships); `analysis/native_structure.json` for source PowerPoint identity (keys, picker names, parents, placeholder types/indices, package hash); `svg/inheritance.json` for consumption and visibility. The three overlap only at contract boundaries so materialization can cross-check identity, ownership, and visibility — never collapse or substitute them. `authoring_summary.json` is the model-facing roster index; `authoring_manifest.json` is machine-only provenance validated by the mirror compiler. Exported `images/` is the canonical reusable image pool; `icons/imported/*.svg` is the canonical decoration pool but not part of the default read set — use `authoring_summary.json` `icon_refs` and cleaned SVGs first, query `*_vector_asset_inventory.json` by exact asset id only when source-ref or fingerprint detail is needed, and open an individual asset only when it affects a design decision.
|
||||||
|
|
||||||
|
## Mirror publication
|
||||||
|
|
||||||
|
Type A mirror is a tool publication path. Run these commands serially on the
|
||||||
|
importer's unchanged output:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 skills/ppt-master/scripts/pptx_template_import.py "<source.pptx>" -o "<import_workspace>" --inheritance-mode both
|
||||||
|
python3 skills/ppt-master/scripts/mirror_template_materialize.py "<import_workspace>" "<template_workspace>" [--kind deck|layout]
|
||||||
|
python3 skills/ppt-master/scripts/svg_quality_checker.py "<template_workspace>/templates" --template-mode --canonical-authoring
|
||||||
|
python3 skills/ppt-master/scripts/template_preview_pptx.py "<template_workspace>" -o "<preview.pptx>"
|
||||||
|
```
|
||||||
|
|
||||||
|
Only `pptx_template_import.py` workspaces with layered IR,
|
||||||
|
`analysis/native_structure.json`, and `sources/source.pptx` qualify. A generic
|
||||||
|
SVG workspace is consumed through `apply_template.py` with its exact root.
|
||||||
|
Do not relabel a generic projection or fabricate source facts.
|
||||||
|
|
||||||
|
The materializer writes the Design Spec skeleton in the same publication
|
||||||
|
transaction. It defaults to Deck because its factual roster is one prototype
|
||||||
|
per source slide; `--kind layout` records a previously chosen neutral Layout
|
||||||
|
target. The default skeleton is necessary for the next checker/preview commands,
|
||||||
|
so it needs no separate enable flag. A workspace whose resolved path lies under
|
||||||
|
`skills/ppt-master/templates/<kind_dir>/` uses `templates/design_spec.md`;
|
||||||
|
every other workspace uses `templates/design_spec.<kind>.TODO.md`, including an
|
||||||
|
empty project workspace. The `spec_skeleton` receipt records that path. It
|
||||||
|
records exact canvas dimensions/viewBox (`custom` if no registered size matches),
|
||||||
|
source page count, Master/Layout keys, picker names, text slots and a Source
|
||||||
|
Preservation Map. The identity and Overview/Color Scheme/Typography/other design
|
||||||
|
sections remain TODOs. Registration rejects the unfinished skeleton with an
|
||||||
|
actionable message; Template_Designer completes its judgment before registering.
|
||||||
|
|
||||||
|
The execution manifest records the source package hash, whether every reachable
|
||||||
|
import document still matches its importer hash, and hashes of published SVGs
|
||||||
|
and their native records/assets. Template-mode checks classify only unchanged source text-bound estimates
|
||||||
|
and source-object overlaps as inherited information. Edited import documents, SVGs or packaged dependencies invalidate that classification; XML, images, native payloads and reusable structure remain blocking.
|
||||||
|
This is source preservation, not proof that every original slide fits. The
|
||||||
|
checker and preview do not require completed design prose. Detailed source
|
||||||
|
validation, asset packaging and rollback: [svg-pipeline.md](svg-pipeline.md#mirror_template_materializepy).
|
||||||
|
|
||||||
## Type B source bundles
|
## Type B source bundles
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -47,13 +86,39 @@ python3 skills/ppt-master/scripts/svg_quality_checker.py "<template_source>" --t
|
|||||||
|
|
||||||
Globs `*.svg` in the template directory; skips `spec_lock.md` drift checks; enforces roster ↔ resolved Design Spec consistency as errors (orphan or missing files break the contract and, in library scope, the index); emits advisory warnings when a page lacks a conventional placeholder (silence them with a `placeholders:` frontmatter map); requires every SVG root to declare one output Master and Layout (zero-slot Layouts are valid); rejects ordinary Master/Layout `<g>` elements, nested structure markers, missing slot bounds, and carrier-bound slots without exactly one compatible carrier (a validated compact authored-preset `<g>` is the sole fixed-layer group exception and may be one `object` carrier); validates cross-page Master equality and same-key Layout atom/slot equality; warns when distinct Layout keys have identical static framing/slot contracts. For `kind: brand` it validates the identity-only frontmatter/sections/colors/provenance/asset references; for `kind: style` the frontmatter, section/field shape, conditional custom and fallback values, portable ID, and one-file roster-free boundary. It validates the authoring contract, not the compiled OOXML package.
|
Globs `*.svg` in the template directory; skips `spec_lock.md` drift checks; enforces roster ↔ resolved Design Spec consistency as errors (orphan or missing files break the contract and, in library scope, the index); emits advisory warnings when a page lacks a conventional placeholder (silence them with a `placeholders:` frontmatter map); requires every SVG root to declare one output Master and Layout (zero-slot Layouts are valid); rejects ordinary Master/Layout `<g>` elements, nested structure markers, missing slot bounds, and carrier-bound slots without exactly one compatible carrier (a validated compact authored-preset `<g>` is the sole fixed-layer group exception and may be one `object` carrier); validates cross-page Master equality and same-key Layout atom/slot equality; warns when distinct Layout keys have identical static framing/slot contracts. For `kind: brand` it validates the identity-only frontmatter/sections/colors/provenance/asset references; for `kind: style` the frontmatter, section/field shape, conditional custom and fallback values, portable ID, and one-file roster-free boundary. It validates the authoring contract, not the compiled OOXML package.
|
||||||
|
|
||||||
|
### Advisory slot capacity
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 scripts/svg_quality_checker.py "<template_workspace_or_svg>" --template-mode --slot-capacity-report
|
||||||
|
```
|
||||||
|
|
||||||
|
This read-only mode prints JSON to stdout and creates no report or workflow log.
|
||||||
|
It does not run the quality gate or add errors/warnings. Use it during Create
|
||||||
|
Template review and before the Strategist locks typography. Each text carrier
|
||||||
|
reports its slot bounds, inherited font family/size/weight/tracking, line pitch
|
||||||
|
and representative Latin/CJK capacities measured with `text_measure.measure_text`
|
||||||
|
(including its calibration/headroom). Latin uses a stated letter sample; CJK uses
|
||||||
|
a full-width glyph. Actual text must still be measured. Line pitch comes from
|
||||||
|
declared `line-height`, explicit tspan baselines, or a clearly labeled 1.2em
|
||||||
|
estimate. If visible text uses tspans, it reads the first visible run and reports
|
||||||
|
mixed family/size variants separately. The estimate assumes uniform carrier typography and does not promise
|
||||||
|
a fixed capacity across fonts, mixed runs, languages or wrapping choices.
|
||||||
|
It also reports `baseline_allowed: [top + 0.85 * size, bottom - 0.35 * size]`
|
||||||
|
and the current `baseline` (plus `baselines` for multiple lines). Template-mode
|
||||||
|
validation applies the existing vertical overflow severity to placeholder
|
||||||
|
carriers even when their text is a `{{...}}` marker; marker length has no content-width contract.
|
||||||
|
Mirror workspaces also read `template_execution/<prototype>.text-slots.json`:
|
||||||
|
each selector produces a `source: mirror-text-slot` row using the source text
|
||||||
|
frame or measured geometry and effective typography. Empty results include a
|
||||||
|
`note`. Invalid bounds are reported as unavailable data, never as checker findings.
|
||||||
|
|
||||||
## `template_preview_pptx.py`
|
## `template_preview_pptx.py`
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python3 skills/ppt-master/scripts/template_preview_pptx.py "<authoring_workspace>" [--force] [--native-charts-and-tables -o <distinct_path>]
|
python3 skills/ppt-master/scripts/template_preview_pptx.py "<authoring_workspace>" [--force] [--native-charts-and-tables -o <distinct_path>]
|
||||||
```
|
```
|
||||||
|
|
||||||
Consumes `templates/*.svg` directly, compiles the declared structured Master/Layout contract into `<authoring_workspace>/exports/<template_id>_template_preview.pptx` (creating `exports/` on demand), and reopens the result to verify one slide per prototype, the expected Master/Layout counts, exact Presentation → Master → Layout → Slide registration, distinct Theme parts per Master, valid unique `p14:creationId` and registration IDs, and — for `standard` / `fidelity` — that every carrier-bound placeholder on each review Slide has the same type, effective index, and full frame as its registered Layout placeholder. Authored modes use ephemeral SVG copies with concise preview-only sample text so long `{{...}}` markers stay readable; source SVGs, carrier typography, slot metadata, and Layout frames are unchanged. The default review keeps visible Chart/Table fallbacks; `--native-charts-and-tables -o <distinct_path>` writes a separately named JSON-first review. The first export refuses an existing output; `--force` replaces it intentionally. It needs no project `spec_lock.md`, creates no persistent project, and never infers structure.
|
Consumes `templates/*.svg` directly, compiles the declared structured Master/Layout contract into `<authoring_workspace>/exports/<template_id>_template_preview.pptx` (creating `exports/` on demand), and reopens the result to verify one slide per prototype, the expected Master/Layout counts, exact Presentation → Master → Layout → Slide registration, distinct Theme parts per Master, valid unique `p14:creationId` and registration IDs, and — for `standard` / `fidelity` — that every carrier-bound placeholder on each review Slide has the same type, effective index, and full frame as its registered Layout placeholder. Authored modes use ephemeral SVG copies with concise preview-only sample text so long `{{...}}` markers stay readable; source SVGs, carrier typography, slot metadata, and Layout frames are unchanged. Typed chart/table placeholders automatically enable native Chart/Table compilation, with the triggering slot kinds printed in the receipt. Without typed slots, the default keeps visible fallbacks; `--native-charts-and-tables -o <distinct_path>` explicitly requests a separately named native review. The first export refuses an existing output; `--force` replaces it intentionally. It needs no project `spec_lock.md`, creates no persistent project, and never infers structure.
|
||||||
|
|
||||||
## `apply_template.py`
|
## `apply_template.py`
|
||||||
|
|
||||||
@@ -61,7 +126,11 @@ Consumes `templates/*.svg` directly, compiles the declared structured Master/Lay
|
|||||||
python3 skills/ppt-master/scripts/apply_template.py <project_path> --root <workspace_root> [--root <workspace_root> ...] [--dry-run] [--skip-validation]
|
python3 skills/ppt-master/scripts/apply_template.py <project_path> --root <workspace_root> [--root <workspace_root> ...] [--dry-run] [--skip-validation]
|
||||||
```
|
```
|
||||||
|
|
||||||
Runs the install half of [`apply-template-workspace.md`](../../workflows/stages/apply-template-workspace.md) §4 for the roots Stage 1 (Default) or the user (Quick) already selected; it selects nothing. Each `--root` is a workspace root exposing `templates/design_spec.md` (library shape) or one `templates/design_spec.<kind>.<id>.md` per kind (project shape); an inner `templates/` directory is refused so sibling `images/` and `icons/` travel with the spec. Per root it runs `svg_quality_checker.py <root>/templates --template-mode --canonical-authoring` (`--skip-validation` when that already ran in the same turn), labels the root `library` when it resolves to the index-derived `templates/<kind_dir>/<id>/` and `explicit` otherwise, and rejects a Style-only library package that carries `images/`, `icons/`, or `exports/`. It then resolves the structural owner (Layout when selected, otherwise Deck), maps every spec to `<project>/templates/design_spec.<kind>.<id>.md` with exactly one `> **Installed from**: \`<root>\` (library|explicit)` line under the H1, maps the owner's `templates/` roster and other non-bitmap structural files and every root's `images/` / `icons/` once, and refuses duplicate kinds, a root passed twice, and any destination that already exists with different bytes — all before writing; a byte-identical destination is reported as `[same]`, so a rerun is a no-op. A root equal to the target project is consumed in place; when a selected Layout supersedes that project's in-place Deck roster, the Deck's structural files are removed and the Layout roster written in the same run. `--dry-run` prints the mapping and receipt without writing. The last stdout line is the §5.3 completion receipt (`roots=…; sources=…; kinds=…; segments=…; active_roster=…; install=…; installed_specs=…`).
|
Runs the install half of [`apply-template-workspace.md`](../../workflows/stages/apply-template-workspace.md) §4 for the roots Stage 1 (Default) or the user (Quick) already selected; it selects nothing. Each `--root` is a workspace root exposing `templates/design_spec.md` (library shape) or one `templates/design_spec.<kind>.<id>.md` per kind (project shape); an inner `templates/` directory is refused so sibling `images/` and `icons/` travel with the spec. Per root it runs `svg_quality_checker.py <root>/templates --template-mode --canonical-authoring` (`--skip-validation` when that already ran in the same turn), labels the root `library` when it resolves to the index-derived `templates/<kind_dir>/<id>/` and `explicit` otherwise, and rejects a Style-only library package that carries `images/`, `icons/`, or `exports/`. It then resolves the structural owner (Layout when selected, otherwise Deck), maps every spec to `<project>/templates/design_spec.<kind>.<id>.md` with exactly one `> **Installed from**: \`<root>\` (library|explicit)` line under the H1, maps the owner's `templates/` roster and other non-bitmap structural files and every root's `images/` / `icons/` once, and refuses duplicate kinds, a root passed twice, and any destination that already exists with different bytes — all before writing; a byte-identical destination is reported as `[same]`, preserving content on reruns. A root equal to the target project is consumed in place; when a selected Layout supersedes that project's in-place Deck roster, the Deck's structural files are replaced by the Layout roster. `--dry-run` prints the mapping and receipt without writing. The last stdout line is the §5.3 completion receipt (`roots=…; sources=…; kinds=…; segments=…; active_roster=…; install=…; installed_specs=…`).
|
||||||
|
|
||||||
|
The installer stages complete `templates/`, `images/`, and `icons/` trees on the target filesystem, verifies every mapped file and the final spec/roster/asset set, then publishes by directory rename. A publication failure rolls back all earlier renames, including removed Deck files and any previous receipt. If rollback itself fails, the error identifies retained backups for recovery. This is exception rollback across directories, not a single filesystem transaction or a process-crash recovery protocol.
|
||||||
|
|
||||||
|
Successful installation also writes `<project>/template_install.json` in that transaction. Its version-1 object records `project`, `roots` (canonical roots, source labels, kinds, spec paths/identities, and source SHA-256 fingerprints), `installed_specs`, `active_roster` (root, kind, and structural file paths, or `null`), and `files` (installed relative paths → SHA-256). A spec's source and installed fingerprints differ by design because installation inserts one provenance line under its H1. `selection_sha256` binds a UI installation to its frozen Stage-1 snapshot; it is `null` for installations without a UI selection. If `confirm_ui/template_selection.json` exists, the supplied roots and their bytes must match it before installation. Confirm UI checks this machine receipt and installed content before exposing or accepting Stage 2; a textual provenance line is insufficient.
|
||||||
|
|
||||||
## `register_template.py`
|
## `register_template.py`
|
||||||
|
|
||||||
|
|||||||
+129
-8
@@ -191,6 +191,7 @@ class AuthoringDocument:
|
|||||||
source_path: Path
|
source_path: Path
|
||||||
source_sha256: str
|
source_sha256: str
|
||||||
source_refs: dict[str, SourceRefRecord]
|
source_refs: dict[str, SourceRefRecord]
|
||||||
|
initial_authoring_sha256: str = ""
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@@ -263,6 +264,8 @@ def _compact_json_bytes(payload: dict[str, object]) -> bytes:
|
|||||||
def _template_execution_manifest_files(
|
def _template_execution_manifest_files(
|
||||||
materialized_roots: list[tuple[Path, ET.Element]],
|
materialized_roots: list[tuple[Path, ET.Element]],
|
||||||
source_import: dict[str, object] | None,
|
source_import: dict[str, object] | None,
|
||||||
|
source_package_sha256: str | None = None,
|
||||||
|
source_geometry_unchanged: bool = False,
|
||||||
) -> list[MaterializedFile]:
|
) -> list[MaterializedFile]:
|
||||||
"""Serialize one compact roster plus per-prototype text-slot sidecars."""
|
"""Serialize one compact roster plus per-prototype text-slot sidecars."""
|
||||||
templates: list[dict[str, object]] = []
|
templates: list[dict[str, object]] = []
|
||||||
@@ -297,6 +300,7 @@ def _template_execution_manifest_files(
|
|||||||
))
|
))
|
||||||
templates.append({
|
templates.append({
|
||||||
"prototype": prototype,
|
"prototype": prototype,
|
||||||
|
"source_svg_sha256": _sha256_bytes(_serialize_svg(root)),
|
||||||
"page_type": relative_path.stem.split("_", 1)[-1],
|
"page_type": relative_path.stem.split("_", 1)[-1],
|
||||||
"viewBox": root.get("viewBox"),
|
"viewBox": root.get("viewBox"),
|
||||||
"master": root.get("data-pptx-master"),
|
"master": root.get("data-pptx-master"),
|
||||||
@@ -330,6 +334,8 @@ def _template_execution_manifest_files(
|
|||||||
"by_code": {},
|
"by_code": {},
|
||||||
},
|
},
|
||||||
"source_themes": SOURCE_THEMES_FILENAME,
|
"source_themes": SOURCE_THEMES_FILENAME,
|
||||||
|
"source_package_sha256": source_package_sha256,
|
||||||
|
"source_geometry_unchanged": source_geometry_unchanged,
|
||||||
"templates": templates,
|
"templates": templates,
|
||||||
}
|
}
|
||||||
files.append(MaterializedFile(
|
files.append(MaterializedFile(
|
||||||
@@ -619,7 +625,9 @@ def _load_authoring_documents(
|
|||||||
)
|
)
|
||||||
if manifest.get("projection_kind") != "layered":
|
if manifest.get("projection_kind") != "layered":
|
||||||
raise MirrorMaterializationError(
|
raise MirrorMaterializationError(
|
||||||
"Mirror materialization requires projection_kind='layered'"
|
"mirror publishes only pptx_template_import.py workspaces; "
|
||||||
|
"an SVG workspace is consumed as an exact root by apply_template.py "
|
||||||
|
"(projection_kind must be layered)"
|
||||||
)
|
)
|
||||||
if manifest.get("authoring_root") != ".":
|
if manifest.get("authoring_root") != ".":
|
||||||
raise MirrorMaterializationError("authoring_manifest.json authoring_root must be '.'")
|
raise MirrorMaterializationError("authoring_manifest.json authoring_root must be '.'")
|
||||||
@@ -727,6 +735,7 @@ def _load_authoring_documents(
|
|||||||
|
|
||||||
documents[authoring_name] = AuthoringDocument(
|
documents[authoring_name] = AuthoringDocument(
|
||||||
name=authoring_name,
|
name=authoring_name,
|
||||||
|
initial_authoring_sha256=str(raw.get("initial_authoring_sha256") or ""),
|
||||||
authoring_path=authoring_path,
|
authoring_path=authoring_path,
|
||||||
source_path=source_path,
|
source_path=source_path,
|
||||||
source_sha256=expected_source_sha,
|
source_sha256=expected_source_sha,
|
||||||
@@ -913,6 +922,12 @@ def _validate_source_ref_closure(
|
|||||||
|
|
||||||
def _load_native_graph(workspace: Path) -> dict[str, Any]:
|
def _load_native_graph(workspace: Path) -> dict[str, Any]:
|
||||||
native_path = native_structure_path(workspace)
|
native_path = native_structure_path(workspace)
|
||||||
|
if not native_path.is_file() or not (workspace / "sources" / "source.pptx").is_file():
|
||||||
|
raise MirrorMaterializationError(
|
||||||
|
"mirror publishes only pptx_template_import.py workspaces; "
|
||||||
|
"an SVG workspace is consumed as an exact root by apply_template.py "
|
||||||
|
"(requires analysis/native_structure.json and sources/source.pptx)"
|
||||||
|
)
|
||||||
native = _load_json(native_path, context="native structure")
|
native = _load_json(native_path, context="native structure")
|
||||||
if native.get("schema") != NATIVE_STRUCTURE_SCHEMA:
|
if native.get("schema") != NATIVE_STRUCTURE_SCHEMA:
|
||||||
raise MirrorMaterializationError(
|
raise MirrorMaterializationError(
|
||||||
@@ -2417,11 +2432,9 @@ def _compose_template(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
typography_roots = [
|
# Each layer/slide atom receives its own source inheritance below. A Slide
|
||||||
candidate
|
# root's compacted font must not become inheritance for Master atoms.
|
||||||
for candidate in (master_root, layout_root, slide_root)
|
typography_roots = [master_root]
|
||||||
if candidate is not None
|
|
||||||
]
|
|
||||||
for name in _ROOT_TYPOGRAPHY_ATTRIBUTES:
|
for name in _ROOT_TYPOGRAPHY_ATTRIBUTES:
|
||||||
for candidate in typography_roots:
|
for candidate in typography_roots:
|
||||||
value = candidate.get(name)
|
value = candidate.get(name)
|
||||||
@@ -2482,6 +2495,13 @@ def _compose_template(
|
|||||||
slide_backgrounds.append(item)
|
slide_backgrounds.append(item)
|
||||||
else:
|
else:
|
||||||
item.set("id", item.get("id") or f"slide-{slide['index']}-node-{index + 1}")
|
item.set("id", item.get("id") or f"slide-{slide['index']}-node-{index + 1}")
|
||||||
|
# A source line can have a zero-width/height frame. Publish its
|
||||||
|
# single geometry atom; a content group requires positive bounds.
|
||||||
|
if _local_name(item.tag) == "g" and item.get("data-pptx-prst") == "line":
|
||||||
|
atoms = _flatten_fixed_group(item, context=f"Slide {slide['index']} line")
|
||||||
|
if len(atoms) == 1:
|
||||||
|
atoms[0].set("id", item.get("id"))
|
||||||
|
item = atoms[0]
|
||||||
if _local_name(item.tag) == "g" and _visible_leaf(item):
|
if _local_name(item.tag) == "g" and _visible_leaf(item):
|
||||||
bounds = _frame(item)
|
bounds = _frame(item)
|
||||||
if bounds is None:
|
if bounds is None:
|
||||||
@@ -2899,11 +2919,66 @@ def _publish_files(
|
|||||||
raise MirrorMaterializationError(f"Mirror publish failed: {exc}") from exc
|
raise MirrorMaterializationError(f"Mirror publish failed: {exc}") from exc
|
||||||
|
|
||||||
|
|
||||||
|
def _source_geometry_is_unchanged(documents: dict[str, AuthoringDocument]) -> bool:
|
||||||
|
"""Require every reachable compact document to match its importer hash."""
|
||||||
|
return bool(documents) and all(
|
||||||
|
document.initial_authoring_sha256 == _sha256_file(document.authoring_path)
|
||||||
|
for document in documents.values()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _spec_skeleton(native: dict, pages: list[tuple[Path, ET.Element]], kind: str) -> str:
|
||||||
|
"""Describe source facts only; design judgment remains explicitly unfinished."""
|
||||||
|
from project_utils import CANVAS_FORMATS
|
||||||
|
|
||||||
|
width = native["slideSize"]["width_px"]
|
||||||
|
height = native["slideSize"]["height_px"]
|
||||||
|
viewbox = f"0 0 {format_coordinate(width)} {format_coordinate(height)}"
|
||||||
|
canvas = next((key for key, value in CANVAS_FORMATS.items()
|
||||||
|
if value["width"] == width and value["height"] == height), "custom")
|
||||||
|
lines = [
|
||||||
|
"---", f"kind: {kind}", f"{kind}_id: TODO", "replication_mode: mirror",
|
||||||
|
"native_structure_mode: structured", f"canvas_format: {canvas}",
|
||||||
|
f"canvas_width: {format_coordinate(width)}", f"canvas_height: {format_coordinate(height)}",
|
||||||
|
f'canvas_viewbox: "{viewbox}"', f'source_viewbox: "{viewbox}"',
|
||||||
|
f"page_count: {len(pages)}", "---", "", "# Mirror Template Design Spec", "",
|
||||||
|
"## I. Template Overview", "", "<!-- TODO -->", "",
|
||||||
|
"## II. Color Scheme", "", "<!-- TODO -->", "",
|
||||||
|
"## III. Typography", "", "<!-- TODO -->", "",
|
||||||
|
"## IV. Signature Design Elements", "", "<!-- TODO -->", "",
|
||||||
|
"## V. Page Roster", "",
|
||||||
|
"| File | Master | Layout key | PowerPoint picker name | Slots |",
|
||||||
|
"| --- | --- | --- | --- | --- |",
|
||||||
|
]
|
||||||
|
for path, root in pages:
|
||||||
|
slots = ", ".join(
|
||||||
|
f"{e.get('id')}: {e.get('data-pptx-placeholder')}"
|
||||||
|
for e in root.iter() if e.get("data-pptx-placeholder")
|
||||||
|
) or "none"
|
||||||
|
cells = [f"`{path.name}`", root.get("data-pptx-master", ""),
|
||||||
|
root.get("data-pptx-layout", ""), root.get("data-pptx-layout-name", ""), slots]
|
||||||
|
lines.append("| " + " | ".join(c.replace("|", "\\|").replace("\n", " ") for c in cells) + " |")
|
||||||
|
lines += ["", "Slots `none` means the source package declared no PowerPoint placeholder; "
|
||||||
|
"editable text targets are listed in `template_execution/<prototype>.text-slots.json`.",
|
||||||
|
"", "### Source Preservation Map", "",
|
||||||
|
f"Source package SHA-256: `{native['source']['sha256']}`", "",
|
||||||
|
"| Source slide | Template | Master | Layout |", "| --- | --- | --- | --- |"]
|
||||||
|
for slide, (path, root) in zip(sorted(native["slides"], key=lambda item: int(item["index"])), pages):
|
||||||
|
lines.append(f"| {slide['index']} | `{path.name}` | {root.get('data-pptx-master')} | {root.get('data-pptx-layout')} |")
|
||||||
|
lines += ["", "Only source-slide-reachable Masters and Layouts are retained.", "",
|
||||||
|
"## VI. Assets", "", "<!-- TODO -->", ""]
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
def materialize_mirror_template(
|
def materialize_mirror_template(
|
||||||
import_workspace: Path,
|
import_workspace: Path,
|
||||||
template_workspace: Path,
|
template_workspace: Path,
|
||||||
|
*,
|
||||||
|
kind: str = "deck",
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Publish source Slide prototypes with their reachable mirror structure."""
|
"""Publish source Slide prototypes with their reachable mirror structure."""
|
||||||
|
if kind not in {"layout", "deck"}:
|
||||||
|
raise MirrorMaterializationError("Mirror publication kind must be layout or deck")
|
||||||
native = _load_native_graph(import_workspace)
|
native = _load_native_graph(import_workspace)
|
||||||
inheritance = _load_inheritance(import_workspace)
|
inheritance = _load_inheritance(import_workspace)
|
||||||
masters: dict[str, dict[str, Any]] = {}
|
masters: dict[str, dict[str, Any]] = {}
|
||||||
@@ -3081,6 +3156,21 @@ def materialize_mirror_template(
|
|||||||
)
|
)
|
||||||
compact_svg_tree(root, compact_native_frames=False)
|
compact_svg_tree(root, compact_native_frames=False)
|
||||||
compact_svg_style_tree(root)
|
compact_svg_style_tree(root)
|
||||||
|
# Compaction may hoist a Slide-specific font to the shared SVG root.
|
||||||
|
# Localize it again so Master/Layout resources have stable inheritance.
|
||||||
|
parents = {child: parent for parent in root.iter() for child in parent}
|
||||||
|
for text in root.iter(f"{{{SVG_NS}}}text"):
|
||||||
|
for name in _ROOT_TYPOGRAPHY_ATTRIBUTES:
|
||||||
|
ancestor = text
|
||||||
|
while ancestor is not None:
|
||||||
|
value = _paint_value(ancestor, name)
|
||||||
|
if value is not None:
|
||||||
|
if ancestor is root:
|
||||||
|
text.set(name, value)
|
||||||
|
break
|
||||||
|
ancestor = parents.get(ancestor)
|
||||||
|
for name in _ROOT_TYPOGRAPHY_ATTRIBUTES:
|
||||||
|
root.attrib.pop(name, None)
|
||||||
_refresh_preset_preview_hashes(root)
|
_refresh_preset_preview_hashes(root)
|
||||||
_refresh_fresh_native_fallback_hashes(root)
|
_refresh_fresh_native_fallback_hashes(root)
|
||||||
try:
|
try:
|
||||||
@@ -3151,8 +3241,21 @@ def materialize_mirror_template(
|
|||||||
_template_execution_manifest_files(
|
_template_execution_manifest_files(
|
||||||
materialized_roots,
|
materialized_roots,
|
||||||
_source_import_summary(import_workspace),
|
_source_import_summary(import_workspace),
|
||||||
|
native["source"]["sha256"],
|
||||||
|
_source_geometry_is_unchanged(documents),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
library_root = Path(__file__).resolve().parent.parent / "templates"
|
||||||
|
resolved_workspace = template_workspace.resolve()
|
||||||
|
library_scope = any(
|
||||||
|
(library_root / directory).resolve() in resolved_workspace.parents
|
||||||
|
for directory in ("brands", "styles", "layouts", "decks")
|
||||||
|
)
|
||||||
|
spec_name = "design_spec.md" if library_scope else f"design_spec.{kind}.TODO.md"
|
||||||
|
files.append(MaterializedFile(
|
||||||
|
Path("templates") / spec_name,
|
||||||
|
_spec_skeleton(native, materialized_roots, kind).encode("utf-8"),
|
||||||
|
))
|
||||||
source_themes_path = Path("templates") / SOURCE_THEMES_FILENAME
|
source_themes_path = Path("templates") / SOURCE_THEMES_FILENAME
|
||||||
files.append(MaterializedFile(source_themes_path, source_theme_bundle))
|
files.append(MaterializedFile(source_themes_path, source_theme_bundle))
|
||||||
|
|
||||||
@@ -3172,6 +3275,18 @@ def materialize_mirror_template(
|
|||||||
files.append(
|
files.append(
|
||||||
MaterializedFile(PAYLOAD_STORE_RELATIVE_PATH, payload_store)
|
MaterializedFile(PAYLOAD_STORE_RELATIVE_PATH, payload_store)
|
||||||
)
|
)
|
||||||
|
# Geometry provenance covers the SVG's native records and reusable assets
|
||||||
|
# as well. Finishing the Design Spec must not invalidate source geometry.
|
||||||
|
preservation_files = {
|
||||||
|
Path(os.path.relpath(item.relative_path, "templates")).as_posix(): _sha256_bytes(item.payload)
|
||||||
|
for item in files
|
||||||
|
if item.relative_path != execution_manifest_path and item.relative_path.suffix != ".md"
|
||||||
|
}
|
||||||
|
for item in files:
|
||||||
|
if item.relative_path == execution_manifest_path:
|
||||||
|
manifest = json.loads(item.payload)
|
||||||
|
manifest["source_files_sha256"] = preservation_files
|
||||||
|
item.payload = _json_bytes(manifest)
|
||||||
relative_files = [item.relative_path for item in files]
|
relative_files = [item.relative_path for item in files]
|
||||||
if len(relative_files) != len(set(relative_files)):
|
if len(relative_files) != len(set(relative_files)):
|
||||||
raise MirrorMaterializationError("Materializer produced duplicate output paths")
|
raise MirrorMaterializationError("Materializer produced duplicate output paths")
|
||||||
@@ -3245,6 +3360,7 @@ def materialize_mirror_template(
|
|||||||
},
|
},
|
||||||
"template_svg_count": len(materialized_roots),
|
"template_svg_count": len(materialized_roots),
|
||||||
"template_execution_manifest": execution_manifest_path.as_posix(),
|
"template_execution_manifest": execution_manifest_path.as_posix(),
|
||||||
|
"spec_skeleton": f"templates/{spec_name}",
|
||||||
"source_themes": source_themes_path.as_posix(),
|
"source_themes": source_themes_path.as_posix(),
|
||||||
"template_text_slot_manifest_count": len(materialized_roots),
|
"template_text_slot_manifest_count": len(materialized_roots),
|
||||||
"imported_vector_count": len(referenced_icons),
|
"imported_vector_count": len(referenced_icons),
|
||||||
@@ -3273,9 +3389,14 @@ def build_parser() -> argparse.ArgumentParser:
|
|||||||
parser = argparse.ArgumentParser(
|
parser = argparse.ArgumentParser(
|
||||||
description=(
|
description=(
|
||||||
"Materialize a deterministic mirror template from one PPTX import "
|
"Materialize a deterministic mirror template from one PPTX import "
|
||||||
"workspace's layered authoring IR."
|
"workspace's layered authoring IR, including a factual Design Spec "
|
||||||
|
"skeleton. SVG workspaces use apply_template.py with their exact root."
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--kind", choices=("deck", "layout"), default="deck",
|
||||||
|
help="Kind of the factual Design Spec skeleton (default: deck); finish its TODOs before registration",
|
||||||
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"import_workspace",
|
"import_workspace",
|
||||||
type=Path,
|
type=Path,
|
||||||
@@ -3301,7 +3422,7 @@ def main(argv: list[str] | None = None) -> int:
|
|||||||
print(f"Error: import workspace does not exist: {import_workspace}", file=sys.stderr)
|
print(f"Error: import workspace does not exist: {import_workspace}", file=sys.stderr)
|
||||||
return 1
|
return 1
|
||||||
try:
|
try:
|
||||||
report = materialize_mirror_template(import_workspace, template_workspace)
|
report = materialize_mirror_template(import_workspace, template_workspace, kind=args.kind)
|
||||||
except (MirrorMaterializationError, OSError) as exc:
|
except (MirrorMaterializationError, OSError) as exc:
|
||||||
print(f"Error: {exc}", file=sys.stderr)
|
print(f"Error: {exc}", file=sys.stderr)
|
||||||
return 1
|
return 1
|
||||||
|
|||||||
@@ -376,7 +376,7 @@ def main() -> int:
|
|||||||
print(
|
print(
|
||||||
"Source structure assessment: "
|
"Source structure assessment: "
|
||||||
f"{native_structure['strategy']['recommendedMode']}; "
|
f"{native_structure['strategy']['recommendedMode']}; "
|
||||||
"create-template rebuilds explicit SVG structure"
|
"create-template supports authored structure or unchanged mirror publication"
|
||||||
)
|
)
|
||||||
return 0
|
return 0
|
||||||
finally:
|
finally:
|
||||||
|
|||||||
+28
-11
@@ -809,6 +809,7 @@ class ProjectManager:
|
|||||||
source_items: list[str],
|
source_items: list[str],
|
||||||
move: bool = False,
|
move: bool = False,
|
||||||
copy: bool = False,
|
copy: bool = False,
|
||||||
|
propagate_images: bool = True,
|
||||||
) -> dict[str, list[str]]:
|
) -> dict[str, list[str]]:
|
||||||
if move and copy:
|
if move and copy:
|
||||||
raise ValueError("--move and --copy are mutually exclusive")
|
raise ValueError("--move and --copy are mutually exclusive")
|
||||||
@@ -883,7 +884,8 @@ class ProjectManager:
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
summary["markdown"].append(str(markdown_path))
|
summary["markdown"].append(str(markdown_path))
|
||||||
self._propagate_companion_image_assets(markdown_path, project_dir)
|
if propagate_images:
|
||||||
|
self._propagate_companion_image_assets(markdown_path, project_dir)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
source_path = Path(item)
|
source_path = Path(item)
|
||||||
@@ -948,7 +950,8 @@ class ProjectManager:
|
|||||||
duplicate_markdown = self._find_equivalent_markdown(source_path, sources_dir)
|
duplicate_markdown = self._find_equivalent_markdown(source_path, sources_dir)
|
||||||
if duplicate_markdown is not None:
|
if duplicate_markdown is not None:
|
||||||
summary["markdown"].append(str(duplicate_markdown))
|
summary["markdown"].append(str(duplicate_markdown))
|
||||||
self._propagate_companion_image_assets(duplicate_markdown, project_dir)
|
if propagate_images:
|
||||||
|
self._propagate_companion_image_assets(duplicate_markdown, project_dir)
|
||||||
summary["notes"].append(
|
summary["notes"].append(
|
||||||
f"{item}: skipped duplicate markdown import because equivalent content already exists as {duplicate_markdown.name}"
|
f"{item}: skipped duplicate markdown import because equivalent content already exists as {duplicate_markdown.name}"
|
||||||
)
|
)
|
||||||
@@ -982,7 +985,8 @@ class ProjectManager:
|
|||||||
moved_web_sources[web_sources] = target
|
moved_web_sources[web_sources] = target
|
||||||
if asset_dir is not None:
|
if asset_dir is not None:
|
||||||
summary["assets"].append(str(asset_dir))
|
summary["assets"].append(str(asset_dir))
|
||||||
self._propagate_image_assets(asset_dir, project_dir)
|
if propagate_images:
|
||||||
|
self._propagate_image_assets(asset_dir, project_dir)
|
||||||
if note:
|
if note:
|
||||||
summary["notes"].append(note)
|
summary["notes"].append(note)
|
||||||
continue
|
continue
|
||||||
@@ -1014,7 +1018,8 @@ class ProjectManager:
|
|||||||
continue
|
continue
|
||||||
if canonical_markdown_path.exists():
|
if canonical_markdown_path.exists():
|
||||||
summary["markdown"].append(str(canonical_markdown_path))
|
summary["markdown"].append(str(canonical_markdown_path))
|
||||||
self._propagate_companion_image_assets(canonical_markdown_path, project_dir)
|
if propagate_images:
|
||||||
|
self._propagate_companion_image_assets(canonical_markdown_path, project_dir)
|
||||||
summary["notes"].append(
|
summary["notes"].append(
|
||||||
f"{item}: skipped PDF auto-conversion because {canonical_markdown_path.name} already exists"
|
f"{item}: skipped PDF auto-conversion because {canonical_markdown_path.name} already exists"
|
||||||
)
|
)
|
||||||
@@ -1023,7 +1028,8 @@ class ProjectManager:
|
|||||||
try:
|
try:
|
||||||
self._import_pdf(archived_path, markdown_path)
|
self._import_pdf(archived_path, markdown_path)
|
||||||
summary["markdown"].append(str(markdown_path))
|
summary["markdown"].append(str(markdown_path))
|
||||||
self._propagate_companion_image_assets(markdown_path, project_dir)
|
if propagate_images:
|
||||||
|
self._propagate_companion_image_assets(markdown_path, project_dir)
|
||||||
except Exception as exc: # pragma: no cover - summary path
|
except Exception as exc: # pragma: no cover - summary path
|
||||||
summary["skipped"].append(f"{item}: PDF conversion failed ({exc})")
|
summary["skipped"].append(f"{item}: PDF conversion failed ({exc})")
|
||||||
elif suffix in PRESENTATION_SUFFIXES:
|
elif suffix in PRESENTATION_SUFFIXES:
|
||||||
@@ -1042,7 +1048,8 @@ class ProjectManager:
|
|||||||
continue
|
continue
|
||||||
if canonical_markdown_path.exists():
|
if canonical_markdown_path.exists():
|
||||||
summary["markdown"].append(str(canonical_markdown_path))
|
summary["markdown"].append(str(canonical_markdown_path))
|
||||||
self._propagate_companion_image_assets(canonical_markdown_path, project_dir)
|
if propagate_images:
|
||||||
|
self._propagate_companion_image_assets(canonical_markdown_path, project_dir)
|
||||||
summary["notes"].append(
|
summary["notes"].append(
|
||||||
f"{item}: skipped presentation auto-conversion because {canonical_markdown_path.name} already exists"
|
f"{item}: skipped presentation auto-conversion because {canonical_markdown_path.name} already exists"
|
||||||
)
|
)
|
||||||
@@ -1051,7 +1058,8 @@ class ProjectManager:
|
|||||||
try:
|
try:
|
||||||
self._import_presentation(archived_path, markdown_path)
|
self._import_presentation(archived_path, markdown_path)
|
||||||
summary["markdown"].append(str(markdown_path))
|
summary["markdown"].append(str(markdown_path))
|
||||||
self._propagate_companion_image_assets(markdown_path, project_dir)
|
if propagate_images:
|
||||||
|
self._propagate_companion_image_assets(markdown_path, project_dir)
|
||||||
except Exception as exc: # pragma: no cover - summary path
|
except Exception as exc: # pragma: no cover - summary path
|
||||||
summary["skipped"].append(f"{item}: presentation conversion failed ({exc})")
|
summary["skipped"].append(f"{item}: presentation conversion failed ({exc})")
|
||||||
elif suffix in EXCEL_SUFFIXES:
|
elif suffix in EXCEL_SUFFIXES:
|
||||||
@@ -1063,7 +1071,8 @@ class ProjectManager:
|
|||||||
continue
|
continue
|
||||||
if canonical_markdown_path.exists():
|
if canonical_markdown_path.exists():
|
||||||
summary["markdown"].append(str(canonical_markdown_path))
|
summary["markdown"].append(str(canonical_markdown_path))
|
||||||
self._propagate_companion_image_assets(canonical_markdown_path, project_dir)
|
if propagate_images:
|
||||||
|
self._propagate_companion_image_assets(canonical_markdown_path, project_dir)
|
||||||
summary["notes"].append(
|
summary["notes"].append(
|
||||||
f"{item}: skipped Excel auto-conversion because {canonical_markdown_path.name} already exists"
|
f"{item}: skipped Excel auto-conversion because {canonical_markdown_path.name} already exists"
|
||||||
)
|
)
|
||||||
@@ -1072,7 +1081,8 @@ class ProjectManager:
|
|||||||
try:
|
try:
|
||||||
self._import_excel(archived_path, markdown_path)
|
self._import_excel(archived_path, markdown_path)
|
||||||
summary["markdown"].append(str(markdown_path))
|
summary["markdown"].append(str(markdown_path))
|
||||||
self._propagate_companion_image_assets(markdown_path, project_dir)
|
if propagate_images:
|
||||||
|
self._propagate_companion_image_assets(markdown_path, project_dir)
|
||||||
except Exception as exc: # pragma: no cover - summary path
|
except Exception as exc: # pragma: no cover - summary path
|
||||||
summary["skipped"].append(f"{item}: Excel conversion failed ({exc})")
|
summary["skipped"].append(f"{item}: Excel conversion failed ({exc})")
|
||||||
elif suffix in LEGACY_EXCEL_SUFFIXES:
|
elif suffix in LEGACY_EXCEL_SUFFIXES:
|
||||||
@@ -1093,7 +1103,8 @@ class ProjectManager:
|
|||||||
continue
|
continue
|
||||||
if canonical_markdown_path.exists():
|
if canonical_markdown_path.exists():
|
||||||
summary["markdown"].append(str(canonical_markdown_path))
|
summary["markdown"].append(str(canonical_markdown_path))
|
||||||
self._propagate_companion_image_assets(canonical_markdown_path, project_dir)
|
if propagate_images:
|
||||||
|
self._propagate_companion_image_assets(canonical_markdown_path, project_dir)
|
||||||
summary["notes"].append(
|
summary["notes"].append(
|
||||||
f"{item}: skipped document auto-conversion because {canonical_markdown_path.name} already exists"
|
f"{item}: skipped document auto-conversion because {canonical_markdown_path.name} already exists"
|
||||||
)
|
)
|
||||||
@@ -1102,7 +1113,8 @@ class ProjectManager:
|
|||||||
try:
|
try:
|
||||||
self._import_doc(archived_path, markdown_path)
|
self._import_doc(archived_path, markdown_path)
|
||||||
summary["markdown"].append(str(markdown_path))
|
summary["markdown"].append(str(markdown_path))
|
||||||
self._propagate_companion_image_assets(markdown_path, project_dir)
|
if propagate_images:
|
||||||
|
self._propagate_companion_image_assets(markdown_path, project_dir)
|
||||||
except Exception as exc: # pragma: no cover - summary path
|
except Exception as exc: # pragma: no cover - summary path
|
||||||
summary["skipped"].append(f"{item}: document conversion failed ({exc})")
|
summary["skipped"].append(f"{item}: document conversion failed ({exc})")
|
||||||
elif suffix == ".txt":
|
elif suffix == ".txt":
|
||||||
@@ -1222,6 +1234,10 @@ def build_parser() -> argparse.ArgumentParser:
|
|||||||
)
|
)
|
||||||
import_sources.add_argument("project_path", help="Project directory")
|
import_sources.add_argument("project_path", help="Project directory")
|
||||||
import_sources.add_argument("sources", nargs="+", help="Source files, directories, or URLs")
|
import_sources.add_argument("sources", nargs="+", help="Source files, directories, or URLs")
|
||||||
|
import_sources.add_argument(
|
||||||
|
"--no-image-propagation", action="store_true",
|
||||||
|
help="Keep extracted companion images in sources/ without copying them into images/",
|
||||||
|
)
|
||||||
mode = import_sources.add_mutually_exclusive_group()
|
mode = import_sources.add_mutually_exclusive_group()
|
||||||
mode.add_argument(
|
mode.add_argument(
|
||||||
"--move",
|
"--move",
|
||||||
@@ -1328,6 +1344,7 @@ def main(argv: list[str] | None = None) -> int:
|
|||||||
args.sources,
|
args.sources,
|
||||||
move=args.move,
|
move=args.move,
|
||||||
copy=args.copy,
|
copy=args.copy,
|
||||||
|
propagate_images=not args.no_image_propagation,
|
||||||
)
|
)
|
||||||
import_complete = _has_usable_import(summary)
|
import_complete = _has_usable_import(summary)
|
||||||
if import_complete:
|
if import_complete:
|
||||||
|
|||||||
+17
-3
@@ -2133,7 +2133,7 @@
|
|||||||
{
|
{
|
||||||
"path": "skills/ppt-master/references/pptx-structure-interface.md",
|
"path": "skills/ppt-master/references/pptx-structure-interface.md",
|
||||||
"role": "reference",
|
"role": "reference",
|
||||||
"fingerprint": "9cef05268f4f",
|
"fingerprint": "39cbcc1365b7",
|
||||||
"reason": "This reference mirrors the owner field grammar or ownership boundary."
|
"reason": "This reference mirrors the owner field grammar or ownership boundary."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -2217,7 +2217,7 @@
|
|||||||
{
|
{
|
||||||
"path": "skills/ppt-master/references/pptx-structure-interface.md",
|
"path": "skills/ppt-master/references/pptx-structure-interface.md",
|
||||||
"role": "reference",
|
"role": "reference",
|
||||||
"fingerprint": "b10eddc66b34",
|
"fingerprint": "0cfaaa77ab86",
|
||||||
"reason": "This reference mirrors the owner field grammar or ownership boundary."
|
"reason": "This reference mirrors the owner field grammar or ownership boundary."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -2247,7 +2247,7 @@
|
|||||||
{
|
{
|
||||||
"path": "skills/ppt-master/references/pptx-structure-interface.md",
|
"path": "skills/ppt-master/references/pptx-structure-interface.md",
|
||||||
"role": "reference",
|
"role": "reference",
|
||||||
"fingerprint": "51fe75ceb256",
|
"fingerprint": "d43b6b9804a4",
|
||||||
"reason": "This reference mirrors the owner field grammar or ownership boundary."
|
"reason": "This reference mirrors the owner field grammar or ownership boundary."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -2302,6 +2302,20 @@
|
|||||||
"skills/ppt-master/workflows/*.md",
|
"skills/ppt-master/workflows/*.md",
|
||||||
"skills/ppt-master/workflows/**/*.md",
|
"skills/ppt-master/workflows/**/*.md",
|
||||||
"skills/ppt-master/templates/*.md"
|
"skills/ppt-master/templates/*.md"
|
||||||
|
],
|
||||||
|
"accepted": [
|
||||||
|
{
|
||||||
|
"field": "image_ai_path",
|
||||||
|
"owner_fingerprint": "29f97cdb0797",
|
||||||
|
"projections": [
|
||||||
|
{
|
||||||
|
"path": "skills/ppt-master/references/confirm-surface.md",
|
||||||
|
"role": "reference",
|
||||||
|
"fingerprint": "dda9d2e6f1ee",
|
||||||
|
"reason": "result.json field list restates the owner grammar; stage1_sha256/selection_sha256 binding added by the template-flow audit."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -1170,6 +1170,12 @@ def _extract_entry(
|
|||||||
frontmatter, body = _read_spec(spec_path)
|
frontmatter, body = _read_spec(spec_path)
|
||||||
fm = frontmatter or {}
|
fm = frontmatter or {}
|
||||||
|
|
||||||
|
if fm.get(f"{kind}_id") == "TODO" or "<!-- TODO -->" in body:
|
||||||
|
raise SpecParseError(
|
||||||
|
"incomplete Design Spec skeleton: finish the TODO identity and design "
|
||||||
|
"sections before registration, then rerun --dry-run"
|
||||||
|
)
|
||||||
|
|
||||||
declared_kind = fm.get("kind")
|
declared_kind = fm.get("kind")
|
||||||
if declared_kind not in (None, kind):
|
if declared_kind not in (None, kind):
|
||||||
raise SpecParseError(
|
raise SpecParseError(
|
||||||
|
|||||||
@@ -325,6 +325,7 @@ try:
|
|||||||
parse_optional_layout_slides as _parse_optional_layout_slides,
|
parse_optional_layout_slides as _parse_optional_layout_slides,
|
||||||
parse_template_slide as _parse_template_structure_slide,
|
parse_template_slide as _parse_template_structure_slide,
|
||||||
parse_template_slides as _parse_template_structure_slides,
|
parse_template_slides as _parse_template_structure_slides,
|
||||||
|
structured_layout_definition_files as _structured_layout_definition_files,
|
||||||
_structure_subtree_signature as _structure_subtree_signature,
|
_structure_subtree_signature as _structure_subtree_signature,
|
||||||
template_lock_errors as _template_lock_errors,
|
template_lock_errors as _template_lock_errors,
|
||||||
template_prototype_errors as _template_prototype_errors,
|
template_prototype_errors as _template_prototype_errors,
|
||||||
@@ -337,6 +338,7 @@ except ImportError:
|
|||||||
_parse_optional_layout_slides = None
|
_parse_optional_layout_slides = None
|
||||||
_parse_template_structure_slide = None
|
_parse_template_structure_slide = None
|
||||||
_parse_template_structure_slides = None
|
_parse_template_structure_slides = None
|
||||||
|
_structured_layout_definition_files = None
|
||||||
_structure_subtree_signature = None
|
_structure_subtree_signature = None
|
||||||
_template_lock_errors = None
|
_template_lock_errors = None
|
||||||
_template_prototype_errors = None
|
_template_prototype_errors = None
|
||||||
@@ -1458,6 +1460,8 @@ class SVGQualityChecker:
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Determine pass/fail
|
# Determine pass/fail
|
||||||
|
if self.template_mode:
|
||||||
|
self._classify_preserved_mirror_geometry(svg_path, result)
|
||||||
result['passed'] = len(result['errors']) == 0
|
result['passed'] = len(result['errors']) == 0
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -1466,6 +1470,57 @@ class SVGQualityChecker:
|
|||||||
|
|
||||||
return self._record_result(result)
|
return self._record_result(result)
|
||||||
|
|
||||||
|
def _classify_preserved_mirror_geometry(self, svg_path: Path, result: Dict) -> None:
|
||||||
|
"""Report immutable source geometry as inherited, never exempt edits.
|
||||||
|
|
||||||
|
The materializer records the exact published SVG hash. Only its text
|
||||||
|
width estimates and source-object overlap qualify; XML, assets, native
|
||||||
|
payloads and reusable structure remain fully blocking.
|
||||||
|
"""
|
||||||
|
manifest_path = svg_path.parent / 'template_execution_manifest.json'
|
||||||
|
try:
|
||||||
|
manifest = json.loads(manifest_path.read_text(encoding='utf-8'))
|
||||||
|
except (OSError, ValueError):
|
||||||
|
return
|
||||||
|
if (not isinstance(manifest, dict)
|
||||||
|
or manifest.get('schema') != 'ppt-master.template-execution-manifest.v1'
|
||||||
|
or manifest.get('replication_mode') != 'mirror'
|
||||||
|
or manifest.get('source_geometry_unchanged') is not True
|
||||||
|
or not re.fullmatch(r'[0-9a-f]{64}', str(manifest.get('source_package_sha256', '')))):
|
||||||
|
return
|
||||||
|
entries = manifest.get('templates')
|
||||||
|
if not isinstance(entries, list):
|
||||||
|
return
|
||||||
|
if not any(isinstance(entry, dict)
|
||||||
|
and entry.get('prototype') == svg_path.name
|
||||||
|
and entry.get('source_svg_sha256') == result.get('source_sha256')
|
||||||
|
for entry in entries):
|
||||||
|
return
|
||||||
|
dependencies = manifest.get('source_files_sha256')
|
||||||
|
if not isinstance(dependencies, dict) or not dependencies:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
for relative, digest in dependencies.items():
|
||||||
|
path = (svg_path.parent / relative).resolve()
|
||||||
|
path.relative_to(svg_path.parent.parent.resolve())
|
||||||
|
if hashlib.sha256(path.read_bytes()).hexdigest() != digest:
|
||||||
|
return
|
||||||
|
except (OSError, ValueError, TypeError):
|
||||||
|
return
|
||||||
|
result['info']['mirror_source_unchanged'] = True
|
||||||
|
source_import = manifest.get('source_import')
|
||||||
|
if isinstance(source_import, dict):
|
||||||
|
self._source_import_summary = source_import
|
||||||
|
for bucket in ('errors', 'warnings'):
|
||||||
|
for message in list(result[bucket]):
|
||||||
|
if (message.startswith('<text>') and ' exceeds ' in message) or (
|
||||||
|
message.startswith('<g ') and ' data-pptx-bounds overlaps ' in message
|
||||||
|
):
|
||||||
|
result[bucket].remove(message)
|
||||||
|
result['info'].setdefault('inherited', []).append({
|
||||||
|
'kind': 'mirror-source-geometry', 'message': message,
|
||||||
|
})
|
||||||
|
|
||||||
def _record_result(self, result: Dict) -> Dict:
|
def _record_result(self, result: Dict) -> Dict:
|
||||||
"""Append one file result and update aggregate counters."""
|
"""Append one file result and update aggregate counters."""
|
||||||
self.summary['total'] += 1
|
self.summary['total'] += 1
|
||||||
@@ -4466,10 +4521,15 @@ class SVGQualityChecker:
|
|||||||
if self._is_hidden_element(text_element, parent_by_id):
|
if self._is_hidden_element(text_element, parent_by_id):
|
||||||
continue
|
continue
|
||||||
visible_text = ''.join(text_element.itertext())
|
visible_text = ''.join(text_element.itertext())
|
||||||
if (
|
marker_text = '{{' in visible_text and '}}' in visible_text
|
||||||
not visible_text.strip()
|
slot = parent_by_id.get(id(text_element))
|
||||||
or ('{{' in visible_text and '}}' in visible_text)
|
template_carrier = (
|
||||||
):
|
self.template_mode
|
||||||
|
and text_element.get('data-pptx-carrier') == 'true'
|
||||||
|
and slot is not None
|
||||||
|
and slot.get('data-pptx-placeholder') is not None
|
||||||
|
)
|
||||||
|
if not visible_text.strip() or (marker_text and not template_carrier):
|
||||||
continue
|
continue
|
||||||
estimated = self._estimated_text_bounds(
|
estimated = self._estimated_text_bounds(
|
||||||
text_element,
|
text_element,
|
||||||
@@ -4478,11 +4538,19 @@ class SVGQualityChecker:
|
|||||||
letter_spacings,
|
letter_spacings,
|
||||||
include_headroom=True,
|
include_headroom=True,
|
||||||
)
|
)
|
||||||
|
if estimated is not None and marker_text:
|
||||||
|
# A token has no content-width contract, but its carrier still
|
||||||
|
# has the same baseline/line-box contract as a generated page.
|
||||||
|
resolved_slot = self._resolved_root_module_bounds(slot)
|
||||||
|
if resolved_slot is not None:
|
||||||
|
boundary = resolved_slot[1]
|
||||||
|
estimated = (boundary[0], estimated[1], boundary[2], estimated[3])
|
||||||
if estimated is not None:
|
if estimated is not None:
|
||||||
estimated_by_id[id(text_element)] = estimated
|
estimated_by_id[id(text_element)] = estimated
|
||||||
|
|
||||||
if (
|
if (
|
||||||
canvas is None
|
marker_text
|
||||||
|
or canvas is None
|
||||||
or self._has_zero_opacity(text_element, parent_by_id)
|
or self._has_zero_opacity(text_element, parent_by_id)
|
||||||
):
|
):
|
||||||
continue
|
continue
|
||||||
@@ -6236,6 +6304,8 @@ class SVGQualityChecker:
|
|||||||
if local_name in definition_containers:
|
if local_name in definition_containers:
|
||||||
return
|
return
|
||||||
if local_name == 'text':
|
if local_name == 'text':
|
||||||
|
if element.get('data-pptx-layer') in {'master', 'layout'}:
|
||||||
|
return
|
||||||
counts.update(collect_text_object_sizes(element))
|
counts.update(collect_text_object_sizes(element))
|
||||||
return
|
return
|
||||||
for child in element:
|
for child in element:
|
||||||
@@ -7129,6 +7199,10 @@ class SVGQualityChecker:
|
|||||||
return
|
return
|
||||||
if specs is None:
|
if specs is None:
|
||||||
return
|
return
|
||||||
|
self._structured_native_slots = sorted({
|
||||||
|
str(item.placeholder) for spec in specs for item in spec.placeholders
|
||||||
|
if item.placeholder in {'chart', 'table'}
|
||||||
|
})
|
||||||
self._pptx_structure_issues.extend(
|
self._pptx_structure_issues.extend(
|
||||||
('error', message)
|
('error', message)
|
||||||
for message in self._shared_fixed_layer_errors(specs)
|
for message in self._shared_fixed_layer_errors(specs)
|
||||||
@@ -7281,9 +7355,21 @@ class SVGQualityChecker:
|
|||||||
self._pptx_structure_issues.append(('error', str(exc)))
|
self._pptx_structure_issues.append(('error', str(exc)))
|
||||||
return
|
return
|
||||||
|
|
||||||
|
dependency_specs = list(specs)
|
||||||
|
try:
|
||||||
|
# Use the exporter's registered dependencies, including explicitly
|
||||||
|
# retained prototypes which have no public page in this deck.
|
||||||
|
definition_files = _structured_layout_definition_files(specs, structure_lock)
|
||||||
|
dependency_specs.extend(
|
||||||
|
_parse_template_structure_slide(path, len(specs) + index)
|
||||||
|
for index, path in enumerate(definition_files, start=1)
|
||||||
|
)
|
||||||
|
except _TemplateStructureError as exc:
|
||||||
|
if complete_roster:
|
||||||
|
self._pptx_structure_issues.append(('error', str(exc)))
|
||||||
self._structured_native_slots = sorted({
|
self._structured_native_slots = sorted({
|
||||||
str(item.placeholder)
|
str(item.placeholder)
|
||||||
for spec in specs
|
for spec in dependency_specs
|
||||||
for item in spec.placeholders
|
for item in spec.placeholders
|
||||||
if item.placeholder in {'chart', 'table'}
|
if item.placeholder in {'chart', 'table'}
|
||||||
})
|
})
|
||||||
@@ -9375,6 +9461,10 @@ class SVGQualityChecker:
|
|||||||
def _print_provenance_category_summary(self):
|
def _print_provenance_category_summary(self):
|
||||||
"""Print compact JSON-equivalent counts for token-safe gate handling."""
|
"""Print compact JSON-equivalent counts for token-safe gate handling."""
|
||||||
categories = self._provenance_categories()
|
categories = self._provenance_categories()
|
||||||
|
unchanged_mirror = self.template_mode and self.results and all(
|
||||||
|
result.get('info', {}).get('mirror_source_unchanged')
|
||||||
|
for result in self.results
|
||||||
|
)
|
||||||
rows = (
|
rows = (
|
||||||
(
|
(
|
||||||
'blocking',
|
'blocking',
|
||||||
@@ -9384,7 +9474,8 @@ class SVGQualityChecker:
|
|||||||
(
|
(
|
||||||
'introduced',
|
'introduced',
|
||||||
len(categories['introduced']),
|
len(categories['introduced']),
|
||||||
'advisory; new or changed',
|
('advisory; mirror: source-authored spellings, workspace unmodified'
|
||||||
|
if unchanged_mirror else 'advisory; new or changed'),
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
'inherited',
|
'inherited',
|
||||||
|
|||||||
@@ -13,7 +13,9 @@ Dependencies:
|
|||||||
Standard library plus local PPT Master validation modules.
|
Standard library plus local PPT Master validation modules.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
import sys
|
import sys
|
||||||
|
from xml.etree import ElementTree as ET
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from attribution_guard import require_skill_integrity
|
from attribution_guard import require_skill_integrity
|
||||||
@@ -134,6 +136,7 @@ def print_usage() -> None:
|
|||||||
print(" --page <basename|path> Required with --stage page; must resolve under")
|
print(" --page <basename|path> Required with --stage page; must resolve under")
|
||||||
print(" the target project's svg_output/ directory.")
|
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(" --slot-capacity-report With --template-mode: print advisory slot JSON only")
|
||||||
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")
|
||||||
@@ -175,6 +178,24 @@ def main() -> None:
|
|||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
template_mode = "--template-mode" in sys.argv
|
template_mode = "--template-mode" in sys.argv
|
||||||
|
if "--slot-capacity-report" in sys.argv:
|
||||||
|
if not template_mode:
|
||||||
|
print("[ERROR] --slot-capacity-report requires --template-mode", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
from .slot_capacity import slot_capacity_report
|
||||||
|
|
||||||
|
target = Path(sys.argv[1])
|
||||||
|
source = target / "templates" if (target / "templates").is_dir() else target
|
||||||
|
files = [source] if source.is_file() else sorted(source.glob("*.svg"))
|
||||||
|
if not files:
|
||||||
|
print(f"[ERROR] No template SVG files found: {source}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
try:
|
||||||
|
print(json.dumps(slot_capacity_report(files), ensure_ascii=False, indent=2))
|
||||||
|
except (OSError, ValueError, KeyError, TypeError, ET.ParseError) as exc:
|
||||||
|
print(f"[ERROR] Cannot read template slots: {exc}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
sys.exit(0)
|
||||||
quick_generate = "--quick-generate" in sys.argv
|
quick_generate = "--quick-generate" in sys.argv
|
||||||
canonical_authoring = "--canonical-authoring" in sys.argv
|
canonical_authoring = "--canonical-authoring" in sys.argv
|
||||||
roundtrip = "--roundtrip" in sys.argv
|
roundtrip = "--roundtrip" in sys.argv
|
||||||
|
|||||||
+231
@@ -0,0 +1,231 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Derive advisory text-slot capacity from SVG geometry and font metrics.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
Via svg_quality_checker.py --template-mode --slot-capacity-report.
|
||||||
|
|
||||||
|
Dependencies:
|
||||||
|
Standard library and the shared text estimator.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import math
|
||||||
|
from pathlib import Path
|
||||||
|
from xml.etree import ElementTree as ET
|
||||||
|
|
||||||
|
from text_measure import measure_text
|
||||||
|
from template_text_slots import analyze_template_text_slots
|
||||||
|
from svg_to_pptx.drawingml.text_properties import (
|
||||||
|
resolve_project_font_sizes,
|
||||||
|
resolve_project_letter_spacings,
|
||||||
|
)
|
||||||
|
|
||||||
|
from .checker import SVGQualityChecker, _effective_presentation_value, _parse_positive_bounds
|
||||||
|
|
||||||
|
|
||||||
|
def _number(value: str | None, default: float) -> float:
|
||||||
|
try:
|
||||||
|
number = float((value or '').removesuffix('px'))
|
||||||
|
except ValueError:
|
||||||
|
return default
|
||||||
|
return number if math.isfinite(number) and number > 0 else default
|
||||||
|
|
||||||
|
|
||||||
|
def _capacity(width: float, height: float, size: float, family: str,
|
||||||
|
weight: str, spacing: float, line_height: float, sample: str) -> dict:
|
||||||
|
# Binary search measures the whole line, including estimator headroom and
|
||||||
|
# tracking. Latin capacity describes this stated sample, not all strings.
|
||||||
|
def fits(count: int) -> bool:
|
||||||
|
text = (sample * (count // len(sample) + 1))[:count]
|
||||||
|
return measure_text(text, size=size, family=family, weight=weight,
|
||||||
|
letter_spacing=spacing) <= width
|
||||||
|
|
||||||
|
low, high = 0, 1
|
||||||
|
while high < 1_000_000 and fits(high):
|
||||||
|
low, high = high, high * 2
|
||||||
|
while low + 1 < high:
|
||||||
|
middle = (low + high) // 2
|
||||||
|
if fits(middle):
|
||||||
|
low = middle
|
||||||
|
else:
|
||||||
|
high = middle
|
||||||
|
return {
|
||||||
|
'sample': sample, 'characters_per_line': low,
|
||||||
|
'lines': max(0, 1 + math.floor((height - size) / line_height)),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _mirror_text_bounds(carrier, parents, font_sizes, spacings):
|
||||||
|
"""Use the source text frame, falling back to measured text geometry."""
|
||||||
|
owner = carrier
|
||||||
|
while owner is not None:
|
||||||
|
for name in ('data-pptx-bounds', 'data-pptx-frame'):
|
||||||
|
raw = owner.get(name)
|
||||||
|
if raw is None:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
bounds = _parse_positive_bounds(raw)
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
if bounds is not None:
|
||||||
|
return bounds, name
|
||||||
|
owner = parents.get(id(owner))
|
||||||
|
estimated = SVGQualityChecker._estimated_text_bounds(carrier, parents, font_sizes, spacings)
|
||||||
|
if estimated is None:
|
||||||
|
return None, 'unresolved text geometry'
|
||||||
|
left, top, right, bottom = estimated
|
||||||
|
return (left, top, right - left, bottom - top), 'estimated text geometry'
|
||||||
|
|
||||||
|
|
||||||
|
def _text_targets(path, root, parents, font_sizes, spacings):
|
||||||
|
for slot in root.iter():
|
||||||
|
placeholder = slot.get('data-pptx-placeholder')
|
||||||
|
if not placeholder:
|
||||||
|
continue
|
||||||
|
carriers = [child for child in slot
|
||||||
|
if child.tag.rsplit('}', 1)[-1] == 'text'
|
||||||
|
and child.get('data-pptx-carrier') == 'true']
|
||||||
|
if not carriers:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
bounds = _parse_positive_bounds(slot.get('data-pptx-bounds', ''))
|
||||||
|
except ValueError:
|
||||||
|
bounds = None
|
||||||
|
yield {'file': str(path), 'slot_id': slot.get('id'), 'source': 'placeholder',
|
||||||
|
'placeholder': placeholder, 'bounds': bounds}, carriers[0]
|
||||||
|
|
||||||
|
manifest = path.parent / 'template_execution_manifest.json'
|
||||||
|
sidecar = path.parent / 'template_execution' / f'{path.stem}.text-slots.json'
|
||||||
|
if not manifest.is_file() or not sidecar.is_file():
|
||||||
|
return
|
||||||
|
manifest_data = json.loads(manifest.read_text(encoding='utf-8'))
|
||||||
|
if not isinstance(manifest_data, dict):
|
||||||
|
raise ValueError(f'{manifest}: expected a manifest object')
|
||||||
|
if manifest_data.get('replication_mode') != 'mirror':
|
||||||
|
return
|
||||||
|
text_elements = [element for element in root.iter() if element.tag.rsplit('}', 1)[-1] == 'text']
|
||||||
|
by_selector = {
|
||||||
|
selector: element
|
||||||
|
for slot, element in zip(analyze_template_text_slots(root), text_elements)
|
||||||
|
for selector in (slot.selector, slot.legacy_selector)
|
||||||
|
}
|
||||||
|
sidecar_data = json.loads(sidecar.read_text(encoding='utf-8'))
|
||||||
|
if not isinstance(sidecar_data, dict) or not isinstance(sidecar_data.get('text_slots'), list):
|
||||||
|
raise ValueError(f'{sidecar}: expected a text_slots array')
|
||||||
|
for slot in sidecar_data['text_slots']:
|
||||||
|
if not isinstance(slot, dict) or not isinstance(slot.get('selector'), str):
|
||||||
|
raise ValueError(f'{sidecar}: each text slot requires a selector string')
|
||||||
|
selector = slot['selector']
|
||||||
|
carrier = by_selector.get(selector)
|
||||||
|
row = {'file': str(path), 'slot_id': selector, 'selector': selector,
|
||||||
|
'source': 'mirror-text-slot', 'placeholder': None, 'bounds': None}
|
||||||
|
if carrier is None:
|
||||||
|
yield {**row, 'unavailable': 'selector does not resolve to a text element'}, None
|
||||||
|
continue
|
||||||
|
row['bounds'], row['bounds_source'] = _mirror_text_bounds(carrier, parents, font_sizes, spacings)
|
||||||
|
yield row, carrier
|
||||||
|
|
||||||
|
|
||||||
|
def slot_capacity_report(svg_files: list[Path]) -> dict:
|
||||||
|
"""Read placeholder and mirror text targets without changing files or gates."""
|
||||||
|
slots = []
|
||||||
|
for path in svg_files:
|
||||||
|
root = ET.parse(path).getroot()
|
||||||
|
parents = {id(child): parent for parent in root.iter() for child in parent}
|
||||||
|
font_sizes = resolve_project_font_sizes(root)
|
||||||
|
spacings = resolve_project_letter_spacings(root, font_sizes)
|
||||||
|
for row, carrier in _text_targets(path, root, parents, font_sizes, spacings):
|
||||||
|
bounds = row['bounds']
|
||||||
|
if bounds is None:
|
||||||
|
row.setdefault('unavailable', 'missing or invalid slot bounds')
|
||||||
|
slots.append(row)
|
||||||
|
continue
|
||||||
|
# Imported paragraphs can put all visible text and typography on
|
||||||
|
# tspans. Use the first visible run, and expose mixed-run conditions.
|
||||||
|
runs = [node for node in carrier.iter() if (node.text or '').strip()]
|
||||||
|
first_run = runs[0] if runs else carrier
|
||||||
|
def value(name: str) -> str | None:
|
||||||
|
return _effective_presentation_value(first_run, name, parents)
|
||||||
|
|
||||||
|
size = font_sizes[id(first_run)]
|
||||||
|
family = value('font-family') or 'Calibri'
|
||||||
|
weight = value('font-weight') or 'normal'
|
||||||
|
spacing = spacings[id(first_run)]
|
||||||
|
raw_line_height = value('line-height')
|
||||||
|
line_height = size * 1.2
|
||||||
|
line_height_source = 'estimated 1.2em'
|
||||||
|
if raw_line_height and raw_line_height != 'normal':
|
||||||
|
if raw_line_height.endswith('%'):
|
||||||
|
line_height = size * _number(raw_line_height[:-1], 120) / 100
|
||||||
|
elif raw_line_height.endswith('em'):
|
||||||
|
line_height = size * _number(raw_line_height[:-2], 1.2)
|
||||||
|
elif raw_line_height.endswith('px'):
|
||||||
|
line_height = _number(raw_line_height, line_height)
|
||||||
|
else:
|
||||||
|
line_height = size * _number(raw_line_height, 1.2)
|
||||||
|
line_height_source = 'declared'
|
||||||
|
else:
|
||||||
|
# Explicit tspan baselines are the actual authored line pitch.
|
||||||
|
baseline_values = set()
|
||||||
|
for tspan in carrier:
|
||||||
|
try:
|
||||||
|
y = float(tspan.get('y', '').removesuffix('px'))
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
if math.isfinite(y):
|
||||||
|
baseline_values.add(y)
|
||||||
|
ys = sorted(baseline_values)
|
||||||
|
pitches = [b - a for a, b in zip(ys, ys[1:]) if b > a]
|
||||||
|
if pitches:
|
||||||
|
line_height = min(pitches)
|
||||||
|
line_height_source = 'tspan baselines'
|
||||||
|
row.update({
|
||||||
|
'font_family': family, 'font_size': size, 'font_weight': weight,
|
||||||
|
'font_family_source': 'declared' if value('font-family') else 'assumed Calibri',
|
||||||
|
'font_size_source': 'declared' if value('font-size') else 'assumed 16px',
|
||||||
|
'letter_spacing': spacing, 'line_height': line_height,
|
||||||
|
'line_height_source': line_height_source,
|
||||||
|
'latin': _capacity(bounds[2], bounds[3], size, family, weight,
|
||||||
|
spacing, line_height, 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz '),
|
||||||
|
'cjk': _capacity(bounds[2], bounds[3], size, family, weight,
|
||||||
|
spacing, line_height, '汉'),
|
||||||
|
})
|
||||||
|
ascent, descent = SVGQualityChecker._text_line_vertical_extent([], size)
|
||||||
|
row['baseline_allowed'] = [bounds[1] + ascent, bounds[1] + bounds[3] - descent]
|
||||||
|
lines = SVGQualityChecker._resolved_text_lines(carrier, parents, font_sizes, spacings)
|
||||||
|
row['baseline'] = lines[0][2] if lines else None
|
||||||
|
if not lines:
|
||||||
|
# Source paragraph models may have a known first baseline even
|
||||||
|
# when the estimator cannot resolve every subsequent line.
|
||||||
|
try:
|
||||||
|
baseline = float((value('y') or '0').removesuffix('px'))
|
||||||
|
except ValueError:
|
||||||
|
baseline = math.nan
|
||||||
|
if math.isfinite(baseline):
|
||||||
|
row['baseline'] = baseline
|
||||||
|
if lines and len(lines) > 1:
|
||||||
|
row['baselines'] = [line[2] for line in lines]
|
||||||
|
variants = sorted({
|
||||||
|
(_effective_presentation_value(run, 'font-family', parents) or family,
|
||||||
|
font_sizes[id(run)])
|
||||||
|
for run in runs
|
||||||
|
})
|
||||||
|
if len(variants) > 1:
|
||||||
|
row['typography_variants'] = [
|
||||||
|
{'font_family': face, 'font_size': pixels} for face, pixels in variants
|
||||||
|
]
|
||||||
|
row['estimate_scope'] = 'first visible run; measure mixed content separately'
|
||||||
|
slots.append(row)
|
||||||
|
report = {
|
||||||
|
'schema': 'ppt-master.slot-capacity.v1', 'advisory': True,
|
||||||
|
'assumptions': 'Uniform carrier typography; Latin sample distribution and CJK full-width glyphs. '
|
||||||
|
'Line count uses declared/baseline pitch or an explicit 1.2em estimate. '
|
||||||
|
'Measure actual content before locking typography; these are not limits.',
|
||||||
|
'slots': slots,
|
||||||
|
}
|
||||||
|
if not slots:
|
||||||
|
report['note'] = ('No text placeholder carriers or mirror text-slot targets were found. '
|
||||||
|
'Mirror targets are listed in template_execution/*.text-slots.json.')
|
||||||
|
return report
|
||||||
+8
-1
@@ -95,6 +95,7 @@ SYSTEM_FONTS = {'system-ui', '-apple-system', 'BlinkMacSystemFont'}
|
|||||||
|
|
||||||
# macOS/Linux-only fonts -> Windows equivalents
|
# macOS/Linux-only fonts -> Windows equivalents
|
||||||
FONT_FALLBACK_WIN = {
|
FONT_FALLBACK_WIN = {
|
||||||
|
'微软雅黑': 'Microsoft YaHei',
|
||||||
'PingFang SC': 'Microsoft YaHei',
|
'PingFang SC': 'Microsoft YaHei',
|
||||||
'PingFang TC': 'Microsoft JhengHei',
|
'PingFang TC': 'Microsoft JhengHei',
|
||||||
'PingFang HK': 'Microsoft JhengHei',
|
'PingFang HK': 'Microsoft JhengHei',
|
||||||
@@ -154,6 +155,11 @@ GENERIC_FONT_MAP = {
|
|||||||
'serif': 'Times New Roman',
|
'serif': 'Times New Roman',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_FONT_CANONICAL_NAMES = {
|
||||||
|
name.casefold(): name
|
||||||
|
for name in EA_FONTS | SYSTEM_FONTS | FONT_FALLBACK_WIN.keys() | GENERIC_FONT_MAP.keys()
|
||||||
|
}
|
||||||
|
|
||||||
# When the latin font is serif and no EA font is specified,
|
# When the latin font is serif and no EA font is specified,
|
||||||
# prefer SimSun (serif CJK) over Microsoft YaHei (sans-serif CJK).
|
# prefer SimSun (serif CJK) over Microsoft YaHei (sans-serif CJK).
|
||||||
_SERIF_LATIN = {
|
_SERIF_LATIN = {
|
||||||
@@ -3249,6 +3255,7 @@ def parse_font_family(
|
|||||||
ea_font = None
|
ea_font = None
|
||||||
|
|
||||||
for font in fonts:
|
for font in fonts:
|
||||||
|
font = _FONT_CANONICAL_NAMES.get(font.casefold(), font)
|
||||||
if font in SYSTEM_FONTS:
|
if font in SYSTEM_FONTS:
|
||||||
continue
|
continue
|
||||||
if font in GENERIC_FONT_MAP:
|
if font in GENERIC_FONT_MAP:
|
||||||
@@ -3262,7 +3269,7 @@ def parse_font_family(
|
|||||||
win_font = (
|
win_font = (
|
||||||
_JA_FONT_FALLBACK_WIN.get(font) if is_japanese else None
|
_JA_FONT_FALLBACK_WIN.get(font) if is_japanese else None
|
||||||
) or FONT_FALLBACK_WIN.get(font, font)
|
) or FONT_FALLBACK_WIN.get(font, font)
|
||||||
if font in EA_FONTS:
|
if font in EA_FONTS or win_font in EA_FONTS:
|
||||||
ea_font = ea_font or win_font
|
ea_font = ea_font or win_font
|
||||||
else:
|
else:
|
||||||
latin_font = latin_font or win_font
|
latin_font = latin_font or win_font
|
||||||
|
|||||||
+37
-13
@@ -3124,9 +3124,21 @@ def _remove_template_shape(
|
|||||||
sp_tree.remove(shape)
|
sp_tree.remove(shape)
|
||||||
|
|
||||||
|
|
||||||
|
def _template_reference_indices(
|
||||||
|
states: list[_TemplateRuntimeSlide],
|
||||||
|
public_slide_count: int | None,
|
||||||
|
) -> list[int]:
|
||||||
|
"""Use public pages as truth, or prototypes for an otherwise unused Master."""
|
||||||
|
return [
|
||||||
|
index for index, state in enumerate(states)
|
||||||
|
if public_slide_count is None or state.spec.slide_num <= public_slide_count
|
||||||
|
] or list(range(len(states)))
|
||||||
|
|
||||||
|
|
||||||
def _move_template_background(
|
def _move_template_background(
|
||||||
states: list[_TemplateRuntimeSlide],
|
states: list[_TemplateRuntimeSlide],
|
||||||
target_path: Path,
|
target_path: Path,
|
||||||
|
public_slide_count: int | None = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
backgrounds = [
|
backgrounds = [
|
||||||
_extract_slide_background_xml(state.slide_path.read_text(encoding="utf-8"))
|
_extract_slide_background_xml(state.slide_path.read_text(encoding="utf-8"))
|
||||||
@@ -3137,8 +3149,10 @@ def _move_template_background(
|
|||||||
"Template background metadata must resolve to an explicit background "
|
"Template background metadata must resolve to an explicit background "
|
||||||
"on every affected slide"
|
"on every affected slide"
|
||||||
)
|
)
|
||||||
|
reference_indices = _template_reference_indices(states, public_slide_count)
|
||||||
canonical_backgrounds = set()
|
canonical_backgrounds = set()
|
||||||
for background in backgrounds:
|
for index in reference_indices:
|
||||||
|
background = backgrounds[index]
|
||||||
if background is None:
|
if background is None:
|
||||||
continue
|
continue
|
||||||
wrapper = ET.fromstring(
|
wrapper = ET.fromstring(
|
||||||
@@ -3148,11 +3162,11 @@ def _move_template_background(
|
|||||||
ET.tostring(list(wrapper)[0], encoding="utf-8")
|
ET.tostring(list(wrapper)[0], encoding="utf-8")
|
||||||
)
|
)
|
||||||
if len(canonical_backgrounds) != 1:
|
if len(canonical_backgrounds) != 1:
|
||||||
slide_names = ", ".join(state.spec.svg_path.name for state in states)
|
slide_names = ", ".join(states[index].spec.svg_path.name for index in reference_indices)
|
||||||
raise TemplateStructureError(
|
raise TemplateStructureError(
|
||||||
f"Explicit template background differs across slides: {slide_names}"
|
f"Explicit template background differs across slides: {slide_names}"
|
||||||
)
|
)
|
||||||
background_xml = backgrounds[0]
|
background_xml = backgrounds[reference_indices[0]]
|
||||||
if background_xml is None:
|
if background_xml is None:
|
||||||
raise TemplateStructureError("Template background is unexpectedly empty")
|
raise TemplateStructureError("Template background is unexpectedly empty")
|
||||||
target_xml = target_path.read_text(encoding="utf-8")
|
target_xml = target_path.read_text(encoding="utf-8")
|
||||||
@@ -3181,6 +3195,7 @@ def _move_template_solid_background_shapes(
|
|||||||
shapes: list[ET.Element],
|
shapes: list[ET.Element],
|
||||||
target_path: Path,
|
target_path: Path,
|
||||||
slide_size_emu: tuple[int, int],
|
slide_size_emu: tuple[int, int],
|
||||||
|
public_slide_count: int | None = None,
|
||||||
) -> str | None:
|
) -> str | None:
|
||||||
"""Move repeated full-slide solid rects into a master/layout p:bg."""
|
"""Move repeated full-slide solid rects into a master/layout p:bg."""
|
||||||
backgrounds = [
|
backgrounds = [
|
||||||
@@ -3194,13 +3209,14 @@ def _move_template_solid_background_shapes(
|
|||||||
"A template background resolves to a full-slide solid rect on only "
|
"A template background resolves to a full-slide solid rect on only "
|
||||||
"some slides sharing the structure"
|
"some slides sharing the structure"
|
||||||
)
|
)
|
||||||
canonical = {background for background in backgrounds if background is not None}
|
reference_indices = _template_reference_indices(states, public_slide_count)
|
||||||
|
canonical = {backgrounds[index] for index in reference_indices}
|
||||||
if len(canonical) != 1:
|
if len(canonical) != 1:
|
||||||
slide_names = ", ".join(state.spec.svg_path.name for state in states)
|
slide_names = ", ".join(states[index].spec.svg_path.name for index in reference_indices)
|
||||||
raise TemplateStructureError(
|
raise TemplateStructureError(
|
||||||
f"Explicit template solid background differs across slides: {slide_names}"
|
f"Explicit template solid background differs across slides: {slide_names}"
|
||||||
)
|
)
|
||||||
background_xml = backgrounds[0]
|
background_xml = backgrounds[reference_indices[0]]
|
||||||
if background_xml is None:
|
if background_xml is None:
|
||||||
return None
|
return None
|
||||||
target_xml = target_path.read_text(encoding="utf-8")
|
target_xml = target_path.read_text(encoding="utf-8")
|
||||||
@@ -3295,7 +3311,7 @@ def _move_template_static_shape(
|
|||||||
raise TemplateStructureError(
|
raise TemplateStructureError(
|
||||||
f"{item.element_id}: structure item is a background on only some slides"
|
f"{item.element_id}: structure item is a background on only some slides"
|
||||||
)
|
)
|
||||||
return _move_template_background(states, target_path)
|
return _move_template_background(states, target_path, public_slide_count)
|
||||||
|
|
||||||
resolved_shapes = [shape for shape in shapes if shape is not None]
|
resolved_shapes = [shape for shape in shapes if shape is not None]
|
||||||
if item.is_background:
|
if item.is_background:
|
||||||
@@ -3304,6 +3320,7 @@ def _move_template_static_shape(
|
|||||||
resolved_shapes,
|
resolved_shapes,
|
||||||
target_path,
|
target_path,
|
||||||
slide_size_emu,
|
slide_size_emu,
|
||||||
|
public_slide_count,
|
||||||
)
|
)
|
||||||
if background_xml is None:
|
if background_xml is None:
|
||||||
raise TemplateStructureError(
|
raise TemplateStructureError(
|
||||||
@@ -3315,10 +3332,9 @@ def _move_template_static_shape(
|
|||||||
# pages alone decide the atom (a re-skinned rule must not be refused
|
# pages alone decide the atom (a re-skinned rule must not be refused
|
||||||
# because an unused prototype still carries the original paint).
|
# because an unused prototype still carries the original paint).
|
||||||
reference = [
|
reference = [
|
||||||
(state, shape)
|
(states[index], resolved_shapes[index])
|
||||||
for state, shape in zip(states, resolved_shapes)
|
for index in _template_reference_indices(states, public_slide_count)
|
||||||
if public_slide_count is None or state.spec.slide_num <= public_slide_count
|
]
|
||||||
] or list(zip(states, resolved_shapes))
|
|
||||||
canonical = {
|
canonical = {
|
||||||
_canonical_shape_xml(shape, state.rels)
|
_canonical_shape_xml(shape, state.rels)
|
||||||
for state, shape in reference
|
for state, shape in reference
|
||||||
@@ -3713,13 +3729,15 @@ def _set_placeholder_theme_font_role(
|
|||||||
item: TemplateElementSpec,
|
item: TemplateElementSpec,
|
||||||
theme_font_spec: ThemeFontSpec | None,
|
theme_font_spec: ThemeFontSpec | None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Force semantic text placeholders onto the correct theme font role."""
|
"""Use the placeholder's theme role only when it preserves the actual face."""
|
||||||
if theme_font_spec is None:
|
if theme_font_spec is None:
|
||||||
return
|
return
|
||||||
if item.placeholder == "title":
|
if item.placeholder == "title":
|
||||||
prefix = "+mj"
|
prefix = "+mj"
|
||||||
|
target_face = theme_font_spec.major
|
||||||
elif item.placeholder in TEMPLATE_PLACEHOLDER_TYPES:
|
elif item.placeholder in TEMPLATE_PLACEHOLDER_TYPES:
|
||||||
prefix = "+mn"
|
prefix = "+mn"
|
||||||
|
target_face = theme_font_spec.minor
|
||||||
else:
|
else:
|
||||||
return
|
return
|
||||||
for props_tag in ("rPr", "defRPr", "endParaRPr"):
|
for props_tag in ("rPr", "defRPr", "endParaRPr"):
|
||||||
@@ -3727,7 +3745,13 @@ def _set_placeholder_theme_font_role(
|
|||||||
for font_tag, suffix in (("latin", "lt"), ("ea", "ea"), ("cs", "cs")):
|
for font_tag, suffix in (("latin", "lt"), ("ea", "ea"), ("cs", "cs")):
|
||||||
font = props.find(f"{{{DML_NS}}}{font_tag}")
|
font = props.find(f"{{{DML_NS}}}{font_tag}")
|
||||||
if font is not None:
|
if font is not None:
|
||||||
font.set("typeface", f"{prefix}-{suffix}")
|
face = font.get("typeface")
|
||||||
|
if face == f"+mj-{suffix}":
|
||||||
|
face = getattr(theme_font_spec.major, font_tag)
|
||||||
|
elif face == f"+mn-{suffix}":
|
||||||
|
face = getattr(theme_font_spec.minor, font_tag)
|
||||||
|
if face and face == getattr(target_face, font_tag):
|
||||||
|
font.set("typeface", f"{prefix}-{suffix}")
|
||||||
|
|
||||||
|
|
||||||
def _layout_placeholder_shape(
|
def _layout_placeholder_shape(
|
||||||
|
|||||||
+31
-5
@@ -37,6 +37,8 @@ from pptx_to_svg.preset_authoring import (
|
|||||||
|
|
||||||
from ..drawingml.utils import (
|
from ..drawingml.utils import (
|
||||||
is_picture_effect_carrier,
|
is_picture_effect_carrier,
|
||||||
|
parse_inline_style,
|
||||||
|
parse_opacity,
|
||||||
parse_project_geometry_length,
|
parse_project_geometry_length,
|
||||||
project_geometry_length_errors,
|
project_geometry_length_errors,
|
||||||
)
|
)
|
||||||
@@ -454,6 +456,8 @@ def _svg_canvas(root: ET.Element) -> tuple[float, float, float, float]:
|
|||||||
def _is_full_canvas_solid_rect(
|
def _is_full_canvas_solid_rect(
|
||||||
elem: ET.Element,
|
elem: ET.Element,
|
||||||
canvas: tuple[float, float, float, float],
|
canvas: tuple[float, float, float, float],
|
||||||
|
*,
|
||||||
|
root: ET.Element | None = None,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""Return whether a direct rect is eligible for scoped p:bg compilation."""
|
"""Return whether a direct rect is eligible for scoped p:bg compilation."""
|
||||||
if canvas[2] <= 0 or canvas[3] <= 0:
|
if canvas[2] <= 0 or canvas[3] <= 0:
|
||||||
@@ -462,6 +466,24 @@ def _is_full_canvas_solid_rect(
|
|||||||
return False
|
return False
|
||||||
if any(elem.get(attr) for attr in ("transform", "filter", "clip-path")):
|
if any(elem.get(attr) for attr in ("transform", "filter", "clip-path")):
|
||||||
return False
|
return False
|
||||||
|
style = parse_inline_style(elem.get("style"))
|
||||||
|
root_style = parse_inline_style(root.get("style")) if root is not None else {}
|
||||||
|
|
||||||
|
def root_value(name: str) -> str | None:
|
||||||
|
return root_style.get(name, root.get(name)) if root is not None else None
|
||||||
|
|
||||||
|
try:
|
||||||
|
fill_opacity = style.get("fill-opacity", elem.get("fill-opacity"))
|
||||||
|
if fill_opacity in {None, "inherit"}:
|
||||||
|
fill_opacity = root_value("fill-opacity")
|
||||||
|
opacity = style.get("opacity", elem.get("opacity"))
|
||||||
|
if opacity == "inherit":
|
||||||
|
opacity = root_value("opacity")
|
||||||
|
if any(parse_opacity(value, allow_percentage=True) < 1
|
||||||
|
for value in (fill_opacity, opacity, root_value("opacity"))):
|
||||||
|
return False
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
try:
|
try:
|
||||||
geometry = (
|
geometry = (
|
||||||
parse_project_geometry_length(elem.get("x", "0"), "x"),
|
parse_project_geometry_length(elem.get("x", "0"), "x"),
|
||||||
@@ -1631,7 +1653,7 @@ def parse_template_slide(
|
|||||||
binding_raw = elem.get("data-pptx-binding")
|
binding_raw = elem.get("data-pptx-binding")
|
||||||
carrier_raw = elem.get("data-pptx-carrier")
|
carrier_raw = elem.get("data-pptx-carrier")
|
||||||
editable_raw = elem.get("data-pptx-editable")
|
editable_raw = elem.get("data-pptx-editable")
|
||||||
is_background = _is_full_canvas_solid_rect(elem, canvas)
|
is_background = _is_full_canvas_solid_rect(elem, canvas, root=root)
|
||||||
effective_layer = layer or ("slide" if is_background else None)
|
effective_layer = layer or ("slide" if is_background else None)
|
||||||
|
|
||||||
if (
|
if (
|
||||||
@@ -1725,7 +1747,8 @@ def parse_template_slide(
|
|||||||
raise TemplateStructureError(
|
raise TemplateStructureError(
|
||||||
f"{svg_path.name}: {element_id or tag} violates template paint order; "
|
f"{svg_path.name}: {element_id or tag} violates template paint order; "
|
||||||
"use Master background, Layout background, Slide background, "
|
"use Master background, Layout background, Slide background, "
|
||||||
"Master shapes, Layout shapes, then Slide content/placeholders"
|
"Master shapes, Layout shapes, then Slide content/placeholders. "
|
||||||
|
"Full-canvas solid rects are treated as backgrounds; translucent overlays are not backgrounds"
|
||||||
)
|
)
|
||||||
last_order_rank = order_rank
|
last_order_rank = order_rank
|
||||||
|
|
||||||
@@ -2106,12 +2129,14 @@ def structured_layout_definition_files(
|
|||||||
specs: list[TemplateSlideSpec],
|
specs: list[TemplateSlideSpec],
|
||||||
structure_lock: PptxStructureLock,
|
structure_lock: PptxStructureLock,
|
||||||
) -> list[Path]:
|
) -> list[Path]:
|
||||||
"""Validate the unique Layout roster and return unused prototype SVGs.
|
"""Validate the explicit registered Layout set and return unused carriers.
|
||||||
|
|
||||||
A generated page can be the carrier for a used Layout definition. A Layout
|
A generated page can be the carrier for a used Layout definition. A Layout
|
||||||
with no generated page must point at one installed template SVG; the builder
|
with no generated page must point at one installed template SVG; the builder
|
||||||
converts that SVG on an internal trailing slide and removes the carrier slide
|
converts that SVG on an internal trailing slide and removes the carrier slide
|
||||||
after registering the reusable Layout.
|
after registering the reusable Layout.
|
||||||
|
Installed prototypes absent from ``pptx_layouts`` are never compiled.
|
||||||
|
Lockless Quick export compiles only its public pages' Layouts.
|
||||||
"""
|
"""
|
||||||
if structure_lock.mode != "structured":
|
if structure_lock.mode != "structured":
|
||||||
return []
|
return []
|
||||||
@@ -3689,7 +3714,7 @@ def _placement_lint_errors(svg_path: Path) -> list[str]:
|
|||||||
layer = (elem.get("data-pptx-layer") or "").strip().lower() or None
|
layer = (elem.get("data-pptx-layer") or "").strip().lower() or None
|
||||||
if layer not in _LAYERS:
|
if layer not in _LAYERS:
|
||||||
layer = None
|
layer = None
|
||||||
is_background = _is_full_canvas_solid_rect(elem, canvas)
|
is_background = _is_full_canvas_solid_rect(elem, canvas, root=root)
|
||||||
effective_layer = layer or ("slide" if is_background else None)
|
effective_layer = layer or ("slide" if is_background else None)
|
||||||
if is_background and effective_layer is not None:
|
if is_background and effective_layer is not None:
|
||||||
order_rank = {"master": 0, "layout": 1, "slide": 2}[effective_layer]
|
order_rank = {"master": 0, "layout": 1, "slide": 2}[effective_layer]
|
||||||
@@ -3703,7 +3728,8 @@ def _placement_lint_errors(svg_path: Path) -> list[str]:
|
|||||||
errors.append(
|
errors.append(
|
||||||
f"{svg_path.name}: {elem.get('id') or tag} violates template paint "
|
f"{svg_path.name}: {elem.get('id') or tag} violates template paint "
|
||||||
"order; use Master background, Layout background, Slide background, "
|
"order; use Master background, Layout background, Slide background, "
|
||||||
"Master shapes, Layout shapes, then Slide content/placeholders"
|
"Master shapes, Layout shapes, then Slide content/placeholders. "
|
||||||
|
"Full-canvas solid rects are treated as backgrounds; translucent overlays are not backgrounds"
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
last_order_rank = order_rank
|
last_order_rank = order_rank
|
||||||
|
|||||||
+13
-2
@@ -43,6 +43,7 @@ from svg_to_pptx.pptx_package.builder import ( # noqa: E402
|
|||||||
)
|
)
|
||||||
from svg_to_pptx.pptx_package.template_structure import ( # noqa: E402
|
from svg_to_pptx.pptx_package.template_structure import ( # noqa: E402
|
||||||
load_template_source_themes,
|
load_template_source_themes,
|
||||||
|
parse_template_slides,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -322,7 +323,8 @@ def build_parser() -> argparse.ArgumentParser:
|
|||||||
action="store_true",
|
action="store_true",
|
||||||
help=(
|
help=(
|
||||||
"Replace eligible SVG chart/table fallbacks with PowerPoint-native "
|
"Replace eligible SVG chart/table fallbacks with PowerPoint-native "
|
||||||
"objects. Default review export keeps the visible SVG fallbacks."
|
"objects. Enabled automatically for typed chart/table placeholders; "
|
||||||
|
"otherwise the default keeps visible SVG fallbacks."
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
return parser
|
return parser
|
||||||
@@ -380,11 +382,20 @@ def main(argv: list[str] | None = None) -> int:
|
|||||||
text_style, title_px, body_px = infer_master_text_style_spec(
|
text_style, title_px, body_px = infer_master_text_style_spec(
|
||||||
all_svg_files
|
all_svg_files
|
||||||
)
|
)
|
||||||
|
typed_slots = sorted({
|
||||||
|
item.placeholder
|
||||||
|
for spec in parse_template_slides(svg_files)
|
||||||
|
for item in spec.placeholders
|
||||||
|
if item.placeholder in {"chart", "table"}
|
||||||
|
})
|
||||||
|
native_objects = args.native_charts_and_tables or bool(typed_slots)
|
||||||
|
|
||||||
print("PPT Master - Template Preview PPTX Exporter")
|
print("PPT Master - Template Preview PPTX Exporter")
|
||||||
print(f" Workspace: {workspace}")
|
print(f" Workspace: {workspace}")
|
||||||
print(f" Template source: {template_dir}")
|
print(f" Template source: {template_dir}")
|
||||||
print(f" Slide SVG prototypes: {len(svg_files)}")
|
print(f" Slide SVG prototypes: {len(svg_files)}")
|
||||||
|
if typed_slots:
|
||||||
|
print(" Native Chart/Table compilation: required by typed " + ", ".join(typed_slots) + " slots")
|
||||||
if replication_mode == "mirror":
|
if replication_mode == "mirror":
|
||||||
print(" Review placeholder frames: preserved source Slide geometry")
|
print(" Review placeholder frames: preserved source Slide geometry")
|
||||||
else:
|
else:
|
||||||
@@ -408,7 +419,7 @@ def main(argv: list[str] | None = None) -> int:
|
|||||||
enable_notes=False,
|
enable_notes=False,
|
||||||
animation=None,
|
animation=None,
|
||||||
image_optimize=False,
|
image_optimize=False,
|
||||||
native_objects=args.native_charts_and_tables,
|
native_objects=native_objects,
|
||||||
pptx_structure="structured",
|
pptx_structure="structured",
|
||||||
use_layout_placeholder_frames=use_full_placeholder_frames,
|
use_layout_placeholder_frames=use_full_placeholder_frames,
|
||||||
master_text_style_spec=text_style,
|
master_text_style_spec=text_style,
|
||||||
|
|||||||
+1
-1
@@ -53,7 +53,7 @@ class ApplyTemplateTests(unittest.TestCase):
|
|||||||
text[h1:],
|
text[h1:],
|
||||||
)
|
)
|
||||||
self.assertEqual(text.count("**Installed from**"), 1)
|
self.assertEqual(text.count("**Installed from**"), 1)
|
||||||
self.assertEqual(sorted(p.name for p in self.project.iterdir()), ["templates"])
|
self.assertEqual(sorted(p.name for p in self.project.iterdir()), ["template_install.json", "templates"])
|
||||||
receipt = _receipt(plan)
|
receipt = _receipt(plan)
|
||||||
self.assertIn("sources=library", receipt)
|
self.assertIn("sources=library", receipt)
|
||||||
self.assertIn("kinds=style", receipt)
|
self.assertIn("kinds=style", receipt)
|
||||||
|
|||||||
+381
@@ -0,0 +1,381 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Regression coverage for template-flow audit N1/N2/N4/N5/N6/N7/N8."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import contextlib
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch
|
||||||
|
from xml.etree import ElementTree as ET
|
||||||
|
|
||||||
|
SCRIPTS_DIR = Path(__file__).resolve().parents[1]
|
||||||
|
if str(SCRIPTS_DIR) not in sys.path:
|
||||||
|
sys.path.insert(0, str(SCRIPTS_DIR))
|
||||||
|
|
||||||
|
import apply_template # noqa: E402
|
||||||
|
import template_preview_pptx # noqa: E402
|
||||||
|
from confirm_ui import server # noqa: E402
|
||||||
|
from svg_to_pptx.drawingml.theme_fonts import ThemeFontFace, ThemeFontSpec # noqa: E402
|
||||||
|
from svg_to_pptx.pptx_package import builder # noqa: E402
|
||||||
|
from svg_to_pptx.pptx_package.template_structure import ( # noqa: E402
|
||||||
|
TemplateElementSpec,
|
||||||
|
TemplateSlideSpec,
|
||||||
|
TemplateStructureError,
|
||||||
|
)
|
||||||
|
|
||||||
|
PML = builder.PML_NS
|
||||||
|
DML = builder.DML_NS
|
||||||
|
TEMPLATES = SCRIPTS_DIR.parent / 'templates'
|
||||||
|
|
||||||
|
|
||||||
|
def _files(root: Path) -> dict[str, bytes]:
|
||||||
|
return {str(path.relative_to(root)): path.read_bytes()
|
||||||
|
for path in root.rglob('*') if path.is_file()}
|
||||||
|
|
||||||
|
|
||||||
|
def _workspace(root: Path, kind: str, template_id: str) -> Path:
|
||||||
|
templates = root / 'templates'
|
||||||
|
templates.mkdir(parents=True)
|
||||||
|
(templates / f'design_spec.{kind}.{template_id}.md').write_text(
|
||||||
|
f'---\nkind: {kind}\n{kind}_id: {template_id}\n---\n# {template_id}\n',
|
||||||
|
encoding='utf-8',
|
||||||
|
)
|
||||||
|
if kind in {'layout', 'deck'}:
|
||||||
|
(templates / f'{kind}.svg').write_text('<svg/>', encoding='utf-8')
|
||||||
|
return root
|
||||||
|
|
||||||
|
|
||||||
|
def _confirm(project: Path, roots: list[Path], mode: str = 'templates') -> dict:
|
||||||
|
confirm = project / 'confirm_ui'
|
||||||
|
server._write_json_atomic(confirm / server.RECOMMENDATION_STAGE_NAMES[1], {
|
||||||
|
'stage': 'stage1', 'template_options': {
|
||||||
|
'schema_version': 1, 'phase': 'template', 'default_mode': mode,
|
||||||
|
'explicit_workspace_roots': [str(root) for root in roots],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
options, candidates = server._build_template_options(confirm)
|
||||||
|
keys = [key for key, candidate in candidates.items()
|
||||||
|
if candidate['workspace_root'] in {str(root) for root in roots}]
|
||||||
|
selection = server._resolve_template_confirmation(
|
||||||
|
{'mode': mode, 'selection_keys': keys}, candidates, options['options_sha256'],
|
||||||
|
project,
|
||||||
|
)
|
||||||
|
server._write_json_atomic(confirm / server.TEMPLATE_SELECTION_NAME, selection)
|
||||||
|
server._write_json_atomic(confirm / server.RESULT_NAME, {
|
||||||
|
'stage': 'stage1', 'status': 'stage1-confirmed',
|
||||||
|
'stage1_sha256': server._stage1_sha256(confirm),
|
||||||
|
'selection_sha256': selection['selection_sha256'],
|
||||||
|
})
|
||||||
|
return selection
|
||||||
|
|
||||||
|
|
||||||
|
class TemplateInstallTests(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
temp = tempfile.TemporaryDirectory()
|
||||||
|
self.addCleanup(temp.cleanup)
|
||||||
|
self.tmp = Path(temp.name)
|
||||||
|
self.project = _workspace(self.tmp / 'project', 'deck', 'old')
|
||||||
|
self.layout = _workspace(self.tmp / 'layout', 'layout', 'new')
|
||||||
|
for directory in ('images', 'icons'):
|
||||||
|
(self.project / directory).mkdir()
|
||||||
|
(self.project / directory / 'keep.bin').write_bytes(b'original')
|
||||||
|
(self.layout / directory / 'nested').mkdir(parents=True)
|
||||||
|
(self.layout / directory / 'nested' / 'new.bin').write_bytes(b'new')
|
||||||
|
|
||||||
|
def _apply(self):
|
||||||
|
return apply_template.apply_templates(
|
||||||
|
self.project, [str(self.project), str(self.layout)], validate=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_n1_staging_copy_failure_preserves_original_tree(self) -> None:
|
||||||
|
before = _files(self.project)
|
||||||
|
copy2 = shutil.copy2
|
||||||
|
|
||||||
|
def fail_copy(src, dst, *args, **kwargs):
|
||||||
|
if Path(src) == self.layout / 'icons' / 'nested' / 'new.bin':
|
||||||
|
raise OSError('injected staging failure')
|
||||||
|
return copy2(src, dst, *args, **kwargs)
|
||||||
|
|
||||||
|
with patch('apply_template.shutil.copy2', side_effect=fail_copy):
|
||||||
|
with self.assertRaises((OSError, apply_template.ApplyTemplateError)):
|
||||||
|
self._apply()
|
||||||
|
self.assertEqual(_files(self.project), before)
|
||||||
|
self.assertEqual(sorted(p.name for p in self.project.iterdir()), ['icons', 'images', 'templates'])
|
||||||
|
|
||||||
|
def test_n1_each_publication_failure_restores_all_directories(self) -> None:
|
||||||
|
(self.project / 'template_install.json').write_bytes(b'previous receipt')
|
||||||
|
before = _files(self.project)
|
||||||
|
replace = os.replace
|
||||||
|
for name in ('templates', 'images', 'icons', 'template_install.json'):
|
||||||
|
with self.subTest(destination=name):
|
||||||
|
failed = False
|
||||||
|
|
||||||
|
def fail_publish(src, dst):
|
||||||
|
nonlocal failed
|
||||||
|
if Path(dst) == self.project / name and not failed:
|
||||||
|
failed = True
|
||||||
|
raise OSError('injected publication failure')
|
||||||
|
return replace(src, dst)
|
||||||
|
|
||||||
|
with patch('os.replace', side_effect=fail_publish):
|
||||||
|
with self.assertRaises((OSError, apply_template.ApplyTemplateError)):
|
||||||
|
self._apply()
|
||||||
|
self.assertTrue(failed)
|
||||||
|
self.assertEqual(_files(self.project), before)
|
||||||
|
|
||||||
|
def test_n5_disjoint_explicit_roots_can_be_confirmed(self) -> None:
|
||||||
|
brand = _workspace(self.tmp / 'brand', 'brand', 'identity')
|
||||||
|
selection = _confirm(self.project, [brand, self.layout])
|
||||||
|
self.assertEqual({item['kind'] for item in selection['selections']}, {'brand', 'layout'})
|
||||||
|
|
||||||
|
def test_n5_conflicting_roots_are_rejected_in_both_orders(self) -> None:
|
||||||
|
other = _workspace(self.tmp / 'other', 'layout', 'other')
|
||||||
|
for roots in ([other, self.layout], [self.layout, other]):
|
||||||
|
with self.subTest(roots=roots), self.assertRaisesRegex(ValueError, 'one workspace for kind'):
|
||||||
|
_confirm(self.project, roots)
|
||||||
|
|
||||||
|
def test_n5_multikind_root_cannot_be_partially_selected(self) -> None:
|
||||||
|
(self.project / 'templates' / 'design_spec.brand.identity.md').write_text(
|
||||||
|
'---\nkind: brand\nbrand_id: identity\n---\n# Identity\n', encoding='utf-8',
|
||||||
|
)
|
||||||
|
_confirm(self.project, [self.project])
|
||||||
|
options, candidates = server._build_template_options(self.project / 'confirm_ui')
|
||||||
|
keys = [key for key, item in candidates.items()
|
||||||
|
if item['workspace_root'] == str(self.project) and item['kind'] == 'deck']
|
||||||
|
with self.assertRaisesRegex(ValueError, 'every kind'):
|
||||||
|
server._resolve_template_confirmation(
|
||||||
|
{'mode': 'templates', 'selection_keys': keys}, candidates, options['options_sha256'],
|
||||||
|
self.project,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_n6_authorized_in_place_install_keeps_confirmation_valid(self) -> None:
|
||||||
|
_confirm(self.project, [self.project, TEMPLATES / 'layouts' / 'report_core'])
|
||||||
|
apply_template.apply_templates(
|
||||||
|
self.project, [str(self.project), str(TEMPLATES / 'layouts' / 'report_core')],
|
||||||
|
validate=False,
|
||||||
|
)
|
||||||
|
self.assertIsNone(server._stage2_ready_error(self.project, self.project / 'confirm_ui'))
|
||||||
|
handoff = server._read_template_installation(self.project)
|
||||||
|
self.assertEqual(handoff['status'], 'confirmed')
|
||||||
|
|
||||||
|
def test_n6_external_spec_drift_is_rejected_before_install(self) -> None:
|
||||||
|
_confirm(self.project, [self.layout])
|
||||||
|
spec = self.layout / 'templates' / 'design_spec.layout.new.md'
|
||||||
|
spec.write_text(spec.read_text(encoding='utf-8') + '\nChanged\n', encoding='utf-8')
|
||||||
|
with self.assertRaises((ValueError, apply_template.ApplyTemplateError)):
|
||||||
|
apply_template.apply_templates(self.project, [str(self.layout)], validate=False)
|
||||||
|
|
||||||
|
def test_n6_unreceipted_in_place_change_is_rejected(self) -> None:
|
||||||
|
_confirm(self.project, [self.project])
|
||||||
|
(self.project / 'templates' / 'deck.svg').write_text('<svg>changed</svg>', encoding='utf-8')
|
||||||
|
with self.assertRaises((ValueError, apply_template.ApplyTemplateError)):
|
||||||
|
server._read_template_selection(self.project / 'confirm_ui' / server.TEMPLATE_SELECTION_NAME)
|
||||||
|
|
||||||
|
def test_n6_options_drift_is_still_rejected(self) -> None:
|
||||||
|
_confirm(self.project, [self.layout])
|
||||||
|
options_path = self.project / 'confirm_ui' / server.RECOMMENDATION_STAGE_NAMES[1]
|
||||||
|
options = json.loads(options_path.read_text(encoding='utf-8'))
|
||||||
|
options['template_options']['lang'] = 'ja'
|
||||||
|
previous_time = options_path.stat().st_mtime_ns
|
||||||
|
server._write_json_atomic(options_path, options)
|
||||||
|
os.utime(options_path, ns=(previous_time, previous_time))
|
||||||
|
with self.assertRaisesRegex(ValueError, 'options_sha256'):
|
||||||
|
server._read_template_selection(self.project / 'confirm_ui' / server.TEMPLATE_SELECTION_NAME)
|
||||||
|
|
||||||
|
def test_n6_external_asset_drift_is_rejected_after_install(self) -> None:
|
||||||
|
brand = _workspace(self.tmp / 'brand', 'brand', 'identity')
|
||||||
|
(brand / 'images').mkdir()
|
||||||
|
(brand / 'images' / 'logo.bin').write_bytes(b'logo')
|
||||||
|
_confirm(self.project, [self.project, brand])
|
||||||
|
apply_template.apply_templates(self.project, [str(self.project), str(brand)], validate=False)
|
||||||
|
(brand / 'images' / 'logo.bin').write_bytes(b'drift')
|
||||||
|
self.assertIsNotNone(server._stage2_ready_error(self.project, self.project / 'confirm_ui'))
|
||||||
|
|
||||||
|
def test_n8_unrelated_installed_spec_cannot_complete_handoff(self) -> None:
|
||||||
|
_confirm(self.project, [TEMPLATES / 'brands' / 'zcare-rescue', TEMPLATES / 'layouts' / 'report_core'])
|
||||||
|
self.assertIsNotNone(server._stage2_ready_error(self.project, self.project / 'confirm_ui'))
|
||||||
|
|
||||||
|
def test_n8_correct_filenames_without_installer_receipt_are_rejected(self) -> None:
|
||||||
|
_confirm(self.project, [self.layout])
|
||||||
|
shutil.copy2(self.layout / 'templates' / 'design_spec.layout.new.md', self.project / 'templates')
|
||||||
|
self.assertIsNotNone(server._stage2_ready_error(self.project, self.project / 'confirm_ui'))
|
||||||
|
|
||||||
|
def test_n8_installed_asset_drift_invalidates_ready_handoff(self) -> None:
|
||||||
|
_confirm(self.project, [self.project, TEMPLATES / 'brands' / 'zcare-rescue'])
|
||||||
|
apply_template.apply_templates(
|
||||||
|
self.project, [str(self.project), str(TEMPLATES / 'brands' / 'zcare-rescue')], validate=False,
|
||||||
|
)
|
||||||
|
self.assertIsNone(server._stage2_ready_error(self.project, self.project / 'confirm_ui'))
|
||||||
|
(self.project / 'icons' / 'keep.bin').write_bytes(b'changed')
|
||||||
|
with self.assertRaises((ValueError, apply_template.ApplyTemplateError)):
|
||||||
|
server._read_template_installation(self.project)
|
||||||
|
|
||||||
|
def test_n8_receipt_for_another_selection_cannot_authorize_handoff(self) -> None:
|
||||||
|
apply_template.apply_templates(self.project, [str(self.project)], validate=False)
|
||||||
|
_confirm(self.project, [self.layout])
|
||||||
|
self.assertIsNotNone(server._stage2_ready_error(self.project, self.project / 'confirm_ui'))
|
||||||
|
|
||||||
|
def test_n8_installed_spec_and_roster_changes_are_rejected(self) -> None:
|
||||||
|
_confirm(self.project, [self.project])
|
||||||
|
apply_template.apply_templates(self.project, [str(self.project)], validate=False)
|
||||||
|
for relative in ('templates/design_spec.deck.old.md', 'templates/deck.svg'):
|
||||||
|
with self.subTest(relative=relative):
|
||||||
|
path = self.project / relative
|
||||||
|
before = path.read_bytes()
|
||||||
|
path.write_bytes(before + b'changed')
|
||||||
|
self.assertIsNotNone(server._stage2_ready_error(self.project, self.project / 'confirm_ui'))
|
||||||
|
path.write_bytes(before)
|
||||||
|
(self.project / 'images' / 'later-project-asset.bin').write_bytes(b'new project material')
|
||||||
|
self.assertIsNone(server._stage2_ready_error(self.project, self.project / 'confirm_ui'))
|
||||||
|
|
||||||
|
def test_free_design_still_completes_without_installation(self) -> None:
|
||||||
|
_confirm(self.project, [], mode='free_design')
|
||||||
|
self.assertIsNone(server._stage2_ready_error(self.project, self.project / 'confirm_ui'))
|
||||||
|
|
||||||
|
|
||||||
|
class TemplateBuilderTests(unittest.TestCase):
|
||||||
|
def _backgrounds(self, colors: list[str], *, baked: bool = False, public_count: int = 1):
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
root_dir = Path(tmp)
|
||||||
|
item = TemplateElementSpec('background', 0, 'rect', layer='master', is_background=True)
|
||||||
|
states = []
|
||||||
|
for num, color in enumerate(colors, 1):
|
||||||
|
root = ET.fromstring(f'<p:sld xmlns:p="{PML}" xmlns:a="{DML}"><p:cSld><p:spTree/></p:cSld></p:sld>')
|
||||||
|
shape = ET.fromstring(
|
||||||
|
f'<p:sp xmlns:p="{PML}" xmlns:a="{DML}"><p:spPr>'
|
||||||
|
'<a:xfrm><a:off x="0" y="0"/><a:ext cx="1280" cy="720"/></a:xfrm>'
|
||||||
|
f'<a:prstGeom prst="rect"/><a:solidFill><a:srgbClr val="{color}"/></a:solidFill>'
|
||||||
|
'</p:spPr></p:sp>'
|
||||||
|
)
|
||||||
|
if baked:
|
||||||
|
root.find(f'{{{PML}}}cSld').insert(0, ET.fromstring(
|
||||||
|
builder._solid_background_xml_from_shape(shape, (1280, 720)),
|
||||||
|
))
|
||||||
|
else:
|
||||||
|
root.find(f'.//{{{PML}}}spTree').append(shape)
|
||||||
|
path = root_dir / f'slide{num}.xml'
|
||||||
|
ET.ElementTree(root).write(path, encoding='utf-8')
|
||||||
|
spec = TemplateSlideSpec(
|
||||||
|
num, Path(f'page{num}.svg'), 'm', 'M', str(num), str(num), True, True, (item,),
|
||||||
|
)
|
||||||
|
states.append(builder._TemplateRuntimeSlide(
|
||||||
|
spec, path, path.with_suffix('.rels'), ET.ElementTree(root), root, {},
|
||||||
|
{} if baked else {'2': shape}, {} if baked else {'background': ['2']},
|
||||||
|
))
|
||||||
|
target = root_dir / 'master.xml'
|
||||||
|
target.write_text(
|
||||||
|
f'<p:sldMaster xmlns:p="{PML}"><p:cSld><p:spTree/></p:cSld></p:sldMaster>',
|
||||||
|
encoding='utf-8',
|
||||||
|
)
|
||||||
|
result = builder._move_template_static_shape(
|
||||||
|
states, item, target, root_dir / 'master.rels', (1280, 720), public_slide_count=public_count,
|
||||||
|
)
|
||||||
|
self.assertTrue(all(not list(state.root.iter(f'{{{PML}}}sp')) for state in states))
|
||||||
|
return result
|
||||||
|
|
||||||
|
def test_n2_public_background_wins_over_unused_prototype(self) -> None:
|
||||||
|
for baked in (False, True):
|
||||||
|
with self.subTest(baked=baked):
|
||||||
|
self.assertIn('FAFAFA', self._backgrounds(['FAFAFA', 'FFFFFF'], baked=baked))
|
||||||
|
|
||||||
|
def test_n2_inconsistent_public_backgrounds_still_fail(self) -> None:
|
||||||
|
for baked in (False, True):
|
||||||
|
with self.subTest(baked=baked), self.assertRaises(TemplateStructureError):
|
||||||
|
self._backgrounds(['FAFAFA', 'FFFFFF'], baked=baked, public_count=2)
|
||||||
|
|
||||||
|
def test_n2_master_without_public_pages_uses_its_prototype(self) -> None:
|
||||||
|
self.assertIn('FFFFFF', self._backgrounds(['FFFFFF'], public_count=0))
|
||||||
|
|
||||||
|
def test_n7_custom_faces_survive_slide_and_layout_placeholder_rebuild(self) -> None:
|
||||||
|
fonts = ThemeFontSpec(ThemeFontFace('Arial', 'SimSun', 'Arial'),
|
||||||
|
ThemeFontFace('Arial', 'SimSun', 'Arial'), 'Arial', 'Arial')
|
||||||
|
for kind in ('title', 'body'):
|
||||||
|
for face, expected in (('Arial Black', 'Arial Black'), ('Arial', '+mj-lt' if kind == 'title' else '+mn-lt'),
|
||||||
|
('+mn-lt', '+mj-lt' if kind == 'title' else '+mn-lt')):
|
||||||
|
with self.subTest(kind=kind, face=face):
|
||||||
|
shape = ET.fromstring(
|
||||||
|
f'<p:sp xmlns:p="{PML}" xmlns:a="{DML}"><p:nvSpPr><p:nvPr/></p:nvSpPr>'
|
||||||
|
'<p:spPr/><p:txBody><a:bodyPr/><a:lstStyle/><a:p><a:r>'
|
||||||
|
f'<a:rPr><a:latin typeface="{face}"/></a:rPr><a:t>01</a:t>'
|
||||||
|
'</a:r></a:p></p:txBody></p:sp>'
|
||||||
|
)
|
||||||
|
item = TemplateElementSpec('number', 0, 'g', placeholder=kind,
|
||||||
|
placeholder_bounds=(0, 0, 100, 100))
|
||||||
|
layout = builder._layout_placeholder_shape(shape, item, 1, fonts)
|
||||||
|
builder._patch_slide_placeholder(shape, item, 1, theme_font_spec=fonts)
|
||||||
|
for output in (shape, layout):
|
||||||
|
self.assertEqual(output.find(f'.//{{{DML}}}latin').get('typeface'), expected)
|
||||||
|
|
||||||
|
|
||||||
|
class TemplatePreviewTests(unittest.TestCase):
|
||||||
|
def test_n4_default_report_core_preview_compiles_typed_slots(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmp, contextlib.redirect_stdout(io.StringIO()) as output:
|
||||||
|
code = template_preview_pptx.main([
|
||||||
|
str(TEMPLATES / 'layouts' / 'report_core'), '-o', str(Path(tmp) / 'review.pptx'),
|
||||||
|
])
|
||||||
|
self.assertEqual(code, 0, output.getvalue())
|
||||||
|
self.assertIn('2 master(s)', output.getvalue())
|
||||||
|
self.assertIn('typed', output.getvalue())
|
||||||
|
|
||||||
|
|
||||||
|
class TemplateBrowserTests(unittest.TestCase):
|
||||||
|
@unittest.skipUnless(shutil.which('node'), 'Node.js is needed for the frontend behavior check')
|
||||||
|
def test_n5_explicit_checkboxes_expand_roots_and_reject_kind_conflicts(self) -> None:
|
||||||
|
source = (SCRIPTS_DIR / 'confirm_ui' / 'static' / 'app.js').read_text(encoding='utf-8')
|
||||||
|
selection_code = source[source.index(' function normalizeTemplateCandidates('):
|
||||||
|
source.index(' function normalizeRecId(')]
|
||||||
|
validation_code = source[source.index(' function templateSelectionValid('):
|
||||||
|
source.index(' function submitStage1(')]
|
||||||
|
script = '''
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
var TEMPLATE_KINDS = ['brand', 'style', 'layout', 'deck'];
|
||||||
|
var TEMPLATE_SELECTIONS, TEMPLATE_OPTIONS, TEMPLATE_CANDIDATES, TEMPLATE_SELECTED_KEYS, TEMPLATE_MODE;
|
||||||
|
var status = {}, inputs = [];
|
||||||
|
var document = {
|
||||||
|
getElementById: id => id === 'confirm-status' ? status : null,
|
||||||
|
querySelectorAll: () => inputs
|
||||||
|
};
|
||||||
|
function t(key) { return key; }
|
||||||
|
function localized(item, field) { return item[field]; }
|
||||||
|
function el(tag, cls, text) {
|
||||||
|
var element = {children: [], appendChild(child) { this.children.push(child); },
|
||||||
|
addEventListener(event, handler) { this[event] = handler; }};
|
||||||
|
if (tag === 'input') inputs.push(element);
|
||||||
|
return element;
|
||||||
|
}
|
||||||
|
''' + selection_code + validation_code + '''
|
||||||
|
const explicit = [
|
||||||
|
{key: 'b', kind: 'brand', workspace_root: '/one'},
|
||||||
|
{key: 's', kind: 'style', workspace_root: '/one'},
|
||||||
|
{key: 'l', kind: 'layout', workspace_root: '/two'},
|
||||||
|
{key: 'x', kind: 'layout', workspace_root: '/conflict'}
|
||||||
|
];
|
||||||
|
initTemplateOptions({default_mode: 'templates', explicit, preselected_keys: ['b', 's']});
|
||||||
|
renderExplicitTemplateChoices();
|
||||||
|
assert.equal(inputs.length, 3);
|
||||||
|
assert.deepEqual(TEMPLATE_SELECTED_KEYS, ['b', 's']);
|
||||||
|
inputs[1].checked = true;
|
||||||
|
inputs[1].change();
|
||||||
|
assert.deepEqual(TEMPLATE_SELECTED_KEYS, ['b', 's', 'l']);
|
||||||
|
assert.equal(templateSelectionValid(), true);
|
||||||
|
inputs[2].checked = true;
|
||||||
|
inputs[2].change();
|
||||||
|
assert.equal(templateSelectionValid(), false);
|
||||||
|
assert.deepEqual(TEMPLATE_SELECTED_KEYS, ['b', 's', 'l', 'x']);
|
||||||
|
inputs[2].checked = false;
|
||||||
|
inputs[2].change();
|
||||||
|
assert.equal(templateSelectionValid(), true);
|
||||||
|
chooseFreeDesign();
|
||||||
|
assert.deepEqual(TEMPLATE_SELECTED_KEYS, []);
|
||||||
|
assert.equal(inputs.some(input => input.checked), false);
|
||||||
|
'''
|
||||||
|
result = subprocess.run(['node', '-e', script], capture_output=True, text=True, check=False)
|
||||||
|
self.assertEqual(result.returncode, 0, result.stderr)
|
||||||
+420
@@ -0,0 +1,420 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Regressions for deterministic template publication and confirmation.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python3 -m unittest tests.test_template_flow_batch3
|
||||||
|
|
||||||
|
Dependencies:
|
||||||
|
Standard library and the script runtime dependencies.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import contextlib
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch
|
||||||
|
from xml.etree import ElementTree as ET
|
||||||
|
|
||||||
|
SCRIPTS = Path(__file__).resolve().parents[1]
|
||||||
|
if str(SCRIPTS) not in sys.path:
|
||||||
|
sys.path.insert(0, str(SCRIPTS))
|
||||||
|
|
||||||
|
import mirror_template_materialize as mirror # noqa: E402
|
||||||
|
import apply_template # noqa: E402
|
||||||
|
from confirm_ui import server # noqa: E402
|
||||||
|
from project_management.cli import ProjectManager # noqa: E402
|
||||||
|
from svg_quality.checker import SVGQualityChecker # noqa: E402
|
||||||
|
from svg_to_pptx.pptx_package.template_structure import ( # noqa: E402
|
||||||
|
load_pptx_structure_lock,
|
||||||
|
parse_template_slides,
|
||||||
|
structured_layout_definition_files,
|
||||||
|
)
|
||||||
|
|
||||||
|
SVG_NS = 'http://www.w3.org/2000/svg'
|
||||||
|
|
||||||
|
|
||||||
|
def _write_json(path: Path, data: object) -> None:
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
path.write_text(json.dumps(data), encoding='utf-8')
|
||||||
|
|
||||||
|
|
||||||
|
def _stage1(mode: str = 'free_design') -> dict:
|
||||||
|
return {
|
||||||
|
'stage': 'stage1', 'primary_language': 'en',
|
||||||
|
'template_options': {
|
||||||
|
'schema_version': 1, 'phase': 'template', 'default_mode': mode,
|
||||||
|
'explicit_workspace_roots': [],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _structured_page(key: str, content: str = '') -> str:
|
||||||
|
return (
|
||||||
|
f'<svg xmlns="{SVG_NS}" width="1280" height="720" viewBox="0 0 1280 720" '
|
||||||
|
f'data-pptx-master="m" data-pptx-master-name="Master" '
|
||||||
|
f'data-pptx-layout="{key}" data-pptx-layout-name="{key}">{content}</svg>'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _stage2(selection_sha256: str, template: bool) -> tuple[dict, dict]:
|
||||||
|
palette = {
|
||||||
|
'background': '#FFFFFF', 'secondary_bg': '#EEEEEE', 'primary': '#111111',
|
||||||
|
'accent': '#0000FF', 'secondary_accent': '#FF0000', 'body_text': '#222222',
|
||||||
|
}
|
||||||
|
fonts = [{
|
||||||
|
'heading': {'primary': face, 'css': face},
|
||||||
|
'body': {'primary': face, 'css': face}, 'body_size': 24,
|
||||||
|
'sizes': {'title': 40, 'subtitle': 30, 'annotation': 18},
|
||||||
|
} for face in ('Arial', 'Georgia', 'Consolas')]
|
||||||
|
recommendation = {
|
||||||
|
'stage': 'stage2', 'primary_language': 'en',
|
||||||
|
'selection_sha256': selection_sha256,
|
||||||
|
'recommend': {'generation_mode': 'continuous', 'image_usage': ['none']},
|
||||||
|
'refine_spec': {'value': False}, 'design_spec_depth': {'value': 'brief'},
|
||||||
|
'color': {'candidates': [palette] * 3}, 'typography': {'candidates': fonts},
|
||||||
|
}
|
||||||
|
result = {
|
||||||
|
'stage': 'final', 'generation_mode': 'continuous', 'image_usage': ['none'],
|
||||||
|
'refine_spec': False, 'design_spec_depth': 'brief',
|
||||||
|
'color': palette, 'typography': fonts[0],
|
||||||
|
}
|
||||||
|
if template:
|
||||||
|
recommendation['template_application'] = {'value': 'Use the selected brand and layout.'}
|
||||||
|
result['template_application'] = 'Use the selected brand and layout.'
|
||||||
|
return recommendation, result
|
||||||
|
|
||||||
|
|
||||||
|
class TemplateFlowBatch3Tests(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
temp = tempfile.TemporaryDirectory()
|
||||||
|
self.addCleanup(temp.cleanup)
|
||||||
|
self.root = Path(temp.name)
|
||||||
|
|
||||||
|
def test_a_mirror_keeps_zero_height_source_line(self) -> None:
|
||||||
|
empty = ET.fromstring(f'<svg xmlns="{SVG_NS}"/>')
|
||||||
|
slide = ET.fromstring(
|
||||||
|
f'<svg xmlns="{SVG_NS}"><g id="line" data-pptx-prst="line" '
|
||||||
|
'data-pptx-frame="0 80 1280 0"><path d="M 0 80 L 1280 80" '
|
||||||
|
'stroke="#000000" fill="none"/></g></svg>'
|
||||||
|
)
|
||||||
|
result = mirror._compose_template(
|
||||||
|
native={'slideSize': {'width_px': 1280, 'height_px': 720}},
|
||||||
|
master={'key': 'm', 'name': 'Master'},
|
||||||
|
layout={'key': 'l', 'name': 'Layout', 'showMasterShapes': True},
|
||||||
|
master_root=empty, layout_root=empty,
|
||||||
|
slide={'index': 1, 'showInheritedShapes': True}, slide_root=slide,
|
||||||
|
slot_plans=[],
|
||||||
|
)
|
||||||
|
line = next(e for e in result.iter() if e.get('id') == 'line')
|
||||||
|
self.assertEqual(line.tag, f'{{{SVG_NS}}}path')
|
||||||
|
self.assertEqual(line.get('d'), 'M 0 80 L 1280 80')
|
||||||
|
|
||||||
|
def test_a_type_b_has_actionable_rejection(self) -> None:
|
||||||
|
stderr = io.StringIO()
|
||||||
|
with contextlib.redirect_stderr(stderr):
|
||||||
|
code = mirror.main([str(self.root), str(self.root / 'destination')])
|
||||||
|
self.assertEqual(code, 1)
|
||||||
|
self.assertIn('mirror publishes only pptx_template_import.py workspaces', stderr.getvalue())
|
||||||
|
self.assertIn('apply_template.py', stderr.getvalue())
|
||||||
|
self.assertNotIn('Traceback', stderr.getvalue())
|
||||||
|
_write_json(self.root / 'authoring-svg' / mirror.AUTHORING_MANIFEST_NAME, {
|
||||||
|
'schema': mirror.AUTHORING_SCHEMA, 'projection_kind': 'generic',
|
||||||
|
})
|
||||||
|
with self.assertRaisesRegex(mirror.MirrorMaterializationError, 'apply_template.py'):
|
||||||
|
mirror._load_authoring_documents(self.root, set())
|
||||||
|
|
||||||
|
def test_b_language_normalization_keeps_the_confirmed_round(self) -> None:
|
||||||
|
confirm = self.root / 'confirm_ui'
|
||||||
|
_write_json(confirm / 'recommendations.stage1.json', {
|
||||||
|
**_stage1(), 'primary_language': 'AR_sa',
|
||||||
|
})
|
||||||
|
client = server.create_app(self.root).test_client()
|
||||||
|
data = client.get('/api/recommendations').get_json()
|
||||||
|
self.assertEqual(data['primary_language'], 'ar-SA')
|
||||||
|
response = client.post('/api/confirm', json={
|
||||||
|
'stage': 'stage1', 'options_sha256': data['template_options']['options_sha256'],
|
||||||
|
'template_selection': {'mode': 'free_design', 'selection_keys': []},
|
||||||
|
})
|
||||||
|
self.assertEqual(response.status_code, 200, response.get_json())
|
||||||
|
self.assertFalse(server._fresh_template_restart(confirm))
|
||||||
|
self.assertIsNone(server._stage2_ready_error(self.root, confirm))
|
||||||
|
|
||||||
|
def test_b_embedded_options_and_equal_mtimes_allow_free_design(self) -> None:
|
||||||
|
confirm = self.root / 'confirm_ui'
|
||||||
|
rec = confirm / 'recommendations.stage1.json'
|
||||||
|
_write_json(rec, _stage1())
|
||||||
|
self.assertIsNone(server._confirmation_launch_error(confirm))
|
||||||
|
client = server.create_app(self.root).test_client()
|
||||||
|
response = client.post('/api/confirm', json={
|
||||||
|
'stage': 'stage1',
|
||||||
|
'options_sha256': server._build_template_options(confirm)[0]['options_sha256'],
|
||||||
|
'template_selection': {'mode': 'free_design', 'selection_keys': []},
|
||||||
|
})
|
||||||
|
self.assertEqual(response.status_code, 200, response.get_json())
|
||||||
|
for path in confirm.iterdir():
|
||||||
|
os.utime(path, ns=(1_000_000_000, 1_000_000_000))
|
||||||
|
self.assertIsNone(server._stage2_ready_error(self.root, confirm))
|
||||||
|
previous = json.loads((confirm / 'template_selection.json').read_text())
|
||||||
|
data = _stage1()
|
||||||
|
data['revision'] = 2
|
||||||
|
_write_json(rec, data)
|
||||||
|
os.utime(rec, ns=(1_000_000_000, 1_000_000_000))
|
||||||
|
self.assertTrue(server._fresh_template_restart(confirm))
|
||||||
|
self.assertIsNone(server._confirmation_launch_error(confirm))
|
||||||
|
self.assertIsNotNone(server._stage2_ready_error(self.root, confirm))
|
||||||
|
self.assertEqual(server._active_recommendations_path(confirm), rec)
|
||||||
|
self.assertIsNone(server._wait_result_status(confirm / 'result.json', 'stage1'))
|
||||||
|
response = client.post('/api/confirm', json={
|
||||||
|
'stage': 'stage1',
|
||||||
|
'options_sha256': server._build_template_options(confirm)[0]['options_sha256'],
|
||||||
|
'template_selection': {'mode': 'free_design', 'selection_keys': []},
|
||||||
|
})
|
||||||
|
self.assertEqual(response.status_code, 200, response.get_json())
|
||||||
|
current = json.loads((confirm / 'template_selection.json').read_text())
|
||||||
|
self.assertNotEqual(previous['selection_sha256'], current['selection_sha256'])
|
||||||
|
|
||||||
|
def test_b_real_endpoints_require_install_and_selection_bound_stage2(self) -> None:
|
||||||
|
for template in (True, False):
|
||||||
|
with self.subTest(template=template):
|
||||||
|
project = self.root / str(template)
|
||||||
|
project.mkdir()
|
||||||
|
confirm = project / 'confirm_ui'
|
||||||
|
_write_json(confirm / 'recommendations.stage1.json',
|
||||||
|
_stage1('templates' if template else 'free_design'))
|
||||||
|
client = server.create_app(project).test_client()
|
||||||
|
options = client.get('/api/recommendations').get_json()['template_options']
|
||||||
|
response = client.post('/api/confirm', json={
|
||||||
|
'stage': 'stage1', 'options_sha256': options['options_sha256'],
|
||||||
|
'template_selection': {
|
||||||
|
'mode': 'templates' if template else 'free_design',
|
||||||
|
'selection_keys': ['library:brand:zcare-rescue', 'library:layout:report_core']
|
||||||
|
if template else [],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
self.assertEqual(response.status_code, 200, response.get_json())
|
||||||
|
selection = json.loads((confirm / 'template_selection.json').read_text())
|
||||||
|
rec2, final = _stage2(selection['selection_sha256'], template)
|
||||||
|
_write_json(confirm / 'recommendations.stage2.json', rec2)
|
||||||
|
if template:
|
||||||
|
response = client.get('/api/recommendations')
|
||||||
|
self.assertEqual(response.status_code, 409)
|
||||||
|
self.assertIn('template_install.json', response.get_json()['error'])
|
||||||
|
apply_template.apply_templates(project, [
|
||||||
|
str(SCRIPTS.parent / 'templates' / 'brands' / 'zcare-rescue'),
|
||||||
|
str(SCRIPTS.parent / 'templates' / 'layouts' / 'report_core'),
|
||||||
|
], validate=False)
|
||||||
|
for path in confirm.iterdir():
|
||||||
|
os.utime(path, ns=(10, 10))
|
||||||
|
response = client.get('/api/recommendations')
|
||||||
|
self.assertEqual(response.status_code, 200, response.get_json())
|
||||||
|
rec2['selection_sha256'] = '0' * 64
|
||||||
|
_write_json(confirm / 'recommendations.stage2.json', rec2)
|
||||||
|
self.assertNotEqual(client.get('/api/recommendations').status_code, 200)
|
||||||
|
self.assertNotEqual(client.post('/api/confirm', json=final).status_code, 200)
|
||||||
|
rec2['selection_sha256'] = selection['selection_sha256']
|
||||||
|
_write_json(confirm / 'recommendations.stage2.json', rec2)
|
||||||
|
response = client.post('/api/confirm', json=final)
|
||||||
|
self.assertEqual(response.status_code, 200, response.get_json())
|
||||||
|
result = json.loads((confirm / 'result.json').read_text())
|
||||||
|
self.assertEqual(result['status'], 'confirmed')
|
||||||
|
self.assertEqual(result['selection_sha256'], selection['selection_sha256'])
|
||||||
|
self.assertFalse((confirm / 'template_handoff.json').exists())
|
||||||
|
self.assertEqual(client.get('/api/recommendations').status_code, 409)
|
||||||
|
|
||||||
|
def test_b_stale_browser_cannot_confirm_revised_options(self) -> None:
|
||||||
|
confirm = self.root / 'confirm_ui'
|
||||||
|
_write_json(confirm / 'recommendations.stage1.json', _stage1())
|
||||||
|
client = server.create_app(self.root).test_client()
|
||||||
|
options = client.get('/api/recommendations').get_json()['template_options']
|
||||||
|
_write_json(confirm / 'recommendations.stage1.json', {**_stage1(), 'revision': 2})
|
||||||
|
response = client.post('/api/confirm', json={
|
||||||
|
'stage': 'stage1', 'options_sha256': options['options_sha256'],
|
||||||
|
'template_selection': {'mode': 'free_design', 'selection_keys': []},
|
||||||
|
})
|
||||||
|
self.assertEqual(response.status_code, 409)
|
||||||
|
self.assertFalse((confirm / 'template_selection.json').exists())
|
||||||
|
|
||||||
|
def test_a_preservation_receipt_never_exempts_edited_geometry_or_assets(self) -> None:
|
||||||
|
import hashlib
|
||||||
|
|
||||||
|
svg = self.root / 'slide.svg'
|
||||||
|
svg.write_text('<svg/>', encoding='utf-8')
|
||||||
|
digest = hashlib.sha256(svg.read_bytes()).hexdigest()
|
||||||
|
asset = self.root / 'photo.png'
|
||||||
|
asset.write_bytes(b'original image asset')
|
||||||
|
_write_json(self.root / 'template_execution_manifest.json', {
|
||||||
|
'schema': 'ppt-master.template-execution-manifest.v1',
|
||||||
|
'replication_mode': 'mirror', 'source_package_sha256': 'a' * 64,
|
||||||
|
'source_geometry_unchanged': True,
|
||||||
|
'templates': [{'prototype': 'slide.svg', 'source_svg_sha256': digest}],
|
||||||
|
'source_files_sha256': {
|
||||||
|
'slide.svg': digest, asset.name: hashlib.sha256(asset.read_bytes()).hexdigest(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
geometry = '<text> exceeds the root viewBox on the horizontal axis'
|
||||||
|
result = {'source_sha256': digest, 'errors': [geometry, 'missing image'],
|
||||||
|
'warnings': [], 'info': {}}
|
||||||
|
checker = SVGQualityChecker(template_mode=True)
|
||||||
|
checker._classify_preserved_mirror_geometry(svg, result)
|
||||||
|
self.assertEqual(result['errors'], ['missing image'])
|
||||||
|
self.assertEqual(len(result['info']['inherited']), 1)
|
||||||
|
result.update(source_sha256='b' * 64, errors=[geometry], info={})
|
||||||
|
checker._classify_preserved_mirror_geometry(svg, result)
|
||||||
|
self.assertEqual(result['errors'], [geometry])
|
||||||
|
asset.write_bytes(b'changed image asset')
|
||||||
|
result.update(source_sha256=digest, errors=[geometry], info={})
|
||||||
|
checker._classify_preserved_mirror_geometry(svg, result)
|
||||||
|
self.assertEqual(result['errors'], [geometry])
|
||||||
|
|
||||||
|
def test_a_edited_import_cannot_receive_source_geometry_provenance(self) -> None:
|
||||||
|
import hashlib
|
||||||
|
|
||||||
|
source = self.root / 'source.svg'
|
||||||
|
source.write_text('<svg/>', encoding='utf-8')
|
||||||
|
doc = mirror.AuthoringDocument(
|
||||||
|
source.name, source, source, '', {}, hashlib.sha256(source.read_bytes()).hexdigest(),
|
||||||
|
)
|
||||||
|
self.assertTrue(mirror._source_geometry_is_unchanged({source.name: doc}))
|
||||||
|
source.write_text('<svg>edited</svg>', encoding='utf-8')
|
||||||
|
self.assertFalse(mirror._source_geometry_is_unchanged({source.name: doc}))
|
||||||
|
|
||||||
|
def test_a_spec_skeleton_is_factual_and_registration_rejects_todos(self) -> None:
|
||||||
|
from register_template import SpecParseError, _extract_entry
|
||||||
|
|
||||||
|
root = ET.fromstring(_structured_page('body'))
|
||||||
|
native = {'slideSize': {'width_px': 1280, 'height_px': 720},
|
||||||
|
'source': {'sha256': 'a' * 64}, 'slides': [{'index': 1}]}
|
||||||
|
spec = mirror._spec_skeleton(native, [(Path('templates/001_content.svg'), root)], 'deck')
|
||||||
|
self.assertIn('deck_id: TODO', spec)
|
||||||
|
self.assertIn('page_count: 1', spec)
|
||||||
|
self.assertIn('canvas_format: ppt169', spec)
|
||||||
|
self.assertIn('### Source Preservation Map', spec)
|
||||||
|
templates = self.root / 'templates'
|
||||||
|
templates.mkdir()
|
||||||
|
(templates / 'design_spec.md').write_text(spec, encoding='utf-8')
|
||||||
|
with self.assertRaisesRegex(SpecParseError, 'incomplete Design Spec skeleton'):
|
||||||
|
_extract_entry('deck', self.root.name, self.root)
|
||||||
|
|
||||||
|
def test_d_pptx_new_and_existing_companions_honor_switch(self) -> None:
|
||||||
|
source = self.root / 'reference.pptx'
|
||||||
|
source.write_bytes(b'source handled by converter below')
|
||||||
|
manager = ProjectManager()
|
||||||
|
|
||||||
|
def convert(_source: Path, markdown: Path) -> None:
|
||||||
|
markdown.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
markdown.write_text('reference', encoding='utf-8')
|
||||||
|
assets = markdown.with_name(markdown.stem + '_files')
|
||||||
|
assets.mkdir(exist_ok=True)
|
||||||
|
(assets / 'photo.png').write_bytes(b'image')
|
||||||
|
_write_json(assets / 'image_manifest.json', [{'filename': 'photo.png'}])
|
||||||
|
|
||||||
|
for existing in (True, False):
|
||||||
|
for propagate in (True, False):
|
||||||
|
project = self.root / f'{existing}-{propagate}'
|
||||||
|
project.mkdir()
|
||||||
|
if existing:
|
||||||
|
convert(source, project / 'sources' / 'reference.md')
|
||||||
|
with patch.object(manager, '_import_pptx_intake', return_value=project / 'analysis'), \
|
||||||
|
patch.object(manager, '_import_presentation', side_effect=convert):
|
||||||
|
manager.import_sources(str(project), [str(source)], copy=True,
|
||||||
|
propagate_images=propagate)
|
||||||
|
self.assertEqual((project / 'images' / 'photo.png').exists(), propagate)
|
||||||
|
self.assertTrue((project / 'sources' / 'reference_files' / 'photo.png').exists())
|
||||||
|
|
||||||
|
def test_e_quick_ignores_installed_unused_prototypes(self) -> None:
|
||||||
|
pages = self.root / 'svg_output'
|
||||||
|
pages.mkdir()
|
||||||
|
templates = self.root / 'templates'
|
||||||
|
templates.mkdir()
|
||||||
|
page = pages / '01_body.svg'
|
||||||
|
page.write_text(_structured_page('body'), encoding='utf-8')
|
||||||
|
(templates / 'unused.svg').write_text('invalid unused prototype', encoding='utf-8')
|
||||||
|
checker = SVGQualityChecker(quick_generate=True)
|
||||||
|
checker._check_pptx_structure_contract(pages, [page])
|
||||||
|
self.assertEqual(checker._pptx_structure_issues, [])
|
||||||
|
self.assertEqual(checker._structured_native_slots, [])
|
||||||
|
|
||||||
|
def test_c_capacity_is_font_sensitive_and_read_only(self) -> None:
|
||||||
|
from svg_quality.slot_capacity import slot_capacity_report
|
||||||
|
|
||||||
|
path = self.root / 'title.svg'
|
||||||
|
path.write_text(_structured_page('title',
|
||||||
|
'<g id="title" data-pptx-placeholder="title" data-pptx-bounds="0 0 320 100" '
|
||||||
|
'font-family="Arial" font-size="40"><text data-pptx-carrier="true" '
|
||||||
|
'x="0" y="40">{{TITLE}}</text></g>'), encoding='utf-8')
|
||||||
|
before = path.read_bytes()
|
||||||
|
report = slot_capacity_report([path])
|
||||||
|
slot = report['slots'][0]
|
||||||
|
self.assertEqual(slot['font_size'], 40)
|
||||||
|
self.assertGreater(slot['latin']['characters_per_line'], slot['cjk']['characters_per_line'])
|
||||||
|
self.assertGreater(slot['cjk']['lines'], 0)
|
||||||
|
self.assertNotIn('errors', report)
|
||||||
|
self.assertNotIn('warnings', report)
|
||||||
|
self.assertEqual(path.read_bytes(), before)
|
||||||
|
|
||||||
|
def test_c_capacity_reads_tspan_fonts_and_zero_baseline(self) -> None:
|
||||||
|
from svg_quality.slot_capacity import slot_capacity_report
|
||||||
|
|
||||||
|
path = self.root / 'mixed.svg'
|
||||||
|
path.write_text(_structured_page('mixed',
|
||||||
|
'<g id="mixed" data-pptx-placeholder="body" data-pptx-bounds="0 0 320 100">'
|
||||||
|
'<text data-pptx-carrier="true" font-family="Arial" font-size="16">'
|
||||||
|
'<tspan x="0" y="0" font-size="40">Large</tspan>'
|
||||||
|
'<tspan x="0" y="48" font-size="24">Small</tspan></text></g>'), encoding='utf-8')
|
||||||
|
slot = slot_capacity_report([path])['slots'][0]
|
||||||
|
self.assertEqual(slot['font_size'], 40)
|
||||||
|
self.assertEqual(slot['line_height'], 48)
|
||||||
|
self.assertEqual(slot['line_height_source'], 'tspan baselines')
|
||||||
|
self.assertEqual(len(slot['typography_variants']), 2)
|
||||||
|
self.assertIn('first visible run', slot['estimate_scope'])
|
||||||
|
|
||||||
|
def test_d_source_images_can_stay_out_of_runtime_pool(self) -> None:
|
||||||
|
source = self.root / 'reference.md'
|
||||||
|
source.write_text('', encoding='utf-8')
|
||||||
|
assets = self.root / 'reference_files'
|
||||||
|
assets.mkdir()
|
||||||
|
(assets / 'photo.png').write_bytes(b'bitmap')
|
||||||
|
_write_json(assets / 'image_manifest.json', [{'filename': 'photo.png'}])
|
||||||
|
project = self.root / 'project'
|
||||||
|
project.mkdir()
|
||||||
|
manager = ProjectManager()
|
||||||
|
manager.import_sources(str(project), [str(source)], copy=True, propagate_images=False)
|
||||||
|
self.assertTrue((project / 'sources' / 'reference_files' / 'photo.png').is_file())
|
||||||
|
self.assertFalse((project / 'images').exists())
|
||||||
|
manager.import_sources(str(project), [str(source)], copy=True)
|
||||||
|
self.assertTrue((project / 'images' / 'photo.png').is_file())
|
||||||
|
|
||||||
|
def test_e_registered_unused_typed_layout_is_in_checker_dependencies(self) -> None:
|
||||||
|
pages = self.root / 'svg_output'
|
||||||
|
pages.mkdir()
|
||||||
|
templates = self.root / 'templates'
|
||||||
|
templates.mkdir()
|
||||||
|
(pages / '01_content.svg').write_text(_structured_page('content'), encoding='utf-8')
|
||||||
|
(templates / '02_chart.svg').write_text(_structured_page('chart',
|
||||||
|
'<g id="chart" data-pptx-placeholder="chart" data-pptx-bounds="0 0 600 400">'
|
||||||
|
'<g data-pptx-carrier="true" data-pptx-replace-with="chart">'
|
||||||
|
'<rect x="0" y="0" width="600" height="400"/></g>'
|
||||||
|
'</g>'), encoding='utf-8')
|
||||||
|
lock = ('## typography\n- font_family: Arial\n- title: 40\n- body: 24\n'
|
||||||
|
'## colors\n- primary: #000000\n- background: #FFFFFF\n'
|
||||||
|
'## pptx_structure\n- mode: structured\n## pptx_masters\n- m: Master\n'
|
||||||
|
'## pptx_layouts\n- content: m | content | P01\n'
|
||||||
|
'- chart: m | chart | template:02_chart\n'
|
||||||
|
'## page_pptx_layouts\n- P01: content\n')
|
||||||
|
(self.root / 'spec_lock.md').write_text(lock, encoding='utf-8')
|
||||||
|
specs = parse_template_slides([pages / '01_content.svg'])
|
||||||
|
structure = load_pptx_structure_lock(self.root)
|
||||||
|
self.assertEqual(structured_layout_definition_files(specs, structure), [templates / '02_chart.svg'])
|
||||||
|
checker = SVGQualityChecker()
|
||||||
|
checker._check_pptx_structure_contract(pages, [pages / '01_content.svg'])
|
||||||
|
self.assertEqual(checker._structured_native_slots, ['chart'], checker._pptx_structure_issues)
|
||||||
|
(self.root / 'spec_lock.md').write_text(
|
||||||
|
lock.replace('- chart: m | chart | template:02_chart\n', ''), encoding='utf-8')
|
||||||
|
structure = load_pptx_structure_lock(self.root)
|
||||||
|
self.assertEqual(structured_layout_definition_files(specs, structure), [])
|
||||||
+207
@@ -0,0 +1,207 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Regressions for dogfood template publication, typography and geometry.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python3 -m unittest tests.test_template_flow_batch4
|
||||||
|
|
||||||
|
Dependencies:
|
||||||
|
Standard library and the script runtime dependencies.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import contextlib
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
import zipfile
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch
|
||||||
|
from xml.etree import ElementTree as ET
|
||||||
|
|
||||||
|
SCRIPTS = Path(__file__).resolve().parents[1]
|
||||||
|
if str(SCRIPTS) not in sys.path:
|
||||||
|
sys.path.insert(0, str(SCRIPTS))
|
||||||
|
|
||||||
|
import mirror_template_materialize as mirror # noqa: E402
|
||||||
|
import pptx_template_import # noqa: E402
|
||||||
|
from svg_quality.checker import SVGQualityChecker # noqa: E402
|
||||||
|
from svg_quality.slot_capacity import slot_capacity_report # noqa: E402
|
||||||
|
from svg_to_pptx.drawingml.utils import parse_font_family, unsafe_exported_font_faces # noqa: E402
|
||||||
|
from svg_to_pptx.drawingml.theme_fonts import MasterTextStyleSpec # noqa: E402
|
||||||
|
from svg_to_pptx.pptx_package.builder import create_pptx_with_native_svg # noqa: E402
|
||||||
|
from svg_to_pptx.pptx_package.template_structure import parse_template_slide # noqa: E402
|
||||||
|
from template_text_slots import analyze_template_text_slots # noqa: E402
|
||||||
|
|
||||||
|
SVG_NS = 'http://www.w3.org/2000/svg'
|
||||||
|
NS = {'a': 'http://schemas.openxmlformats.org/drawingml/2006/main',
|
||||||
|
'p': 'http://schemas.openxmlformats.org/presentationml/2006/main'}
|
||||||
|
PNG = ('data:image/png;base64,'
|
||||||
|
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGP4z8AAAAMBAQDJ/pLvAAAAAElFTkSuQmCC')
|
||||||
|
|
||||||
|
|
||||||
|
def _page(body: str, attributes: str = '') -> str:
|
||||||
|
return (f'<svg xmlns="{SVG_NS}" viewBox="0 0 1280 720" font-family="Arial" '
|
||||||
|
'data-pptx-master="m" data-pptx-master-name="Master" '
|
||||||
|
f'data-pptx-layout="l" data-pptx-layout-name="Layout" {attributes}>{body}</svg>')
|
||||||
|
|
||||||
|
|
||||||
|
class TemplateFlowBatch4Tests(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
temporary = tempfile.TemporaryDirectory()
|
||||||
|
self.addCleanup(temporary.cleanup)
|
||||||
|
self.root = Path(temporary.name)
|
||||||
|
|
||||||
|
def _write(self, name: str, content: str) -> Path:
|
||||||
|
path = self.root / name
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
path.write_text(content, encoding='utf-8')
|
||||||
|
return path
|
||||||
|
|
||||||
|
def test_s1_materializer_uses_resolved_workspace_scope(self) -> None:
|
||||||
|
svg = self._write('source.svg', _page(
|
||||||
|
'<text x="100" y="100" font-size="24">Source</text>'))
|
||||||
|
pptx = self.root / 'source.pptx'
|
||||||
|
with contextlib.redirect_stdout(io.StringIO()):
|
||||||
|
self.assertTrue(create_pptx_with_native_svg(
|
||||||
|
[svg], pptx, resource_root=self.root, verbose=False, use_compat_mode=False,
|
||||||
|
master_text_style_spec=MasterTextStyleSpec(3000, 1800)))
|
||||||
|
imported = self.root / 'imported'
|
||||||
|
with patch.object(sys, 'argv', ['pptx_template_import.py', str(pptx),
|
||||||
|
'-o', str(imported), '--inheritance-mode', 'both']):
|
||||||
|
self.assertEqual(pptx_template_import.main(), 0)
|
||||||
|
fake_script = self.root / 'skill/scripts/mirror_template_materialize.py'
|
||||||
|
library = self.root / 'skill/templates/decks'
|
||||||
|
library.mkdir(parents=True)
|
||||||
|
outside = self.root / 'outside'
|
||||||
|
outside.mkdir()
|
||||||
|
(library / 'escaped').symlink_to(outside, target_is_directory=True)
|
||||||
|
with patch.object(mirror, '__file__', str(fake_script)):
|
||||||
|
for destination, kind, expected in (
|
||||||
|
(self.root / 'project', 'deck', 'design_spec.deck.TODO.md'),
|
||||||
|
(self.root / 'project-layout', 'layout', 'design_spec.layout.TODO.md'),
|
||||||
|
(library / 'example', 'deck', 'design_spec.md'),
|
||||||
|
(library.parent / 'layouts/example', 'layout', 'design_spec.md'),
|
||||||
|
(library / 'escaped/project', 'deck', 'design_spec.deck.TODO.md'),
|
||||||
|
):
|
||||||
|
with self.subTest(destination=destination):
|
||||||
|
receipt = mirror.materialize_mirror_template(imported, destination, kind=kind)
|
||||||
|
self.assertEqual(receipt['spec_skeleton'], f'templates/{expected}')
|
||||||
|
self.assertTrue((destination / 'templates' / expected).is_file())
|
||||||
|
|
||||||
|
def test_s2_mirror_capacity_resolves_sidecar_selectors_and_effective_fonts(self) -> None:
|
||||||
|
path = self._write('templates/01.svg', _page(
|
||||||
|
'<g id="source" data-pptx-bounds="20 40 320 100" font-size="40">'
|
||||||
|
'<text x="20" y="80"><tspan font-family="Microsoft YaHei">中文</tspan></text></g>'
|
||||||
|
'<text id="unlisted" x="500" y="80">Unlisted</text>'))
|
||||||
|
slots = analyze_template_text_slots(ET.parse(path).getroot())
|
||||||
|
self._write('templates/template_execution_manifest.json', json.dumps({
|
||||||
|
'replication_mode': 'mirror', 'source_geometry_unchanged': True,
|
||||||
|
}))
|
||||||
|
self._write('templates/template_execution/01.text-slots.json', json.dumps({
|
||||||
|
'text_slots': [slots[0].model_payload()],
|
||||||
|
}))
|
||||||
|
before = {p: p.read_bytes() for p in self.root.rglob('*') if p.is_file()}
|
||||||
|
rows = slot_capacity_report([path])['slots']
|
||||||
|
self.assertEqual(len(rows), 1)
|
||||||
|
self.assertEqual(rows[0]['selector'], '#source>text')
|
||||||
|
self.assertEqual(rows[0]['source'], 'mirror-text-slot')
|
||||||
|
self.assertEqual(rows[0]['bounds'], (20, 40, 320, 100))
|
||||||
|
self.assertEqual(rows[0]['font_size'], 40)
|
||||||
|
self.assertEqual(rows[0]['font_family'], 'Microsoft YaHei')
|
||||||
|
self.assertGreater(rows[0]['cjk']['characters_per_line'], 0)
|
||||||
|
self.assertEqual(before, {p: p.read_bytes() for p in self.root.rglob('*') if p.is_file()})
|
||||||
|
empty = self._write('empty.svg', _page('<rect width="5" height="5"/>'))
|
||||||
|
report = slot_capacity_report([empty])
|
||||||
|
self.assertEqual(report['slots'], [])
|
||||||
|
self.assertIn('note', report)
|
||||||
|
sidecar = self._write('templates/template_execution/01.text-slots.json', json.dumps({
|
||||||
|
'text_slots': [{'selector': '#missing'}],
|
||||||
|
}))
|
||||||
|
self.assertIn('unavailable', slot_capacity_report([path])['slots'][0])
|
||||||
|
sidecar.write_text('[]')
|
||||||
|
with self.assertRaisesRegex(ValueError, 'text_slots array'):
|
||||||
|
slot_capacity_report([path])
|
||||||
|
|
||||||
|
def test_s3_translucent_canvas_rect_stays_a_master_shape(self) -> None:
|
||||||
|
self._write('design_spec.md', '---\nkind: deck\nnative_structure_mode: structured\n---\n# Spec\n')
|
||||||
|
for opacity in ('fill-opacity="0.8"', 'opacity="0.8"',
|
||||||
|
'style="fill-opacity:0.8"', 'style="opacity:80%"'):
|
||||||
|
with self.subTest(opacity=opacity):
|
||||||
|
path = self._write('scrim.svg', _page(
|
||||||
|
'<rect id="m-bg" data-pptx-layer="master" width="1280" height="720" fill="#FFFFFF"/>'
|
||||||
|
f'<image id="m-field" data-pptx-layer="master" href="{PNG}" width="1280" height="720"/>'
|
||||||
|
'<rect id="m-scrim" data-pptx-layer="master" width="1280" height="720" '
|
||||||
|
f'fill="#FFFFFF" {opacity}/>'))
|
||||||
|
result = SVGQualityChecker(template_mode=True).check_file(str(path))
|
||||||
|
self.assertFalse(any('paint order' in issue for issue in result['errors']), result['errors'])
|
||||||
|
parse_template_slide(path, 1)
|
||||||
|
# Export an attribute-based case and inspect the native shape alpha.
|
||||||
|
path.write_text(path.read_text().replace('style="opacity:80%"', 'fill-opacity="0.8"'))
|
||||||
|
pptx = self.root / 'scrim.pptx'
|
||||||
|
with contextlib.redirect_stdout(io.StringIO()):
|
||||||
|
self.assertTrue(create_pptx_with_native_svg(
|
||||||
|
[path], pptx, resource_root=self.root, verbose=False, use_compat_mode=False,
|
||||||
|
master_text_style_spec=MasterTextStyleSpec(3000, 1800)))
|
||||||
|
with zipfile.ZipFile(pptx) as package:
|
||||||
|
master = ET.fromstring(package.read('ppt/slideMasters/slideMaster1.xml'))
|
||||||
|
self.assertEqual(len(master.findall('./p:cSld/p:bg', NS)), 1)
|
||||||
|
self.assertIsNone(master.find('./p:cSld/p:bg//a:alpha', NS))
|
||||||
|
self.assertEqual(master.find('.//p:sp/p:spPr/a:solidFill/a:srgbClr/a:alpha', NS).get('val'), '80000')
|
||||||
|
opaque = path.read_text().replace('fill-opacity="0.8"', 'fill-opacity="1"')
|
||||||
|
path.write_text(opaque)
|
||||||
|
self.assertTrue(any('paint order' in issue for issue in
|
||||||
|
SVGQualityChecker(template_mode=True).check_file(str(path))['errors']))
|
||||||
|
for inherited in ('fill-opacity="0.8"', 'style="opacity:0.8"'):
|
||||||
|
with self.subTest(inherited=inherited):
|
||||||
|
path.write_text(opaque.replace('fill-opacity="1"', '').replace(
|
||||||
|
'data-pptx-layout="l"', f'data-pptx-layout="l" {inherited}'))
|
||||||
|
self.assertFalse(any('paint order' in issue for issue in
|
||||||
|
SVGQualityChecker(template_mode=True).check_file(str(path))['errors']))
|
||||||
|
parse_template_slide(path, 1)
|
||||||
|
|
||||||
|
def test_s4_cjk_alias_does_not_claim_the_named_latin_slot(self) -> None:
|
||||||
|
for family in ('微软雅黑', 'Microsoft YaHei', 'microsoft yahei', 'noto sans sc'):
|
||||||
|
for stack in (f'{family}, Arial', f'"{family}", Arial, sans-serif'):
|
||||||
|
with self.subTest(stack=stack):
|
||||||
|
self.assertEqual(parse_font_family(stack), {'latin': 'Arial', 'ea': 'Microsoft YaHei'})
|
||||||
|
self.assertEqual(unsafe_exported_font_faces(stack), {})
|
||||||
|
for stack in ('微软雅黑', 'Microsoft YaHei, SimHei, sans-serif'):
|
||||||
|
self.assertEqual(parse_font_family(stack), {'latin': 'Microsoft YaHei', 'ea': 'Microsoft YaHei'})
|
||||||
|
self.assertEqual(parse_font_family('Arial, sans-serif')['latin'], 'Arial')
|
||||||
|
self.assertEqual(parse_font_family('serif, Microsoft YaHei')['latin'], 'Times New Roman')
|
||||||
|
self.assertEqual(parse_font_family('Arial', 'ja-JP')['ea'], 'Yu Gothic')
|
||||||
|
|
||||||
|
def test_s5_template_marker_checks_baseline_with_existing_severity(self) -> None:
|
||||||
|
for baseline, bucket in ((272, 'errors'), (268.4, 'warnings'), (262.4, None), (257.6, None)):
|
||||||
|
with self.subTest(baseline=baseline):
|
||||||
|
path = self._write('chapter.svg', _page(
|
||||||
|
'<g id="chapter-num-slot" data-pptx-placeholder="body" data-pptx-bounds="160 176 320 120">'
|
||||||
|
f'<text id="chapter-num-carrier" data-pptx-carrier="true" x="160" y="{baseline}" '
|
||||||
|
'font-size="96">{{CHAPTER_NUM}}</text></g>'))
|
||||||
|
result = SVGQualityChecker(template_mode=True).check_file(str(path))
|
||||||
|
findings = {key: [issue for issue in result[key] if 'exceeds' in issue]
|
||||||
|
for key in ('errors', 'warnings')}
|
||||||
|
if bucket:
|
||||||
|
self.assertEqual(len(findings[bucket]), 1, result)
|
||||||
|
self.assertIn('vertical axis', findings[bucket][0])
|
||||||
|
else:
|
||||||
|
self.assertEqual(findings, {'errors': [], 'warnings': []})
|
||||||
|
slot = slot_capacity_report([path])['slots'][0]
|
||||||
|
self.assertEqual(slot['baseline_allowed'], [257.6, 262.4])
|
||||||
|
self.assertEqual(slot['baseline'], baseline)
|
||||||
|
|
||||||
|
def test_s6_fixed_layers_do_not_consume_sparse_display_quota(self) -> None:
|
||||||
|
self._write('spec_lock.md', '<!-- ppt-master-schema: spec-lock/v1 -->\n'
|
||||||
|
'# Lock\n\n## typography\n- body: 24\n\n## pptx_structure\n- mode: structured\n')
|
||||||
|
for layer in ('master', 'layout', None):
|
||||||
|
with self.subTest(layer=layer):
|
||||||
|
metadata = f'data-pptx-layer="{layer}" data-pptx-editable="false"' if layer else ''
|
||||||
|
path = self._write('svg_output/01.svg', _page(''.join(
|
||||||
|
f'<text id="index-{i}" {metadata} x="140" y="{250 + i * 90}" font-size="40">0{i}</text>'
|
||||||
|
for i in range(4))))
|
||||||
|
result = SVGQualityChecker().check_file(str(path))
|
||||||
|
recurrence = [issue for issue in result['errors'] if 'typography-size recurrence:' in issue]
|
||||||
|
self.assertEqual(bool(recurrence), layer is None, result)
|
||||||
+2
-2
@@ -40,9 +40,9 @@
|
|||||||
href="../images/skyline_bg.png" preserveAspectRatio="xMidYMid meet"
|
href="../images/skyline_bg.png" preserveAspectRatio="xMidYMid meet"
|
||||||
data-pptx-layer="layout" data-pptx-editable="false"/>
|
data-pptx-layer="layout" data-pptx-editable="false"/>
|
||||||
<g id="section-number-slot" data-pptx-placeholder="body" data-pptx-idx="1"
|
<g id="section-number-slot" data-pptx-placeholder="body" data-pptx-idx="1"
|
||||||
data-pptx-bounds="96 148 300 116">
|
data-pptx-bounds="96 120 300 140">
|
||||||
<text id="section-number-carrier" data-pptx-carrier="true"
|
<text id="section-number-carrier" data-pptx-carrier="true"
|
||||||
x="96" y="242" fill="#C00000" fill-opacity="0.14"
|
x="96" y="218" fill="#C00000" fill-opacity="0.14"
|
||||||
font-family="Arial, sans-serif" font-size="112" font-weight="700">{{CHAPTER_NUM}}</text>
|
font-family="Arial, sans-serif" font-size="112" font-weight="700">{{CHAPTER_NUM}}</text>
|
||||||
</g>
|
</g>
|
||||||
<g id="section-title-slot" data-pptx-placeholder="title"
|
<g id="section-title-slot" data-pptx-placeholder="title"
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 3.7 KiB After Width: | Height: | Size: 3.7 KiB |
+1
-1
@@ -21,7 +21,7 @@
|
|||||||
<g id="chapter-full-number-slot" data-pptx-placeholder="body" data-pptx-idx="2"
|
<g id="chapter-full-number-slot" data-pptx-placeholder="body" data-pptx-idx="2"
|
||||||
data-pptx-bounds="80 232 320 72">
|
data-pptx-bounds="80 232 320 72">
|
||||||
<text id="chapter-full-number-carrier" data-pptx-carrier="true"
|
<text id="chapter-full-number-carrier" data-pptx-carrier="true"
|
||||||
x="80" y="292" fill="#64748B" font-family="Arial, Microsoft YaHei, sans-serif"
|
x="80" y="282" fill="#64748B" font-family="Arial, Microsoft YaHei, sans-serif"
|
||||||
font-size="56" font-weight="700">{{CHAPTER_NUM}}</text>
|
font-size="56" font-weight="700">{{CHAPTER_NUM}}</text>
|
||||||
</g>
|
</g>
|
||||||
<g id="chapter-full-title-slot" data-pptx-placeholder="title"
|
<g id="chapter-full-title-slot" data-pptx-placeholder="title"
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 2.2 KiB |
+1
-1
@@ -9,7 +9,7 @@
|
|||||||
<g id="section-divider-number-slot" data-pptx-placeholder="body" data-pptx-idx="1"
|
<g id="section-divider-number-slot" data-pptx-placeholder="body" data-pptx-idx="1"
|
||||||
data-pptx-bounds="48 200 200 72">
|
data-pptx-bounds="48 200 200 72">
|
||||||
<text id="section-divider-number-carrier" data-pptx-carrier="true"
|
<text id="section-divider-number-carrier" data-pptx-carrier="true"
|
||||||
x="48" y="260" fill="#64748B" font-family="Arial, Microsoft YaHei, sans-serif"
|
x="48" y="250" fill="#64748B" font-family="Arial, Microsoft YaHei, sans-serif"
|
||||||
font-size="56" font-weight="700">{{CHAPTER_NUM}}</text>
|
font-size="56" font-weight="700">{{CHAPTER_NUM}}</text>
|
||||||
</g>
|
</g>
|
||||||
<g id="section-divider-title-slot" data-pptx-placeholder="title"
|
<g id="section-divider-title-slot" data-pptx-placeholder="title"
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 1.6 KiB |
@@ -40,7 +40,7 @@ A complete source PPTX does not determine the kind — classify only the stable
|
|||||||
## Process Overview
|
## Process Overview
|
||||||
|
|
||||||
```
|
```
|
||||||
Reference Bundle Intake & Analysis → Fact-Based Brief Proposal → User Confirmation Gate → Preflight + Invoke Selected Child → Validate Child Output → [Review PPTX: optional for one Master, required for multi-Master] → [Register Library Index] → Output
|
Reference Bundle Intake & Analysis → Fact-Based Brief Proposal → User Confirmation Gate → Preflight + Invoke Selected Child → Validate Child Output → [Prototype Review Round: Layout/Deck] → [Review PPTX: optional for one Master, required for multi-Master] → [Register Library Index] → Output
|
||||||
```
|
```
|
||||||
|
|
||||||
**No final template directory, template SVG, or Design Spec may be written until `[TEMPLATE_BRIEF_CONFIRMED]` is emitted in Step 3.** Reference-analysis intermediates (import workspaces under `/tmp/`) are not subject to this gate.
|
**No final template directory, template SVG, or Design Spec may be written until `[TEMPLATE_BRIEF_CONFIRMED]` is emitted in Step 3.** Reference-analysis intermediates (import workspaces under `/tmp/`) are not subject to this gate.
|
||||||
@@ -54,7 +54,7 @@ Run every applicable branch for the bundle (one source, several files, mixed typ
|
|||||||
| Type | Supplied | Tool / read path | Strategies the evidence supports |
|
| Type | Supplied | Tool / read path | Strategies the evidence supports |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| **A** `.pptx` | A `.pptx` path | `pptx_template_import.py` → import workspace ([`template-tools.md`](../scripts/docs/template-tools.md)) | `standard` / `fidelity` / `mirror` |
|
| **A** `.pptx` | A `.pptx` path | `pptx_template_import.py` → import workspace ([`template-tools.md`](../scripts/docs/template-tools.md)) | `standard` / `fidelity` / `mirror` |
|
||||||
| **B** SVG assets | `projects/<x>/svg_output/`, a current workspace root, or a loose `.svg` folder | Normalize the source, create an authoring IR bundle with `svg_authoring_view.py`, run the readability pass on that IR only; read companion `design_spec.md` / `spec_lock.md` | `standard` / `fidelity`; `mirror` only with a complete explicit Master/Layout/placeholder/native-object contract |
|
| **B** SVG assets | `projects/<x>/svg_output/`, a current workspace root, or a loose `.svg` folder | Normalize into a throwaway generic IR bundle for authored modes; read companion specs. Consume an existing template only through its exact root with `apply_template.py` | `standard` / `fidelity` |
|
||||||
| **C** Images / visuals | PNG/JPG/WebP, screenshots, moodboards, PDF pages | `ls` + `Read` each visual (multimodal) | `standard` only by itself |
|
| **C** Images / visuals | PNG/JPG/WebP, screenshots, moodboards, PDF pages | `ls` + `Read` each visual (multimodal) | `standard` only by itself |
|
||||||
| **D** Text / document / website / assets | Direct text, Markdown/TXT, DOCX/PDF/HTML/URL, brand manuals, logo/icon/font assets | Direct text as-is; convert documents/URLs with `source_to_md.py` into a temporary analysis workspace; inventory explicit assets | `standard` only by itself |
|
| **D** Text / document / website / assets | Direct text, Markdown/TXT, DOCX/PDF/HTML/URL, brand manuals, logo/icon/font assets | Direct text as-is; convert documents/URLs with `source_to_md.py` into a temporary analysis workspace; inventory explicit assets | `standard` only by itself |
|
||||||
| **E** Nothing | A request with no source and no substantive brief | Skip analysis; collect every Required value in Steps 2–3 | `standard` only |
|
| **E** Nothing | A request with no source and no substantive brief | Skip analysis; collect every Required value in Steps 2–3 | `standard` only |
|
||||||
@@ -65,13 +65,13 @@ Run every applicable branch for the bundle (one source, several files, mixed typ
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `standard` | Any channel; C/D/E supplement but never create native topology | Review all source structure, then author a compact source-aligned roster with one Slide prototype per retained Layout |
|
| `standard` | Any channel; C/D/E supplement but never create native topology | Review all source structure, then author a compact source-aligned roster with one Slide prototype per retained Layout |
|
||||||
| `fidelity` | A/B page evidence | Review all source structure, then author a broader source-aligned roster with one Slide prototype per retained Layout |
|
| `fidelity` | A/B page evidence | Review all source structure, then author a broader source-aligned roster with one Slide prototype per retained Layout |
|
||||||
| `mirror` | A, or a complete current B contract | Author compact parsed SVG for every source Slide, preserving the reachable graph, meaning, ownership, and similar presentation — not code identity; Create Layout mirror requires a brand/application-neutral contract |
|
| `mirror` | A only | Publish the importer output through the [tool path](../scripts/docs/template-tools.md#mirror-publication); the model decides reuse intent, asset adoption and Design Spec judgment. Create Layout mirror requires brand/application-neutral evidence |
|
||||||
|
|
||||||
Future decks need not keep source page count/order.
|
Future decks need not keep source page count/order.
|
||||||
|
|
||||||
### 1A. `.pptx` reference
|
### 1A. `.pptx` reference
|
||||||
|
|
||||||
Run `pptx_template_import.py "<reference.pptx>" -o "<import_workspace>"` (without `-o` the workspace lands beside the source file); the workspace it produces and each artifact's role are [`template-tools.md`](../scripts/docs/template-tools.md). Type A is the canonical mirror path; in `standard` / `fidelity`, imported facts do not define output topology. Never copy lossless or flat pages into `templates/`. For Type A `mirror`, `mirror_template_materialize.py` validates and publishes after review, fidelity edits, and the readability pass; authored modes never use it.
|
Run `pptx_template_import.py "<reference.pptx>" -o "<import_workspace>"` (without `-o` the workspace lands beside the source file); the workspace it produces and each artifact's role are [`template-tools.md`](../scripts/docs/template-tools.md). Type A is the canonical mirror path; in `standard` / `fidelity`, imported facts do not define output topology. Never copy lossless or flat pages into `templates/`. For Type A `mirror`, run the unchanged importer output through the [mirror publication tools](../scripts/docs/template-tools.md#mirror-publication); authored modes never use the materializer.
|
||||||
|
|
||||||
**Explicit complex-SVG picture normalization** (`standard` / `fidelity` only): when one imported native group is deliberately retained as one complex SVG picture rather than rebuilt as editable paths, select its exact id in the layered IR with `extract_svg_pictures.py ... --select "<group_id>" --resource-root "<import_workspace>" --images-dir "<import_workspace>/picture-assets" --inplace` (repeat `--select` for independent siblings; select the outer group when an ancestor carries a transform, style, clip, or opacity). If chosen for a Master or Layout, copy the asset into the image pool and author the fixed atom as a direct `<image data-pptx-layer="master|layout">`. This is a semantic decision, never automatic, never by repetition, never a way to infer ownership. Not for placeholders, individual native shapes, table/chart fallbacks, icon placeholders, authored presets, or `mirror`.
|
**Explicit complex-SVG picture normalization** (`standard` / `fidelity` only): when one imported native group is deliberately retained as one complex SVG picture rather than rebuilt as editable paths, select its exact id in the layered IR with `extract_svg_pictures.py ... --select "<group_id>" --resource-root "<import_workspace>" --images-dir "<import_workspace>/picture-assets" --inplace` (repeat `--select` for independent siblings; select the outer group when an ancestor carries a transform, style, clip, or opacity). If chosen for a Master or Layout, copy the asset into the image pool and author the fixed atom as a direct `<image data-pptx-layer="master|layout">`. This is a semantic decision, never automatic, never by repetition, never a way to infer ownership. Not for placeholders, individual native shapes, table/chart fallbacks, icon placeholders, authored presets, or `mirror`.
|
||||||
|
|
||||||
@@ -80,11 +80,11 @@ Run `pptx_template_import.py "<reference.pptx>" -o "<import_workspace>"` (withou
|
|||||||
| Strategy | Read |
|
| Strategy | Read |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `standard` / `fidelity` | `analysis/manifest.json`, exported resources, `svg/inheritance.json`, `authoring_summary.json`, and every cleaned layered IR document (Masters, Layouts, Slides — the complete read surface, including Layouts unused by any sample slide); flat pages are optional spot checks; never `authoring_manifest.json` |
|
| `standard` / `fidelity` | `analysis/manifest.json`, exported resources, `svg/inheritance.json`, `authoring_summary.json`, and every cleaned layered IR document (Masters, Layouts, Slides — the complete read surface, including Layouts unused by any sample slide); flat pages are optional spot checks; never `authoring_manifest.json` |
|
||||||
| `mirror` | Both manifests, inheritance, the summary, every source Slide SVG, and only reachable Master/Layout SVGs |
|
| `mirror` | Source summaries and asset evidence needed for reuse and Design Spec judgment; tools consume the complete import workspace |
|
||||||
|
|
||||||
Use manifest facts for orientation and screenshots or the original PPTX only for visual cross-checking; never bulk-read opaque payload. If the reference was also brought in with `import-sources`, its extracted bitmaps were propagated into the project's `images/`; keep only identity assets there (a logo, an emblem) before Step 4, or `apply_template.py` carries the sample pictures into every downstream project.
|
Use manifest facts for orientation and screenshots or the original PPTX only for visual cross-checking; never bulk-read opaque payload. When bringing a reference into project scope for analysis, use `project_manager.py import-sources --no-image-propagation` to retain extracted bitmaps under `sources/`; adopt identity assets into `images/` deliberately before Step 4.
|
||||||
|
|
||||||
**Mirror reachable-graph gate**: before offering `mirror`, compare every source Slide and referenced Layout/Master with the authoring summary; missing reachable evidence or ambiguous parentage blocks; omit unused identities. The publisher verifies source SHA, refs, graph/assignment closure, and subtree hashes; an authored change never triggers visible XML restoration.
|
**Mirror gate**: the [publisher](../scripts/docs/template-tools.md#mirror-publication) verifies the reachable source graph; a failing receipt blocks publication. Report source limitations from its output.
|
||||||
|
|
||||||
### Basic norm extraction (mandatory when reference content exists)
|
### Basic norm extraction (mandatory when reference content exists)
|
||||||
|
|
||||||
@@ -107,11 +107,10 @@ Extract the source's observable operating rules — not generic design advice
|
|||||||
|
|
||||||
**Source resolution**: a root exposing any `templates/` Design Spec uses `<input>/templates/` plus sibling `images/` / `icons/`; otherwise the directory is loose evidence (flatness is not a structure signal). A selected free-design subset ingests only the named pages and never scans the whole `svg_output/`.
|
**Source resolution**: a root exposing any `templates/` Design Spec uses `<input>/templates/` plus sibling `images/` / `icons/`; otherwise the directory is loose evidence (flatness is not a structure signal). A selected free-design subset ingests only the named pages and never scans the whole `svg_output/`.
|
||||||
|
|
||||||
**Read**: build the throwaway IR bundle per [`template-tools.md`](../scripts/docs/template-tools.md), then read `authoring_summary.json`, `ls` the workspace, and every cleaned `authoring-svg/*.svg` for canvas, recurring colors (dominant 2–4 hex as candidate theme colors), fonts, existing `{{...}}` placeholders, and structural decoration. Open imported vectors only when a specific asset affects a decision. A companion `design_spec.md` / `spec_lock.md` is part of the mirror source contract and must agree with the SVG identities; in authored modes it is context only.
|
**Read**: build the throwaway IR bundle per [`template-tools.md`](../scripts/docs/template-tools.md), then read `authoring_summary.json`, `ls` the workspace, and every cleaned `authoring-svg/*.svg` for canvas, recurring colors (dominant 2–4 hex as candidate theme colors), fonts, existing `{{...}}` placeholders, and structural decoration. Open imported vectors only when a specific asset affects a decision. Companion specs provide context for authored modes; a current SVG template is reused through its exact workspace root, never through mirror publication.
|
||||||
|
|
||||||
| Type B strategy | Rule |
|
| Type B strategy | Rule |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `mirror` | Requires a complete current explicit contract; preserves page count/order, presentation, each Slide's Layout/Master chain, slot metadata, native-object metadata, and ownership in the new workspace (page type from a PPT Master-convention filename, else `content`); a loose visual-only folder cannot mirror |
|
|
||||||
| `fidelity` | Designs a broader new roster and structure after inspecting the complete roster |
|
| `fidelity` | Designs a broader new roster and structure after inspecting the complete roster |
|
||||||
| Legacy or unstructured B (`baseline` / `preserve` / `layout_strategy: distill` / `data-pptx-layout-kind` / direct atomic placeholders / no root identity) | Visual reference for authored modes only; use the original PPTX to mirror native facts |
|
| Legacy or unstructured B (`baseline` / `preserve` / `layout_strategy: distill` / `data-pptx-layout-kind` / direct atomic placeholders / no root identity) | Visual reference for authored modes only; use the original PPTX to mirror native facts |
|
||||||
|
|
||||||
@@ -199,7 +198,7 @@ Before emitting the marker, all of these hold:
|
|||||||
| Scope | Condition |
|
| Scope | Condition |
|
||||||
|---|---|
|
|---|---|
|
||||||
| All | Every Required item shown with provenance; one natural-language plan, no mode menu; internal IDs absent or confined to an audit note; the user replied with corrections or acceptance; scope confirmed (project with an explicit initialized path); every channel analyzed or explicitly excluded with conflicts surfaced; child-specific norms surfaced or marked N/A; library metadata complete enough to register, or project scope with no registration planned |
|
| All | Every Required item shown with provenance; one natural-language plan, no mode menu; internal IDs absent or confined to an audit note; the user replied with corrections or acceptance; scope confirmed (project with an explicit initialized path); every channel analyzed or explicitly excluded with conflicts surfaced; child-specific norms surfaced or marked N/A; library metadata complete enough to register, or project scope with no registration planned |
|
||||||
| Layout/Deck | The canvas is fixed; the derived strategy matches the evidence (`fidelity` needs A/B, `mirror` needs A or structured B, C/D/E permit only `standard`; Layout mirror evidence is brand/application-neutral); structure ownership explicit |
|
| Layout/Deck | The canvas is fixed; the strategy matches the evidence (`fidelity` needs A/B, `mirror` needs A, C/D/E permit only `standard`; Layout mirror evidence is brand/application-neutral); structure ownership explicit |
|
||||||
| Mirror | Every source Slide and reachable chain valid and context-complete, with omitted identities and missing facts reported |
|
| Mirror | Every source Slide and reachable chain valid and context-complete, with omitted identities and missing facts reported |
|
||||||
| Style | Method, vocabulary, evidence rules, defaults, direction, and review focus confirmed with project-specific context; identity/structure N/A |
|
| Style | Method, vocabulary, evidence rules, defaults, direction, and review focus confirmed with project-specific context; identity/structure N/A |
|
||||||
| Deck | Application context understood without turning it into policy |
|
| Deck | Application context understood without turning it into policy |
|
||||||
@@ -234,7 +233,7 @@ Before emitting the marker, all of these hold:
|
|||||||
|
|
||||||
| Type | Package |
|
| Type | Package |
|
||||||
|---|---|
|
|---|---|
|
||||||
| A | The brief, `analysis/manifest.json`, `native_structure.json` and `sources/source.pptx`, `validation/conversion-report.json` when present, exported resources, `*_vector_asset_inventory.json` as an exact-id query surface, `authoring_summary.json` plus the layered IR (manifest bundled for the compiler, never loaded), and for `mirror` the immutable `svg/` plus `inheritance.json` |
|
| A | `standard` / `fidelity`: the brief, `analysis/manifest.json`, `native_structure.json` and `sources/source.pptx`, `validation/conversion-report.json` when present, exported resources, `*_vector_asset_inventory.json` as an exact-id query surface, `authoring_summary.json` plus the layered IR (manifest bundled for the compiler, never loaded). `mirror`: source summaries, asset evidence and the publication receipt; tools consume the complete import workspace |
|
||||||
| B | The summary, cleaned SVG list, inventory query surface, companion specs, notes |
|
| B | The summary, cleaned SVG list, inventory query surface, companion specs, notes |
|
||||||
| C | Image list and notes |
|
| C | Image list and notes |
|
||||||
| D | Direct text, converted outputs, source list, asset inventory, notes |
|
| D | Direct text, converted outputs, source list, asset inventory, notes |
|
||||||
@@ -244,9 +243,9 @@ Before emitting the marker, all of these hold:
|
|||||||
| Mode | Final SVG authority | Structure behavior |
|
| Mode | Final SVG authority | Structure behavior |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `standard` / `fidelity` | Newly authored SVGs from the brief and complete source evidence | Author a compact or broader useful Master/Layout/slot system; never retain identities merely because they exist. Use the compact canonical `<g>` from `preset_shape_svg.py` when one registered preset expresses one object (paint from the brief and spec; add only the registered structural attributes after insertion; geometry or paint changes require a new render); Template_Designer decides any `shape_boolean_svg.py` use under [`native-shape-authoring.md`](../references/native-shape-authoring.md) §6 |
|
| `standard` / `fidelity` | Newly authored SVGs from the brief and complete source evidence | Author a compact or broader useful Master/Layout/slot system; never retain identities merely because they exist. Use the compact canonical `<g>` from `preset_shape_svg.py` when one registered preset expresses one object (paint from the brief and spec; add only the registered structural attributes after insertion; geometry or paint changes require a new render); Template_Designer decides any `shape_boolean_svg.py` use under [`native-shape-authoring.md`](../references/native-shape-authoring.md) §6 |
|
||||||
| `mirror` | Reviewed compact authoring SVG plus inline native JSON and structure facts | Publish source Slides and their reachable chains from the current authored tree; complete inherited context without changing ownership; similar presentation required, code isomorphism not. Redraw/normalize visible SVG, equivalent inheritance, safe metadata, and transport without changing meaning; never synthesize, promote/demote, rename, or re-parent |
|
| `mirror` | Unchanged Type-A importer workspace | Run the [publication chain](../scripts/docs/template-tools.md#mirror-publication); the model completes Design Spec judgment and reports source limitations |
|
||||||
|
|
||||||
For Type A `mirror`, publish with `mirror_template_materialize.py "<import_workspace>" "<authoring_workspace>"` into a workspace with no existing roster. It validates and publishes but never authors visible design, writes the sidecars listed in [`svg-pipeline.md`](../scripts/docs/svg-pipeline.md#mirror_template_materializepy), and does not create the Design Spec — Template_Designer writes it from the brief and the materialized roster before Step 5.
|
For Type A `mirror`, follow [Mirror publication](../scripts/docs/template-tools.md#mirror-publication) into a workspace with no existing roster. The materializer writes the factual Design Spec skeleton; Template_Designer completes its TODOs before registration.
|
||||||
|
|
||||||
**Hard rule — multi-Master package boundary**: more than one Master is valid only when `mirror` preserves a source graph or an authored template intentionally defines distinct reusable design families — never one Master per Layout or equivalent duplicates. Every Master owns at least one emitted Layout and every Layout is selected by at least one prototype.
|
**Hard rule — multi-Master package boundary**: more than one Master is valid only when `mirror` preserves a source graph or an authored template intentionally defines distinct reusable design families — never one Master per Layout or equivalent duplicates. Every Master owns at least one emitted Layout and every Layout is selected by at least one prototype.
|
||||||
|
|
||||||
@@ -259,14 +258,14 @@ Never encode package repair in SVGs. Do not package `native_structure.json` or `
|
|||||||
|
|
||||||
**Sprite-sheet preservation**: PPTX-exported assets are often sprite sheets cropped through nested `<svg viewBox>` wrappers around `<image width="1" height="1">`; that nesting is load-bearing geometry — preserve the exact `viewBox` crop and outer placement, never flatten to one `<image>` with direct geometry. If an asset's pixel aspect differs from its on-page aspect, it is a sprite.
|
**Sprite-sheet preservation**: PPTX-exported assets are often sprite sheets cropped through nested `<svg viewBox>` wrappers around `<image width="1" height="1">`; that nesting is load-bearing geometry — preserve the exact `viewBox` crop and outer placement, never flatten to one `<image>` with direct geometry. If an asset's pixel aspect differs from its on-page aspect, it is a sprite.
|
||||||
|
|
||||||
**Mirror authoring/publication** (A or B): author and publish one SVG per source Slide in `<authoring_workspace>/templates/` — inspect every matching `authoring-svg/` document, redraw where useful, refresh the summary, then run the materializer (never hand-copy the lossless tree or rebuild the graph); what mirror preserves is [`template-designer.md`](../references/template-designer.md) Mirror mode.
|
**Mirror publication (A only)**: execute [Mirror publication](../scripts/docs/template-tools.md#mirror-publication) on unchanged importer output; review the result and complete the Design Spec judgment.
|
||||||
|
|
||||||
| Mirror detail | Rule |
|
| Mirror detail | Rule |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Filenames | `<NNN>_<page_type>.svg` (3-digit source order; type from `pageTypeCandidates` for A, from a convention filename or content for B, else `content`) |
|
| Filenames | `<NNN>_<page_type>.svg` (3-digit source order; type from `pageTypeCandidates`, else `content`) |
|
||||||
| Assets | Route through the common contract — Type A media from `<import_workspace>/images/`, Type B relative hrefs resolved and copied once — into `images/` with `../images/<name>` references and semantic directories for audio/video/payloads, keeping stable source asset identity |
|
| Assets | Route through the common contract — media from `<import_workspace>/images/` copied once into `images/` with `../images/<name>` references and semantic directories for audio/video/payloads, keeping stable source asset identity |
|
||||||
| Decoration vectors | Copy once to `icons/imported/` as `<use data-icon="imported/<name>" data-pptx-asset-role="decoration"/>` (never `templates/icons/`, never inlined by hand) |
|
| Decoration vectors | Copy once to `icons/imported/` as `<use data-icon="imported/<name>" data-pptx-asset-role="decoration"/>` (never `templates/icons/`, never inlined by hand) |
|
||||||
| Spec | Write `<design_spec_path>` per template-designer §1; `replication_mode: mirror` records creation, never a 1:1 downstream sequence |
|
| Spec | Write `<design_spec_path>` per template-designer §1; for mirror, finish the tool-generated skeleton. `replication_mode: mirror` records creation, never a 1:1 downstream sequence |
|
||||||
|
|
||||||
**Expected outputs**: `<design_spec_path>` with package-specific rules only (deck: descriptive Overview, Color Scheme, Signature Elements, factual Page Roster, conditional Typography / Assets / Overrides; layout: structure-owned Signature Elements and Page Roster only) and no restated generic constraints; the roster per template-designer; conventional `{{...}}` placeholder vocabulary with a `placeholders:` frontmatter override when a style legitimately differs (indexed TOC pattern, never one-off families); each SVG carrying the native contract of [`pptx-structure-interface.md`](../references/pptx-structure-interface.md) §2; optional assets under the common routing.
|
**Expected outputs**: `<design_spec_path>` with package-specific rules only (deck: descriptive Overview, Color Scheme, Signature Elements, factual Page Roster, conditional Typography / Assets / Overrides; layout: structure-owned Signature Elements and Page Roster only) and no restated generic constraints; the roster per template-designer; conventional `{{...}}` placeholder vocabulary with a `placeholders:` frontmatter override when a style legitimately differs (indexed TOC pattern, never one-off families); each SVG carrying the native contract of [`pptx-structure-interface.md`](../references/pptx-structure-interface.md) §2; optional assets under the common routing.
|
||||||
|
|
||||||
@@ -301,6 +300,10 @@ Checker behavior in template mode: [`template-tools.md`](../scripts/docs/templat
|
|||||||
|
|
||||||
This step is a **hard gate**: no review PPTX, registration, staged install, or handoff until it passes. After a staged project install, rerun the checker on the final `<target_project>/templates/`. A one-Master template may skip Step 6 when no review was requested; a multi-Master template must pass Step 6 before registration or completion.
|
This step is a **hard gate**: no review PPTX, registration, staged install, or handoff until it passes. After a staged project install, rerun the checker on the final `<target_project>/templates/`. A one-Master template may skip Step 6 when no review was requested; a multi-Master template must pass Step 6 before registration or completion.
|
||||||
|
|
||||||
|
### Step 5.1: Prototype Review Round
|
||||||
|
|
||||||
|
Layout/Deck only; Brand and Style skip it. Step 3 confirmed scope from a brief, not from the new design, so show the validated prototypes before they become durable: list the roster with one line per prototype (basename, role, slots) and point to the files or the Step 6 review PPTX, then wait for the user's reaction. Feedback revises prototypes or the spec inside the confirmed brief; a change to scope, kind, canvas, or replication intent returns to Step 2; `mirror` prototypes are hash-preserved, so feedback lands only in the spec or returns to Step 2. After any revision rerun Step 5 (and Step 6 when triggered) before Step 7. Under explicit delegation, perform the review yourself — the roster line, the advisory capacity report, every prototype's slot carriers (baseline inside the frame, a font stack with Latin and EA faces), and roster ↔ §V agreement — then record `Prototype review: delegated` with the items checked and any revision in the completion summary.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Step 6: Template Review PPTX and Multi-Master Package Gate
|
## Step 6: Template Review PPTX and Multi-Master Package Gate
|
||||||
@@ -309,7 +312,7 @@ This step is a **hard gate**: no review PPTX, registration, staged install, or h
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
python3 skills/ppt-master/scripts/template_preview_pptx.py "<authoring_workspace>" # exports/<template_id>_template_preview.pptx
|
python3 skills/ppt-master/scripts/template_preview_pptx.py "<authoring_workspace>" # exports/<template_id>_template_preview.pptx
|
||||||
python3 skills/ppt-master/scripts/template_preview_pptx.py "<authoring_workspace>" --native-charts-and-tables -o "<authoring_workspace>/exports/<template_id>_template_preview_native.pptx" # optional JSON-first check
|
python3 skills/ppt-master/scripts/template_preview_pptx.py "<authoring_workspace>" --native-charts-and-tables -o "<authoring_workspace>/exports/<template_id>_template_preview_native.pptx" # explicit native review; typed chart/table slots already compile natively in the default run
|
||||||
python3 skills/ppt-master/scripts/template_preview_pptx.py "<authoring_workspace>" --force # intentional replacement after a fix
|
python3 skills/ppt-master/scripts/template_preview_pptx.py "<authoring_workspace>" --force # intentional replacement after a fix
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -333,7 +336,7 @@ A multi-Master failure blocks registration and completion; an unrequested one-Ma
|
|||||||
|
|
||||||
| Scope | Action |
|
| Scope | Action |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `library` | After Step 5 (and Step 6 when requested or required), run `python3 skills/ppt-master/scripts/register_template.py <template_id> --kind brand|style|deck|layout`; it derives the entry from the spec frontmatter (preferred) or prose plus the actual `templates/*.svg` roster and updates that kind's `*_index.json` — the complete discovery source for Default Stage-1 controls and chat listing (neither scans directories) |
|
| `library` | After Step 5.1 (and Step 6 when requested or required), run `python3 skills/ppt-master/scripts/register_template.py <template_id> --kind brand|style|deck|layout`; it derives the entry from the spec frontmatter (preferred) or prose plus the actual `templates/*.svg` roster and updates that kind's `*_index.json` — the complete discovery source for Default Stage-1 controls and chat listing (neither scans directories) |
|
||||||
| `project` | Skip the registrar, edit no index or README, and report `Not registered (project workspace)` |
|
| `project` | Skip the registrar, edit no index or README, and report `Not registered (project workspace)` |
|
||||||
|
|
||||||
An exact unregistered root supplied by the user or handed off by this route appears as an `explicit` candidate preselected only when it is the sole root; a root matching a registered canonical root may display as `library`; bare names are never resolved. Frontmatter examples per kind live in the child workflows; `--rebuild-all` rebuilds a kind's index after editing many specs.
|
An exact unregistered root supplied by the user or handed off by this route appears as an `explicit` candidate preselected only when it is the sole root; a root matching a registered canonical root may display as `library`; bare names are never resolved. Frontmatter examples per kind live in the child workflows; `--rebuild-all` rebuilds a kind's index after editing many specs.
|
||||||
@@ -354,6 +357,7 @@ An exact unregistered root supplied by the user or handed off by this route appe
|
|||||||
**Bitmap Path**: `<template_workspace>/images/` ← omit when nothing was written or adopted
|
**Bitmap Path**: `<template_workspace>/images/` ← omit when nothing was written or adopted
|
||||||
**Imported Vector Path**: `<template_workspace>/icons/imported/` ← omit when nothing was written or adopted
|
**Imported Vector Path**: `<template_workspace>/icons/imported/` ← omit when nothing was written or adopted
|
||||||
**Review PPTX**: `<template_workspace>/exports/<template_id>_template_preview.pptx` ← Layout/Deck only; omit when an optional one-Master review was not requested
|
**Review PPTX**: `<template_workspace>/exports/<template_id>_template_preview.pptx` ← Layout/Deck only; omit when an optional one-Master review was not requested
|
||||||
|
**Prototype Review**: user-reviewed | delegated ← Layout/Deck only
|
||||||
**Primary Color**: <hex> ← Brand/Deck only
|
**Primary Color**: <hex> ← Brand/Deck only
|
||||||
**Index Registration**: Done | Not registered (project workspace)
|
**Index Registration**: Done | Not registered (project workspace)
|
||||||
|
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ python3 ${SKILL_DIR}/scripts/project_manager.py import-sources <project_path> <s
|
|||||||
|
|
||||||
### Step 3: Template Candidate Preparation
|
### Step 3: Template Candidate Preparation
|
||||||
|
|
||||||
Internal preparation for every Default run — no page, question, receipt, selection, template read, or installation. Quick skips this Step.
|
Internal preparation for every Default run — no page, question, receipt, selection, or installation; read only the frontmatter an exact root needs for kind and canvas. Quick skips this Step.
|
||||||
|
|
||||||
Candidates follow [`routing.md`](./routing.md) §7: registered roots from the four indexes plus every exact root supplied for this run. Raw PPTX is source material, not a candidate — raw PPTX plus new content is [`edit-native-pptx`](./edit-native-pptx.md), and a reusable workspace comes from [`create-template`](./create-template.md).
|
Candidates follow [`routing.md`](./routing.md) §7: registered roots from the four indexes plus every exact root supplied for this run. Raw PPTX is source material, not a candidate — raw PPTX plus new content is [`edit-native-pptx`](./edit-native-pptx.md), and a reusable workspace comes from [`create-template`](./create-template.md).
|
||||||
|
|
||||||
@@ -79,20 +79,20 @@ Resolve the surface under [`confirm-surface.md`](../references/confirm-surface.m
|
|||||||
|
|
||||||
| Branch | Preparation |
|
| Branch | Preparation |
|
||||||
|---|---|
|
|---|---|
|
||||||
| UI | Run `--reset-template-selection`, then write `<project_path>/confirm_ui/template_options.json` with `schema_version: 1`, `phase: "template"`, the UI `lang`, all supplied roots as absolute `explicit_workspace_roots` (empty array when none), and `default_mode` — `templates` for explicit template intent or any supplied root, otherwise `free_design`. Do not launch yet; the server reads the indexes itself. |
|
| UI | Embed `template_options` in `recommendations.stage1.json`: `schema_version: 1`, `phase: "template"`, UI `lang`, all supplied roots as absolute `explicit_workspace_roots` (empty array when none), and `default_mode` — `templates` for explicit intent or supplied roots, otherwise `free_design`. Do not launch yet; the server reads indexes. |
|
||||||
| Chat / delegated | Retain the same candidate boundary in context and create no UI artifact. |
|
| Chat / delegated | Retain the same candidate boundary in context and create no UI artifact. |
|
||||||
|
|
||||||
Stage 1 initializes from `default_mode` but the user may switch. Template mode requires at least one selection; exactly one supplied root may be preselected, several remain unselected.
|
Stage 1 initializes from `default_mode` but the user may switch. Template mode requires at least one selection; exactly one supplied root may be preselected, several remain unselected.
|
||||||
|
|
||||||
**✅ Checkpoint** — candidates ready; nothing selected, read, validated, or installed. Proceed to Step 4 without a user-visible stop.
|
**✅ Checkpoint** — candidates ready; nothing selected, validated, or installed. Proceed to Step 4 without a user-visible stop.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Step 4: Strategist Phase (MANDATORY in the default pipeline)
|
### Step 4: Strategist Phase (MANDATORY in the default pipeline)
|
||||||
|
|
||||||
🚧 **GATE**: Steps 1–3 complete; no template content in planning context; Stage 1 not started.
|
🚧 **GATE**: Steps 1–3 complete; Stage 1 not started.
|
||||||
|
|
||||||
**Hard rule — Stage 1 is template-independent**: author every Stage-1 recommendation from the user's request, source facts, conversation constraints, and project-initialization state only; candidate paths, index summaries, template specs/prototypes/assets, and template canvas are not evidence. Template inspection begins only after Stage 1 confirms both the communication contract and the template/free-design choice and any selection is installed.
|
**Hard rule — a template never rewrites the goal**: audience, intent, outcome, core message, delivery context, and afterlife come from the user's request, source facts, conversation constraints, and project-initialization state; a candidate's existence, summary, or prototypes never bend them. Template facts already in context — a root handed off by Create Template in this conversation, an exact root's kind and canvas — may inform the recommended canvas and page range, and a canvas that differs from project initialization is a visible Stage-1 decision, never a silent Stage-2 override. Prototype-level planning begins only after Stage 1 confirms both the communication contract and the template/free-design choice and any selection is installed.
|
||||||
|
|
||||||
Load the planning core in one batch, plus the structured facts already in `<project_path>/analysis/`:
|
Load the planning core in one batch, plus the structured facts already in `<project_path>/analysis/`:
|
||||||
|
|
||||||
@@ -129,7 +129,7 @@ This is a capability map, not a usage checklist; direction construction follows
|
|||||||
|
|
||||||
**Only the user confirms**: the agent authors recommendations, operates the server, reads state, and applies a template. It never confirms on the user's behalf, automates submission, synthesizes a payload, or writes user result state; silence confirms nothing. Under explicit delegation the agent makes the Stage-1 decision, installs it, derives Stage 2, and presents one complete summary without fabricating UI receipts.
|
**Only the user confirms**: the agent authors recommendations, operates the server, reads state, and applies a template. It never confirms on the user's behalf, automates submission, synthesizes a payload, or writes user result state; silence confirms nothing. Under explicit delegation the agent makes the Stage-1 decision, installs it, derives Stage 2, and presents one complete summary without fabricating UI receipts.
|
||||||
|
|
||||||
**UI branch** — `template_options.json` (Step 3), `recommendations.stage1.json`, `template_handoff.json` (written only by `--complete-template-selection`), and `recommendations.stage2.json` are agent inputs; `template_selection.json` and `result.json` are user receipts. Only the active unconfirmed stage file may be overwritten, in place, never with a revision suffix or another stage's payload. Author Stage 1 without reading candidates, launch, post the [`confirm-surface.md`](../references/confirm-surface.md) handoff summary, then wait:
|
**UI branch** — `recommendations.stage1.json` (with `template_options`) and `recommendations.stage2.json` are agent inputs; server-written `template_selection.json` and `result.json` are user receipts, and installer-written `template_install.json` proves installed content. Only the active unconfirmed stage file may be overwritten, in place, never with a revision suffix or another stage's payload. Author Stage 1 without opening candidate specs or prototypes, launch, post the [`confirm-surface.md`](../references/confirm-surface.md) handoff summary, then wait:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python3 ${SKILL_DIR}/scripts/confirm_ui/server.py <project_path> --daemon
|
python3 ${SKILL_DIR}/scripts/confirm_ui/server.py <project_path> --daemon
|
||||||
@@ -138,11 +138,8 @@ python3 ${SKILL_DIR}/scripts/confirm_ui/server.py <project_path> --wait-only --w
|
|||||||
|
|
||||||
**Hard rule — Stage 1 is intermediate**: exit `0` here means continue, not finish — no final reply, no idling. Read `result.json` and `template_selection.json` exactly once (a confirmed contract plus either `free_design` with no roots or `templates` with ≥1 server-resolved root), then in the same run:
|
**Hard rule — Stage 1 is intermediate**: exit `0` here means continue, not finish — no final reply, no idling. Read `result.json` and `template_selection.json` exactly once (a confirmed contract plus either `free_design` with no roots or `templates` with ≥1 server-resolved root), then in the same run:
|
||||||
|
|
||||||
1. For `templates`, run [`apply-template-workspace.md`](./stages/apply-template-workspace.md) against every confirmed root (each installs as `templates/design_spec.<kind>.<id>.md` plus real `images/` and `icons/`); for `free_design` skip it. Then bind the state — agent-only, never hand-authored:
|
1. For `templates`, run [`apply-template-workspace.md`](./stages/apply-template-workspace.md) against every confirmed root (each installs as `templates/design_spec.<kind>.<id>.md` plus real `images/` and `icons/`); for `free_design` skip it. The server verifies selection and the installer receipt directly before Stage 2.
|
||||||
```bash
|
2. Only now inspect installed template state (apply `strategist-template.md` when active), load the planning-capability block, author the three solutions, freeze and read their exact bases, derive the production defaults, and create `recommendations.stage2.json` (`stage: "stage2"`, `selection_sha256` copied from the server-written selection) without changing Stage 1. Wait:
|
||||||
python3 ${SKILL_DIR}/scripts/confirm_ui/server.py <project_path> --complete-template-selection
|
|
||||||
```
|
|
||||||
2. Only now inspect installed template state (apply `strategist-template.md` when active), load the planning-capability block, author the three solutions, freeze and read their exact bases, derive the production defaults, and create `recommendations.stage2.json` (`stage: "stage2"`) without changing Stage 1. Wait:
|
|
||||||
```bash
|
```bash
|
||||||
python3 ${SKILL_DIR}/scripts/confirm_ui/server.py <project_path> --wait-only
|
python3 ${SKILL_DIR}/scripts/confirm_ui/server.py <project_path> --wait-only
|
||||||
```
|
```
|
||||||
@@ -154,7 +151,7 @@ python3 ${SKILL_DIR}/scripts/confirm_ui/server.py <project_path> --wait-only --w
|
|||||||
|
|
||||||
If the user selects chat after launch, apply `confirm-surface.md`'s in-run switch and finish every remaining stage in chat without relaunching.
|
If the user selects chat after launch, apply `confirm-surface.md`'s in-run switch and finish every remaining stage in chat without relaunching.
|
||||||
|
|
||||||
**Chat branch** — present the template mode and Stage-1 contract together and wait for one explicit response (registered candidates shown only when the user chooses `templates`; free design for an ordinary request, template mode for explicit intent or any supplied root, one root preselectable). Create no UI receipts and do not call `--complete-template-selection`. After confirmation, install or fuse selected roots (or close free design), retain that state as the Stage-2 gate, run final Stage 2 in chat, and keep one visible cumulative summary as the final state.
|
**Chat branch** — present the template mode and Stage-1 contract together and wait for one explicit response (registered candidates shown only when the user chooses `templates`; free design for an ordinary request, template mode for explicit intent or any supplied root, one root preselectable). Create no UI receipts; keep the confirmed selection and installed state in the cumulative chat summary. After confirmation, install or fuse selected roots (or close free design), retain that state as the Stage-2 gate, run final Stage 2 in chat, and keep one visible cumulative summary as the final state.
|
||||||
|
|
||||||
⛔ **GATE — final state → Design Spec → conditional review → lock**: consume every present final value once into the complete, audited `design_spec.md` under [`strategist.md`](../references/strategist.md) §6.2, preserving each field's semantic type (acceptance never turns a Reference or Permission into a Literal) and every production, typography, image-source, and `image_notes` obligation; never reopen `result.json`.
|
⛔ **GATE — final state → Design Spec → conditional review → lock**: consume every present final value once into the complete, audited `design_spec.md` under [`strategist.md`](../references/strategist.md) §6.2, preserving each field's semantic type (acceptance never turns a Reference or Permission into a Literal) and every production, typography, image-source, and `image_notes` obligation; never reopen `result.json`.
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -88,7 +88,7 @@ A file taken from another project's tree is copied unless `--move` is explicit;
|
|||||||
|
|
||||||
**Installed templates**: run `apply-template-workspace` against the preflighted roots only; the request is the selection authority, with no receipt or handoff, and every later read uses the installed state. Before P01 read each installed spec once and, for Layout/Deck, every SVG prototype. Apply Brand identity, Style direction, the structure owner's prototype geometry, and Deck context under the stage's §5 segment precedence: an owner's instruction on how a value dominates, recedes, or stays rare binds as strongly as the value, and a Style tendency never demotes a Brand's dominant color.
|
**Installed templates**: run `apply-template-workspace` against the preflighted roots only; the request is the selection authority, with no receipt or handoff, and every later read uses the installed state. Before P01 read each installed spec once and, for Layout/Deck, every SVG prototype. Apply Brand identity, Style direction, the structure owner's prototype geometry, and Deck context under the stage's §5 segment precedence: an owner's instruction on how a value dominates, recedes, or stays rare binds as strongly as the value, and a Style tendency never demotes a Brand's dominant color.
|
||||||
|
|
||||||
**Template Application paragraph**: freeze one in context — explicit user instructions first, otherwise the fit of the content to the complete roster, defaulting to reference-led use (redesign after full-roster study; other readings such as augment-only or replacement-only are examples, not a menu). It names which prototypes may be used, skipped, repeated, reordered, or adapted, what stays fixed, and any exception by exact SVG basename. When a detail is later uncertain, reread the installed SVG.
|
**Template Application paragraph**: freeze one in context — explicit user instructions first, otherwise the fit of the content to the complete roster, defaulting to reference-led use (redesign after full-roster study; other readings such as augment-only or replacement-only are examples, not a menu). It names which prototypes may be used, skipped, repeated, reordered, or adapted, what stays fixed, and any exception by exact SVG basename. When a detail is later uncertain, reread the installed SVG. When the structure owner records `replication_mode: mirror`, say explicitly whether pages reuse the prototypes literally (text values only, topology frozen) or redesign from them — Quick has no `template_reuse_scope`, so this sentence is the only record of that choice.
|
||||||
|
|
||||||
Read the planning-capability batch in one pass — a capability map, not a usage checklist:
|
Read the planning-capability batch in one pass — a capability map, not a usage checklist:
|
||||||
|
|
||||||
@@ -226,7 +226,7 @@ python3 ${SKILL_DIR}/scripts/svg_to_pptx.py <project_path> --quick-generate --wi
|
|||||||
python3 ${SKILL_DIR}/scripts/svg_to_pptx.py <project_path> --quick-generate --no-notes # Speaker Notes disabled
|
python3 ${SKILL_DIR}/scripts/svg_to_pptx.py <project_path> --quick-generate --no-notes # Speaker Notes disabled
|
||||||
```
|
```
|
||||||
|
|
||||||
**Exporter behavior**: `--quick-generate` reads `svg_output/`, resolves project-local assets, infers one canvas and one all-page structure mode (no metadata → flat; complete Master/Layout/slot metadata → structured), and needs no lock. Notes, Custom Animations, and narration stay off unless the agent enabled them or the video rule requires them; append `--native-charts-and-tables` only for an explicit native Chart/Table delivery decision. Never run `finalize_svg.py`. The exporter requires a passing `final` report whose fingerprint matches the current `svg_output/`; the default output path keeps backup and postflight, an explicit `-o <path>.pptx` skips backup.
|
**Exporter behavior**: `--quick-generate` reads `svg_output/`, resolves project-local assets, infers one canvas and one all-page structure mode (no metadata → flat; complete Master/Layout/slot metadata → structured), and needs no lock. Notes, Custom Animations, and narration stay off unless the agent enabled them or the video rule requires them; append `--native-charts-and-tables` for an explicit native Chart/Table delivery decision or any structured `chart` / `table` placeholder slot (the exporter refuses to compile a typed slot as a fallback). Never run `finalize_svg.py`. The exporter requires a passing `final` report whose fingerprint matches the current `svg_output/`; the default output path keeps backup and postflight, an explicit `-o <path>.pptx` skips backup.
|
||||||
|
|
||||||
**On failure**: repair the owning SVG, resource, or capability input, rerun the checker, and export again — never create a Design Spec or lock.
|
**On failure**: repair the owning SVG, resource, or capability input, rerun the checker, and export again — never create a Design Spec or lock.
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -8,7 +8,7 @@ description: Generate-PPTX runbook for validating and installing selected Brand,
|
|||||||
|
|
||||||
## 1. Gate and Normalize Inputs
|
## 1. Gate and Normalize Inputs
|
||||||
|
|
||||||
🚧 **GATE**: Default Stage 1 confirmed a non-free selection, or Quick received exact roots directly from the user/current Create Template handoff — in Quick that input is the complete selection authority (no Confirm UI, no `template_options.json` / `template_selection.json` / `template_handoff.json`). Every selected input resolves to one current contract:
|
🚧 **GATE**: Default Stage 1 confirmed a non-free selection, or Quick received exact roots directly from the user/current Create Template handoff — in Quick that input is the complete selection authority (no Confirm UI recommendations or selection receipt). Every selected input resolves to one current contract:
|
||||||
|
|
||||||
| Input shape | Spec and SVG source | Asset source |
|
| Input shape | Spec and SVG source | Asset source |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
@@ -111,4 +111,4 @@ Each installed file keeps its own frontmatter `kind` and `<id>`; nothing is rela
|
|||||||
- [x] Every kind schema passed preflight; structured SVG checks ran only for Layout/Deck inputs
|
- [x] Every kind schema passed preflight; structured SVG checks ran only for Layout/Deck inputs
|
||||||
- [x] Duplicate kinds and destination collisions were rejected before one atomic install; Layout-over-Deck precedence selected exactly one active roster
|
- [x] Duplicate kinds and destination collisions were rejected before one atomic install; Layout-over-Deck precedence selected exactly one active roster
|
||||||
- [x] `<project_path>/templates/` and portable sibling assets are complete and the only downstream template source
|
- [x] `<project_path>/templates/` and portable sibling assets are complete and the only downstream template source
|
||||||
- [ ] **Next**: Default completes the template-selection handoff and continues [`generate-pptx.md`](../generate-pptx.md) Step 4 Stage 2; Quick returns to [`quick-generate`](../profiles/quick-generate.md) §2
|
- [ ] **Next**: Default continues [`generate-pptx.md`](../generate-pptx.md) Step 4 Stage 2 after the server verifies selection and installation; Quick returns to [`quick-generate`](../profiles/quick-generate.md) §2
|
||||||
Reference in new issue
Block a user