Release standalone PPT template workflow 0.5.0

This commit is contained in:
2026-10-10 21:02:47 +08:00
commit 4e3271ff62
303 changed files with 190946 additions and 0 deletions
@@ -0,0 +1,378 @@
# Advanced Image and Motion Maintenance Smoke
Run this manual smoke from the repository root after changing nested image
crop finalization, native picture export, deterministic Morph pairing, authored
PowerPoint presets, or their planning contracts. It builds one temporary
two-slide project under the gitignored `projects/_smoke_*` namespace and follows
the inline-smoke convention from
[`code-style.md`](../../../../docs/rules/code-style.md) §11; do not turn it into
a test file or public example deck.
The fixture deliberately closes the full planning and execution chain:
- `design_spec.md` carries `Motion suggestion`, `Native shape suggestion`, one
current §VIII image row, and `Crop Policy`;
- `spec_lock.md` projects that row with optional layout pattern `#M1-11`;
- both pages reuse one raster through ordinary, ellipse-preset, and custom-path
independent nested crops;
- `animations.json` pairs the main crop across adjacent Morph pages;
- one helper-authored `rightArrow` verifies native preset discovery and export.
```bash
python3 - <<'PY'
import base64
import io
import json
import re
import subprocess
import sys
import tempfile
import zipfile
from pathlib import Path
from xml.etree import ElementTree as ET
from PIL import Image, ImageDraw
project = Path(
tempfile.mkdtemp(
prefix="_smoke_advanced_image_motion_",
dir="projects",
)
)
scripts = Path("skills/ppt-master/scripts")
images = project / "images"
svg_output = project / "svg_output"
images.mkdir()
svg_output.mkdir()
(project / "README.md").write_text(
"# Advanced image and motion maintenance smoke\n",
encoding="utf-8",
)
def run_tool(script, *args):
result = subprocess.run(
[sys.executable, str(scripts / script), *map(str, args)],
capture_output=True,
text=True,
)
assert result.returncode == 0, result.stderr or result.stdout
return result.stdout.strip()
scene = Image.new("RGB", (1280, 720), "#E2E8F0")
draw = ImageDraw.Draw(scene)
draw.rectangle((0, 0, 420, 720), fill="#2563EB")
draw.rectangle((420, 0, 860, 720), fill="#F97316")
draw.rectangle((860, 0, 1280, 720), fill="#0F172A")
draw.ellipse((460, 150, 820, 510), fill="#F8FAFC")
scene.save(images / "scene.png")
preset = run_tool(
"preset_shape_svg.py",
"render",
"rightArrow",
"--id",
"native-arrow",
"--frame",
"500",
"570",
"280",
"80",
"--fill",
"#2563EB",
"--stroke",
"none",
)
preset = (
'<g id="native-arrow-module" data-pptx-bounds="500 570 280 80">'
+ preset
+ "</g>"
)
(project / "design_spec.md").write_text(
"""<!-- ppt-master-schema: design-spec/v1 -->
# Advanced Image and Motion Smoke - Design Spec
## I. Project Information
| Item | Value |
| --- | --- |
| Project Name | advanced-image-motion-smoke |
| Canvas Format | PPT 16:9 |
| Page Count | 2 |
## II. Canvas Specification
- ViewBox: `0 0 1280 720`
- Format: `ppt169`
## III. Visual Theme
- Direction: restrained technical maintenance fixture
- Colors: blue, orange, slate, and white
## IV. Typography System
- Title: Arial, sans-serif at 36px
- Body: Arial, sans-serif at 20px
## V. Layout Principles
- Reuse one source image through independently editable crop objects.
- Keep every visible crop inside the slide canvas.
## VI. Icon Usage Specification
- No icons.
## VIII. Image Resource List
| Filename | Dimensions | Ratio | Purpose | Type | Layout pattern | Crop Policy | Acquire Via | Status | Reference | text_policy | page_role |
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
| scene.png | 1280x720 | 16:9 | Morph crop continuity | Photo | #M1-11 same-source independent crops with a shaped detail | adaptive | user | Existing | Synthetic three-band scene for crop and Morph verification | none | local |
## IX. Content Outline
### Part 1: Crop continuity
#### Slide 01 - Overview
- **Audience move**: See separate source views as one editable image system.
- **Layout**: Ordinary crop, shaped crop, and one native directional preset.
- **Title**: Overview crop state
- **Core message**: One source can support independent native picture objects.
- **Content**: Show the wide crop and shaped detail together.
- **Images**: Use `scene.png` for both visible crop objects.
- **Native shape suggestion**: Use the PowerPoint `rightArrow` preset to indicate continuation.
- **Motion suggestion**: Continue the primary crop into Slide 02 as one deterministic Morph object.
#### Slide 02 - Detail
- **Audience move**: Recognize the same image after a controlled crop and position change.
- **Layout**: Enlarged primary crop plus a second shaped detail.
- **Title**: Detail crop state
- **Core message**: Morph identity is independent from the picture crop geometry.
- **Content**: Move and zoom the primary crop while preserving the shared source.
- **Images**: Reuse `scene.png`; do not generate or replace the source.
- **Motion suggestion**: Pair the primary crop with Slide 01 and use Morph by object.
## X. Speaker Notes Requirements
- Notes are disabled for this maintenance smoke.
""",
encoding="utf-8",
)
(project / "spec_lock.md").write_text(
"""<!-- ppt-master-schema: spec-lock/v1 -->
# Execution Lock
## canvas
- viewBox: 0 0 1280 720
- format: ppt169
## communication
- audience: PPT Master maintainers
- objective: Verify advanced image and motion contracts end to end.
- core_message: One raster remains editable across native crops and Morph.
## mode
- mode: showcase
## visual_style
- visual_style: swiss-minimal
## colors
- bg: #FFFFFF
- primary: #2563EB
- accent: #F97316
- text: #0F172A
## typography
- font_family: Arial, sans-serif
- title_family: Arial, sans-serif
- body_family: Arial, sans-serif
- title: 36
- body: 20
## icons
- library: none
- inventory: none
## images
- scene: images/scene.png | source=user | pattern=#M1-11 same-source independent crops with a shaped detail | crop=adaptive
## page_rhythm
- P01: dense
- P02: dense
## pptx_structure
- mode: flat
## forbidden
- Unsupported SVG constructs
""",
encoding="utf-8",
)
slide_1 = f"""<svg xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 1280 720" data-pptx-page-role="content">
<defs>
<clipPath id="overview-shape" clipPathUnits="userSpaceOnUse">
<ellipse cx="0.725" cy="0.5" rx="0.275" ry="0.5"/>
</clipPath>
</defs>
<rect id="background" data-pptx-role="background"
x="0" y="0" width="1280" height="720" fill="#FFFFFF"/>
<g id="hero-overview" data-pptx-bounds="80 140 448 360">
<svg id="overview-crop" x="80" y="140" width="448" height="360"
viewBox="0 0 0.7 1" preserveAspectRatio="none" overflow="hidden">
<image href="../images/scene.png" x="0" y="0" width="1" height="1"
preserveAspectRatio="none"/>
</svg>
</g>
<g id="shaped-overview" data-pptx-bounds="760 170 330 340">
<svg id="overview-shaped-crop" data-pptx-crop="1"
x="760" y="170" width="330" height="340"
viewBox="0.45 0 0.55 1" preserveAspectRatio="none" overflow="hidden">
<image href="../images/scene.png" x="0" y="0" width="1" height="1"
preserveAspectRatio="none" clip-path="url(#overview-shape)"/>
</svg>
</g>
{preset}
</svg>
"""
slide_2 = """<svg xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 1280 720" data-pptx-page-role="content">
<defs>
<clipPath id="detail-shape" clipPathUnits="userSpaceOnUse">
<path d="M 0.5 1 L 0.75 0 L 1 1 Z"/>
</clipPath>
</defs>
<rect id="background" data-pptx-role="background"
x="0" y="0" width="1280" height="720" fill="#FFFFFF"/>
<g id="hero-detail" data-pptx-bounds="0 0 1280 720">
<svg id="detail-crop" x="0" y="0" width="1280" height="720"
viewBox="0.15 0.1 0.36 0.36"
preserveAspectRatio="none" overflow="hidden">
<image href="../images/scene.png" x="0" y="0" width="1" height="1"
preserveAspectRatio="none"/>
</svg>
</g>
<g id="shaped-detail" data-pptx-bounds="820 160 320 360">
<svg id="detail-shaped-crop" data-pptx-crop="1"
x="820" y="160" width="320" height="360"
viewBox="0.5 0 0.5 1" preserveAspectRatio="none" overflow="hidden">
<image href="../images/scene.png" x="0" y="0" width="1" height="1"
preserveAspectRatio="none" clip-path="url(#detail-shape)"/>
</svg>
</g>
</svg>
"""
(svg_output / "01_overview.svg").write_text(slide_1, encoding="utf-8")
(svg_output / "02_detail.svg").write_text(slide_2, encoding="utf-8")
(project / "animations.json").write_text(
json.dumps(
{
"version": 1,
"slides": {
"02_detail": {
"transition": {
"effect": "morph",
"effect_options": {"morph_by": "object"},
"duration": 0.8,
},
"morph": {
"from": "01_overview",
"pairs": {
"hero-image": {
"from": "hero-overview",
"to": "hero-detail",
}
},
},
}
},
},
ensure_ascii=False,
indent=2,
)
+ "\n",
encoding="utf-8",
)
run_tool("project_manager.py", "validate", project)
run_tool("svg_quality_checker.py", project, "--format", "ppt169")
run_tool("animation_config.py", "validate", project)
def finalized_image_sizes():
sizes = []
for svg_path in sorted((project / "svg_final").glob("*.svg")):
root = ET.parse(svg_path).getroot()
for image in root.iter("{http://www.w3.org/2000/svg}image"):
href = image.get("href") or image.get(
"{http://www.w3.org/1999/xlink}href"
)
assert href and href.startswith("data:image/"), href
payload = base64.b64decode(href.split(",", 1)[1], validate=True)
with Image.open(io.BytesIO(payload)) as embedded:
sizes.append(embedded.size)
return sizes
finalized_sizes = []
for finalize_args in ((), ("--no-compress",)):
run_tool("finalize_svg.py", project, *finalize_args)
embedded_sizes = finalized_image_sizes()
assert len(embedded_sizes) == 4, embedded_sizes
assert all(width > 100 and height > 100 for width, height in embedded_sizes)
finalized_sizes.append(embedded_sizes)
pptx = project / "advanced-image-motion.pptx"
run_tool("svg_to_pptx.py", project, "--no-notes", "-o", pptx)
with zipfile.ZipFile(pptx) as archive:
slide_names = sorted(
name
for name in archive.namelist()
if re.fullmatch(r"ppt/slides/slide[12]\.xml", name)
)
slide_xml = [archive.read(name) for name in slide_names]
assert len(slide_xml) == 2, slide_names
assert all(b'!!hero-image' in xml for xml in slide_xml)
assert b'morph' in slide_xml[1]
assert sum(xml.count(b"<a:srcRect") for xml in slide_xml) >= 4
assert b'<a:prstGeom prst="ellipse">' in slide_xml[0]
assert b'<a:prstGeom prst="rightArrow">' in slide_xml[0]
assert b"<a:custGeom>" in slide_xml[1]
readback = project / "readback"
run_tool(
"pptx_to_svg.py",
pptx,
"-o",
readback,
"--inheritance-mode",
"flat",
"--strict",
)
readback_svg = "\n".join(
path.read_text(encoding="utf-8")
for path in sorted((readback / "svg").glob("slide_*.svg"))
)
assert readback_svg.count('data-pptx-crop="1"') >= 2
assert readback_svg.count('data-pptx-shape-name="!!hero-image"') == 2
print(
"Advanced image and motion smoke: passed "
f"(embedded sizes={finalized_sizes}; project={project})"
)
PY
```
Every command must exit successfully. The finalized data URIs must retain
useful source pixels rather than collapsing to the nested child’s unit
coordinates; the package must contain four native picture crops, the authored
`rightArrow`, one Morph transition, and exactly one `!!hero-image` target on
each adjacent slide. The strict PPTX-to-SVG readback must restore both shaped
crop markers and both forced-Morph object names.
The printed project is intentionally retained for manual Microsoft PowerPoint
inspection. Morph must continue the primary picture without a visible
cross-fade, sharpness jump, or media re-decoding flash. That Office-only visual
check is evidence gathering; to stress differing media payloads, replace
`scene.png` with a `7680×4320` source and re-export. Do not trigger media
deduplication changes from package structure alone.
@@ -0,0 +1,26 @@
# Legacy Chart Recall Compatibility
`chart_recall.py` preserves the historical broad visualization-recall CLI for
existing projects and external callers. It delegates to
`visualization_recall.py`, reads the same live Chart/Table registries, and emits
the legacy bare-key JSON shape.
```bash
python3 skills/ppt-master/scripts/chart_recall.py recall \
--page P03 \
--tag "time series" \
--tag "three metrics" \
--tag "direction over time"
python3 skills/ppt-master/scripts/chart_recall.py validate process_flow
```
The validation example intentionally uses the legacy bare Structure intent `process_flow`;
the shared resolver recognizes it as a Structure intent without an SVG path.
The wrapper retains the original string in its historical `valid` list and adds
a `resolved` item with `family`, `key`, and `kind`; a Structure intent omits
`reference` and `path`. New planning describes qualitative relationships in §IX
or Quick active context; it does not write a Structure catalog key.
Do not use this wrapper in new planning prompts. See
[`visualization-recall.md`](./visualization-recall.md) for the canonical
`family/key` workflow and `page_visualizations` contract.
@@ -0,0 +1,599 @@
# Confirm UI — Strategist and Template Confirmation Page
> The interactive surface for [`generate-pptx`](../../workflows/generate-pptx.md)
> Step 4. Stage 1 shows the template-independent communication recommendation
> and the template/free-design choice on one page and confirms both with one
> submission. That submission writes the Strategist contract to `result.json`
> and the selection sidecar to `template_selection.json`. The agent then installs
> any selected workspace and writes `template_handoff.json`; only afterward does
> final Stage 2 read installed template state and confirm the coordinated deck
> solution plus production mechanics. The chat path mirrors these boundaries
> without fabricating UI receipts.
## Authority and Scope
| Concern | Owner |
|---|---|
| Stage-1 combined confirmation and post-confirmation installation order | [`generate-pptx.md`](../../workflows/generate-pptx.md) |
| Template option/selection schema and page transport | This document |
| Step 4 gate and pipeline order | [`generate-pptx.md`](../../workflows/generate-pptx.md) |
| Confirm UI schema | This document |
| Stage 1 / final Stage 2 field membership | This document |
| Server launch / wait / shutdown behavior | This document |
| Port and lock behavior | This document |
| Chat fallback equivalence | This document |
| Confirmed-value precedence | [`generate-pptx.md`](../../workflows/generate-pptx.md) plus this document's `result.json` contract |
**Hard rule**: Keep detailed Confirm UI behavior here. The Generate route may summarize orchestration, but it should not duplicate the full JSON schema, catalog behavior, or launcher lifecycle.
**Mandatory surface decision — before any UI command**: Resolve the most recent
explicit confirmation-surface instruction for this run before running
`--daemon` or `--wait-only`. Unrelated later messages do not reset the selected
branch. A new explicit selection may change it before launch; once confirmation
starts in chat or UI switches to chat, keep chat for the rest of this run.
| Most recent explicit surface instruction | Branch |
|---|---|
| The user explicitly delegates confirmation | Make the combined Stage-1 communication/template decision, install it, then present one complete final summary. Do not launch the page or fabricate UI receipts. |
| Otherwise, the user asks for or agrees to personally confirm in chat, or declines the confirmation page | Use chat for both Strategist stages; Stage 1 includes the template/free-design choice. Do not launch the page, run `--wait-only`, or require UI-authored receipts. |
| No explicit confirmation-surface instruction exists for this run | Use the page as the default. |
Interpret the instruction semantically: “confirm here”, “use the chat window”, or
“do not open the confirmation page” are sufficient; no literal `chat-only`
keyword is required. Invoking a chat-question tool by itself does not select the
chat branch—the user's instruction does. Both branches preserve the same
Stage-1 communication/template decision, installation handoff, and template-aware
final Stage 2.
**Chat/delegated Stage-1 listing**: Author the communication recommendation
before reading the four indexes, then present that recommendation together with
an explicit free-design/template-mode choice. Only template mode expands the
registered candidates and supplied exact roots, and it requires at least one
selection. Ordinary requests initialize free design; explicit template intent
or any supplied exact root initializes template mode. Exactly one supplied root
may also seed that candidate, while multiple roots remain unselected. Under
explicit delegation, make the same decision, install
it, and only then derive Stage 2. Do not launch the page or fabricate receipts.
**Fallback rule**: When no surface was selected before launch, the page is the
default. Use chat when the user answers either always-on handoff in chat, or
after launch failure/timeout and one re-check of `result.json` plus the
Stage-1-sidecar `template_selection.json` when Stage 1 is active. A chat-question
tool alone is not a launch failure. Preserve the combined Stage-1 decision and
keep communication prompts open-ended.
**In-run UI → chat switch — any phase or stage**: If the user explicitly selects chat
after the UI server has launched—while `--wait-only` is active, before that wait
starts, or after it times out while the server remains live:
1. If a wait is active, interrupt it and confirm that its process has exited.
Only the return code from this deliberate wait interruption is expected to
be non-zero.
2. Run `server.py <project_path> --shutdown` and require that cleanup to
succeed. The browser tab may remain open, but its stopped server makes it
inactive.
3. Re-check the active receipt once. During Stage 1, require both `result.json`
and `template_selection.json` from the same submission; during Stage 2, check
`result.json`. Retain only values persisted before shutdown; an unsubmitted
browser draft is not confirmed.
4. Continue the unresolved current phase/stage and everything remaining in chat. Do
not call `--wait-only` again, recover the server, or relaunch the page during
this run.
**Always-on Stage-1 chat handoff**: After writing `template_options.json` and
template-independent `recommendations.stage1.json`, launch the healthy daemon
without `--wait`. Immediately post its actual URL plus one compact localized
summary of the current communication recommendation and template choice state:
audience, communication intent, audience outcome, core message, delivery
context, artifact afterlife, `content_divergence`, canvas, and whether the
default is free design or template mode, including any sole preselected root. Explain
that template mode expands the registered-kind and supplied-root selectors.
Show a blank prose value as “not specified” without changing it. End with an
explicit localized line saying that, if the page did not open, the user may
confirm or revise the same communication and template choices in chat. Only
then run `--wait-only --wait-stage stage1`. A chat
reply to that handoff applies the in-run switch above without waiting for
timeout. The handoff is context, not confirmation, and silence confirms
nothing. After launch failure/timeout and the required result re-check, present
the same combined Stage-1 items as open chat questions and wait explicitly.
## `confirm_ui/server.py`
The following launch and wait commands belong to the **UI branch only**:
```bash
python3 scripts/confirm_ui/server.py <project_path> --daemon # 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> --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> --daemon --port 5051
python3 scripts/confirm_ui/server.py <project_path> --no-browser
python3 scripts/confirm_ui/server.py <project_path> --timeout 0 # disable idle auto-shutdown
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)
```
- Without `--port`, binds the first free port from `127.0.0.1:5050`; the launch log prints the actual URL. `--port N` is exact and fails when unavailable. Auto-open is suppressed by `--no-browser`.
- In `--daemon` mode the launcher starts the child with browser opening suppressed, then accepts readiness only when `GET /api/health` identifies this confirm service, project, and child process. It opens the printed `http://127.0.0.1:<port>` URL only after that check.
- Confirm UI and live preview prefer the same memorable base port but keep separate processes and project-local locks (`.confirm_ui.lock` vs `live_preview/lock.json`). Normal Step 4 cleanup releases the confirm port before Step 6; concurrent projects may use different ports.
- `--daemon` starts the Flask process in the background and returns after the health check. 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.
- `--complete-template-selection` is agent-only. It validates the Stage-1 sidecar and writes the bound `template_handoff.json`; template mode additionally requires project-local `templates/design_spec.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.
- `--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.
- 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.
- Stage-1 `GET /api/recommendations` embeds the server-built candidate catalog
as top-level `template_options`. Its `/api/confirm` submission validates
current candidate keys and writes `template_selection.json` beside the pure
Strategist `result.json`; there is no independent template-options endpoint,
template-confirm endpoint, or template wait stage. The same APIs later serve
Stage 2 and strip legacy `template_reuse_scope` / `template_adherence`
fields. The completed template handoff is authoritative: `free_design`
strips a stray `template_application`, while `templates` exposes that
editable natural-language field in Stage 2.
Dependency:
```bash
pip install flask
```
## Stage-1 template-selection sidecar contract
Template selection shares the Stage-1 page and submit action but remains a
separate artifact from the Strategist contract. Its files live under
`<project_path>/confirm_ui/`; selection keys never enter `result.json`.
### Input — `template_options.json` (created before launch)
```json
{
"schema_version": 1,
"phase": "template",
"lang": "zh",
"default_mode": "free_design",
"explicit_workspace_roots": [
"/absolute/path/to/a/project-or-template-workspace"
]
}
```
- `schema_version` is exactly `1`; `phase` is exactly `template`.
- `lang` is optional, but when present it is a non-empty UI-language string.
- `default_mode` is required and is exactly `free_design` or `templates`.
Ordinary requests use `free_design`; explicit template intent or any supplied
exact root uses `templates`. It initializes the UI but never locks the user.
- `explicit_workspace_roots` is required even when empty. Every item is a
unique absolute path resolving to an existing directory with
`templates/design_spec.md` or compatible legacy `design_spec.md`.
- The array supplies candidates for the one specified-root dropdown; it does
not authorize selecting several explicit roots in one confirmation.
- Do not write library entries into this file. The server reads only
`templates/brands/brands_index.json`,
`templates/styles/styles_index.json`,
`templates/layouts/layouts_index.json`, and
`templates/decks/decks_index.json`, derives each direct-child workspace root,
and validates that it exists with `templates/design_spec.md`. It never scans
kind directories.
Stage-1 `GET /api/recommendations` embeds this browser catalog as top-level
`template_options`:
```json
{
"schema_version": 1,
"phase": "template",
"lang": "zh",
"default_mode": "free_design",
"library": {
"brand": [],
"style": [],
"layout": [],
"deck": []
},
"explicit": [],
"preselected_keys": [],
"options_sha256": "<64 lowercase hex characters>"
}
```
A library candidate has `key`, `source: "library"`, `kind`, `id`, `label`,
`summary`, and canonical absolute `workspace_root`. An unregistered explicit
candidate has `key`, `source: "explicit"`, parsed `kind`, `label`, and
canonical absolute `workspace_root`. If a supplied root exactly equals a registered canonical
root, the server reuses the library candidate/key instead of duplicating it as
explicit. Candidate keys are server-owned. The Stage-1 submit payload carries
the current `{ "mode": "free_design"|"templates", "selection_keys": [...] }`
beside the Strategist fields; the server validates that selection and writes it
to the sidecar rather than copying keys into `result.json`.
When the input supplies exactly one root, `preselected_keys` contains its
resolved candidate key as a convenience default, including when exact equality
reclassifies it as library. When several roots are supplied, all remain
candidates but none is preselected; one specified-root dropdown cannot encode
an instruction to use all of them.
**Page selection model**: Stage 1 first asks the user to choose `Free design` or
`Use templates`, initialized from `default_mode` but always switchable. Exactly
one supplied root may initialize its candidate as an editable convenience
default; multiple roots remain unselected. Only `Use templates` expands the
candidate controls: Brand, Style, Layout, and Deck each have one registered
single-select dropdown, and Specified has one explicit-root single-select
dropdown. Every dropdown includes `None`; template mode cannot submit until at
least one is non-empty. Free design clears all dropdowns. Registered kinds may
be combined, but each contributes at most one root and the specified channel at
most one. Because an explicit candidate carries its parsed kind, it may coexist
with one registered root of that kind and enter the two-workspace same-kind
conflict gate. Source provenance never grants priority.
### Output — `template_selection.json` (written with Stage 1)
```json
{
"schema_version": 1,
"phase": "template",
"status": "confirmed",
"mode": "templates",
"selections": [
{
"source": "library",
"kind": "style",
"id": "example_style",
"workspace_root": "/canonical/library/root/example_style"
},
{
"source": "explicit",
"kind": "deck",
"workspace_root": "/canonical/unregistered/workspace/root"
}
],
"options_sha256": "<64 lowercase hex characters>",
"selection_sha256": "<64 lowercase hex characters>",
"confirmed_at": "2026-08-04T12:00:00"
}
```
`mode: "free_design"` requires `selections: []`; `mode: "templates"` requires
at least one selection. Roots are unique canonical absolute paths. A library
selection contains exactly `source`, `kind`, `id`, and `workspace_root`; an
explicit selection contains exactly `source`, `kind`, and `workspace_root`.
There is at most one library selection per kind and at most one explicit
selection overall; cross-kind composition remains valid, and one explicit plus
one library selection may share a kind. The browser
cannot submit arbitrary paths 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 resolved candidates. `selection_sha256` binds the
mode and canonical sorted selections to that option hash. Every receipt read
rebuilds the catalog and rejects option/index drift.
The Stage-1 submission writes this receipt and the Stage-1 `result.json`
together. Generate reads both exactly once after `--wait-only --wait-stage
stage1` returns. Free design skips installation. Template mode runs
`apply-template-workspace` against all selected roots and waits for complete
project-local installation. Only then does the agent complete the
handoff below. Installation copies each selected spec separately; Stage 2
resolves segment ownership and current-project fit from the installed set. Strategist never
reads the source roots.
### Agent handoff — `template_handoff.json`
After free design closes or template installation succeeds, run:
```bash
python3 scripts/confirm_ui/server.py <project_path> --complete-template-selection
```
The command writes, and agents must not hand-author:
```json
{
"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
`<project_path>/templates/design_spec.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
The following fields belong to the Strategist stages, not to the
template-selection receipt.
- **Enumerable + custom** — canvas / icons retain blank manual inputs. Mode and visual style first show three project-specific `custom` values projected from the complete directions, then the full fixed base catalog as conservative single-select alternatives. Selecting a projected card expands its behavior editor in place; edits change only the current value, while the adjusted active whole-direction card exposes an explicit restore action for the authored text.
- **Visual examples for hard-to-name choices** — the full-screen confirmation page loads real SVG page samples from `static/style_previews/` for fixed `visual_style` catalog choices, and renders real sample SVGs from `templates/icons` for `icons`. Project-specific `custom` direction cards show their authored summary instead of requesting a nonexistent preset asset. These thumbnails and summaries make style and icon-library choices visually comparable before the user locks them. Preview copy is fixed role text (big title / section title / body / points), not project content from recommendation files, so users compare visual treatment rather than copywriting. These previews are a confirmation aid only: they do not add fields to recommendation stage files or `result.json`, and they do not replace the later Step 6 live preview.
- **Image usage multi-select** — image sources are selected as one or more catalog ids: `ai` = AI-generated, `web` = Web-sourced, `provided` = User-provided, `placeholder` = Placeholder, `none` = No images. `none` is exclusive. A confirmed non-`none` set is the allowed acquisition-source boundary, not a requirement to use every selected source; only explicit `image_notes` wording can require a source, asset, or page role. Recommendation and result values may be a legacy single string, but new files should use an array. When several sources are recommended, write the source ids to `recommend.image_usage` and write the actual usage strategy to `image_notes`, not a custom prose value.
- **Closed enumerable** — PPT reading mode (`delivery_purpose` compatibility key), formula policy / generation mode / refine spec, plus AI source only when image usage includes `ai`. These have no Custom box; out-of-catalog values snap back to the recommended option.
- **Proactive execution booleans** — Final Stage 2 carries top-level `proactive_speaker_notes`, `proactive_custom_animations`, and `proactive_narration_audio` values. Defaults are `true`, `false`, and `false`, respectively. They control what the Agent does proactively only when the user has not explicitly instructed otherwise; the latest explicit user instruction always wins. These three values are raw confirmation evidence: the UI and server neither couple nor rewrite them, and every boolean combination is valid. When narration audio is enabled, Strategist later resolves the effective Speaker Notes outcome to enabled and records `Narration Audio dependency` as its Design Spec provenance. Disabling proactive custom animation does not suppress the Strategist's advisory motion recommendations.
- **Open prose** — `audience`, `communication_intent`, `audience_outcome`, `core_message`, `delivery_context`, `artifact_afterlife`, `content_divergence`, and `page_count`. `communication_intent` may carry several purposes plus priority / sequence; common paths appear only as help text. `delivery_context` states one primary presenter-led / reader-led / hybrid / recorded-self-running context plus optional secondary use; a hybrid recommendation names which context leads. `content_divergence` is the source-treatment axis. `page_count` may be a range here; Strategist resolves the exact §IX roster, leaving Executor no pagination latitude.
- **Coordinated generative directions** — `design_directions` carries exactly three complete candidates authored top-down from the project contract. Each has a unique stable id and bundles `custom` mode, `custom` visual style, color, typography, icon id, and `custom` generated-image rendering regardless of recommended image source. Its localized note is a compact, user-facing style summary. It may reuse localized display labels from `catalogs.visual_styles` when they describe the result concisely, but those labels are optional vocabulary rather than a selection constraint or required mapping. Otherwise it uses concise natural language and never forces the nearest label. The summary stays within one or two short sentences and does not expose catalog ids or reference mechanics. All three must be viable as whole solutions; they do not need different catalog bases or forced safe / shifted / bold archetypes. After completing all three bundles, Strategist compares them against the confirmed contract and source, then writes the strongest overall fit's zero-based index to `selected`; array position does not determine preference. That bundle becomes the initial default and applies its three custom projections coherently. The page can still render legacy top-level `color`, `typography`, and `image_strategy` candidates, but new staged recommendations use the coordinated bundle.
Direction-local custom projections apply to mode, visual style, and generated-image rendering; all three are editable after selection and a selected custom value cannot be blank. The original recommendation remains immutable so the active whole-direction card can explicitly restore every edited component without making an ordinary card click destructive. Legacy standalone `custom_candidates` remain readable but are optional and are not authored in new files. Color / typography keep their existing manual Custom cards. Image usage uses source ids plus `image_notes`; closed sets have no Custom path.
**Stage-2 catalog read gate.** Before choosing component bases, Strategist reads only `modes/_index.md`, `visual-styles/_index.md`, and `image-renderings/_index.md`. It authors the three whole solution intents first, freezes every basis id from those indexes, and only then reads the deduplicated selected detail files before completing the custom behaviors. Unselected sibling files never enter context; a novel custom reads none.
**Stage-1 current-value contract.** Each editable prose box starts with the Strategist's recommendation, if one exists. The user may retain, revise, or clear it; no Stage-1 prose field has a non-empty validation gate. On confirmation, the browser submits the current strings and the server preserves them through every later stage and the final `result.json`, including `""`. Blank means no explicit user constraint and may cause downstream default judgment, but it never causes the initial recommendation to be restored. A profile-declared `locked: true` field is read-only and remains the sole exception.
`image_ai_path` is conditional: the page shows it and writes it to `result.json` only when `image_usage` includes `ai`. Web-sourced / User-provided / Placeholder / No images paths do not carry an AI backend choice.
## Catalogs — `static/catalogs.json` (the finite option universe)
The front-end loads `/api/catalogs` (served by the confirm server) and falls back to the static `/static/catalogs.json` if that route is unavailable. `/api/catalogs` returns the static file **with the `canvas` list synced live from `config.py CANVAS_FORMATS`** — the set of formats and their `dim` come from config (single source of truth, zero drift), while four-language labels / use text stay in catalogs.json (a plain fallback label is synthesized for any new id config adds). Keys: `canvas`, `modes`, `visual_styles` (grouped), `icons`, `image_usage`, `image_ai_path`, `formula_policy`, `generation_mode`, `delivery_purpose`. Each entry is `{ "id", "label", "label_zh", "label_zh_tw", "label_en", "label_ja", ... }`; descriptions use `desc_zh` / `desc_zh_tw` / `desc_en` / `desc_ja`, and `visual_styles` groups use `group_zh` / `group_zh_tw` / `group_en` / `group_ja`. The front-end falls back to legacy `label` / `desc` / `group`, so old catalogs still load, but new user-facing catalog text must cover all four languages (zh / zh-TW / en / ja). English labels should mirror canonical reference names (`pyramid`, `swiss-minimal`, `Path A`, `mixed`, etc.); Simplified Chinese, Traditional Chinese, and Japanese labels should be translated for users. Descriptions render inline after the option title, not as a separate selected-option line. `visual_styles` is `[{ "group", "group_zh", "group_zh_tw", "group_en", "group_ja", "items": [...] }]`. For `canvas` you only need to maintain the four-language labels in catalogs.json; the format set and dimensions are authoritative in `config.py CANVAS_FORMATS`.
## Round-trip data contract
In the UI branch, round-trip and session files live under
`<project_path>/confirm_ui/`. `template_options.json` is prepared beside the
Stage-1 recommendation. The Stage-1 submit writes `result.json` and
`template_selection.json` together; after installation/free-design closure,
`template_handoff.json` unlocks Stage 2. The chat/delegated branch preserves the
same logical order without fabricating these UI receipts.
### Current two-stage flow
The page runs a **two-stage Strategist wizard in one browser session**. Stage 1
contains the communication contract and template/free-design controls. Each stage
has its own Strategist-authored file and top-level `"stage"` selector. The active,
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
user confirms it, normal progression writes the next stage file rather than
repurposing the previous one. The server derives the active Strategist filename
from `result.json`; the bound template handoff is the prerequisite for Stage 2.
Confirm UI is a one-run surface, not a migration layer. It accepts only the
current Stage-1/Stage-2 files. If a project starts UI confirmation again, run
`--reset-template-selection`, write fresh `template_options.json`, then author a
fresh `recommendations.stage1.json`. Those two inputs start the new UI
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 |
|---|---|---|---|---|
| `recommendations.stage1.json` + `template_options.json` | `"stage1"` | communication contract — content language; audience; open `communication_intent`; audience outcome; core message / primary delivery context + optional secondary use / artifact afterlife / `content_divergence` (all prose fields may be blank); canvas; free-design/template mode and conditional candidate selectors | **Confirm contract & template choice** | writes Stage-1 `result.json` plus `template_selection.json` in one submission; the page stays open and polls while the agent installs/completes the handoff |
| `recommendations.stage2.json` | `"stage2"` | complete deck solution and production — conditional natural-language template application, reading mode, mode, page count, visual direction, color, icons, typography, image usage/rendering, conditional AI acquisition path, formula policy, proactive notes/custom-animation/narration-audio toggles, generation mode, and Design Spec review toggle | **Confirm final plan** | writes `result.json` `{ stage: "final", status: "confirmed", <all fields> }`, then shuts the page down |
In the UI branch, the AI authors Stage 1 without reading template candidates,
then launches the combined page. In chat/delegated confirmation it authors the
same communication recommendation before listing template candidates. After
the one Stage-1 confirmation, the AI installs any selection, completes the
handoff/equivalent state, then inspects only the project-local template and
authors the complete final Stage-2 solution plus production mechanics once from
the user's actual communication contract. An edit inside
the current stage never requests another recommendation. The page preserves
earlier answers across transitions. `GET /api/session` reports `phase:
"strategist"` with current Stage 1 from launch; after Stage-1 submission it
reports `waiting_agent` until the bound handoff and fresh Stage-2 file exist.
`GET /api/recommendations`
is `no-store`, and the server folds confirmed earlier-stage choices back into
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
completed final result is never reopened.
**Progression guard.** Stage 1 requires current `template_options.json` and a
fresh `recommendations.stage1.json`; it does not require a prior selection or
handoff. Its one submit must persist a valid Stage-1 result and selection bound
to those options. Stage 2 requires a newer `template_handoff.json` bound to that
selection and a fresh Stage-2 recommendation. This ordering prevents receipts
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
active filename and its required predecessor; the declared `stage` must also
match the filename. A confirmed templates-mode workspace does not exempt final
Stage 2: its recommendations must include `template_application.value`.
### Input — `recommendations.stage1.json` (created beside template options)
Author this file before reading candidate index summaries in chat and without
reading any template spec, prototype, asset, segment owner, or template
canvas. `template_options.json` supplies display state only and never changes
the communication recommendation.
```json
{
"stage": "stage1",
"lang": "zh",
"primary_language": "zh-CN",
"recommend": {
"canvas": "ppt169"
},
"audience": { "value": "公司管理层,包括财务与产品负责人" },
"communication_intent": {
"value": "先汇报进展并暴露交付风险,再推动管理层决定下一阶段投入"
},
"audience_outcome": {
"value": "管理层能比较三个选项、接受风险判断,并选定一条获得预算的路径"
},
"core_message": {
"value": "现在为方案 B 增加投入,能以可接受的成本守住发布时间"
},
"delivery_context": {
"value": "主要为有主讲的 20 分钟管理层现场评审;次要为会后独立阅读的审批材料"
},
"artifact_afterlife": {
"value": "作为审批记录、项目交接依据和季度审计材料"
},
"content_divergence": { "value": "" }
}
```
All seven Stage-1 prose values may be blank. `primary_language` is required canonical BCP-47. The server normalizes legacy English / Chinese / Japanese / Korean aliases, rejects `und` and Chinese without script/region, and carries it forward; `lang` is UI-only. Prose submits verbatim, including `""`. A profile's `{ "locked": true }` value is read-only, persisted, and stripped of that marker in final `result.json`.
The common paths — inform / explain / persuade / decide / align / teach / report and account / mobilize / record and hand off — appear only as help text for `communication_intent`. They are not catalog ids and must not be emitted as a `primary_job` field.
After Stage 1 is confirmed, create `recommendations.stage2.json` with the complete solution; leave Stage 1 unchanged (the server folds confirmed communication fields back in when serving the page):
**Stage-2 production contract**: the server rejects the recommendation file
unless `recommend.formula_policy`, `recommend.generation_mode`, and boolean
`refine_spec.value` are present; `recommend.image_ai_path` is additionally
required when `image_usage` includes `ai`. Final submission must retain the
corresponding direct values (`formula_policy`, `generation_mode`, boolean
`refine_spec`, and conditional `image_ai_path`) or confirmation is rejected.
```json
{
"stage": "stage2",
"lang": "zh",
"recommend": {
"delivery_purpose": "balanced",
"mode": "custom",
"visual_style": "custom",
"image_strategy": "custom",
"image_usage": ["ai", "provided"],
"image_ai_path": "auto",
"formula_policy": "mixed",
"generation_mode": "continuous"
},
"page_count": { "value": "12-15" },
"image_notes": { "value": "封面和章节页用 AI 主视觉;产品页优先用户素材。" },
"proactive_speaker_notes": { "value": true },
"proactive_custom_animations": { "value": false },
"proactive_narration_audio": { "value": false },
"refine_spec": { "value": false },
"design_directions": {
"selected": 1,
"candidates": [
{
"id": "executive-clarity",
"name_zh": "稳妥专业",
"note_zh": "以瑞士极简为主,融合柔和圆角与编辑出版风格。",
"mode": "custom",
"mode_behavior_zh": "以 pyramid 作为唯一目录基底,为当前风险决策材料定制两次结论闸门;标题保持判断句,每章先给判断,再用证据展开并以可执行结论收束。",
"visual_style": "custom",
"visual_style_behavior_zh": "由 swiss-minimal 负责精确栅格和大留白,soft-rounded 负责少量关键容器的轮廓与轻微抬升,editorial 负责细规则、边注与证据层级;标题锐利,正文中性,装饰只标记推理关系。",
"icons": "tabler-outline",
"color": { "name_zh": "冷静专业", "palette": {
"background": "#FFFFFF", "secondary_bg": "#F4F6F8",
"primary": "#1A3A6B", "accent": "#E8A317",
"secondary_accent": "#4A7BB5", "body_text": "#1D2430"
} },
"typography": {
"name_zh": "微软雅黑 + Arial",
"heading": { "primary": "Microsoft YaHei", "english": "Arial", "css": "sans-serif" },
"body": { "primary": "Microsoft YaHei", "english": "Arial", "css": "sans-serif" },
"body_size": 24
},
"image_strategy": {
"name_zh": "编辑式证据图",
"rendering": "custom",
"visual_zh": "简化矢量主体配合编辑式注释与局部材质对比",
"mood_zh": "审慎、可信,像调查报道中的证据插图",
"behavior_zh": "由 vector-illustration 负责清晰轮廓,minimalist-swiss 负责留白构图,screen-print 负责克制的半调纹理,warm-scene 负责暖光与可信氛围;四者服从同一平面主体和当前演示文稿颜色角色,避免写实景深与装饰性渐变。"
}
}
]
}
}
```
The example shows one candidate's complete shape; the actual array repeats that shape for exactly three candidates. Every candidate requires a unique stable `id`, literal `custom` mode/style/rendering with localized behavior, an icon value, complete six-role palette, complete heading/body stack, and complete image strategy even when `recommend.image_usage` excludes AI. The server rejects any fixed mode/style/rendering inside a new direction and any explicit `design_directions.selected` outside integer `0` through `2`; omission remains readable as a legacy index-`0` fallback. Legacy grids remain readable only with three complete palettes and complete typography.
- `design_directions.selected` owns the initial complete bundle and MUST be the actual zero-based index (`0`, `1`, or `2`) that Strategist chooses only after all three bundles are complete. The chosen card is marked Recommended and initially applies its matching mode, visual-style, and generated-image custom candidates together; array order carries no recommendation meaning. New direction mode/style/rendering values are always literal `custom`; prose stays in the required behavior sibling. `recommend.*` remains a compatibility hint and may mirror `custom`, but it never replaces the selected direction. Legacy aliases remain accepted; new files write canonical ids.
- The three proactive-execution fields are top-level boolean `{ "value": ... }` objects, not catalog ids. Omitted fields use `true / false / false` for notes / custom animation / narration audio. These are absence-of-instruction defaults, not permission to override the user's latest explicit request. Preserve all three raw values independently through `result.json`; do not couple or rewrite them. Strategist derives effective Speaker Notes as enabled when audio is `true` and records `Narration Audio dependency` as provenance in the Design Spec. `proactive_custom_animations: false` leaves Strategist animation suggestions unchanged; it only prevents unrequested custom-animation execution.
- `custom_candidates` is an optional legacy recommendation-only shape. New files place all three project-specific variants inside `design_directions`; each remains separately selectable and becomes editable in place. A custom behavior may use zero, one, or many exact catalog bases. Reference count has no fixed cap: every named id must contribute a distinct executable job, the behavior omits any id whose contribution it cannot state, and one basis never requires a decorative second. The UI rejects a selected blank and submits only the edited current value. Template-backed variants obey inherited identity, prototype capacity, and `template_application`.
- Seed `audience`, `communication_intent`, `audience_outcome`, and `delivery_context` when evidence supports them; users need not supply them, and every Stage-1 prose field may end blank. The contract and `primary_language` stay in `result.json` and `design_spec.md`; `spec_lock.md communication` receives `primary_language`, compact `audience` / `objective` / `core_message`, and reading mode. `communication_intent` may preserve multiple purposes and priority/sequence; never add a `primary_job` enum.
- Do not write `recommend.template_reuse_scope` or `recommend.template_adherence`. Strategist records those internal exporter values later in `spec_lock.md` after inspecting the actual template and current content.
- For a confirmed templates-mode handoff, write one editable prose field as top-level `template_application.value`. It summarizes **how to use** the already selected project-local template: actual page/prototype use and preservation/reorganization decisions. It never chooses, changes, or reinstalls a workspace. Omit it for free design. The UI returns the current string through final Stage 2; Strategist then persists the final effective plan as `- **Template Application**: ...` in `design_spec.md §I`, which Executor reads from the retained Design Spec. Never replace it with internal reuse/adherence ids or a fixed option menu.
Template-mode-only Stage-2 fragment:
```json
{
"template_application": {
"value": "选用封面、章节页和数据页原型;跳过示例内容页。品牌标识和页脚保留,正文可按当前材料重组。"
}
}
```
- `recommend.image_usage` should be an array of source ids when more than one source applies, e.g. `["ai", "provided"]`. A single string is still accepted for backward compatibility. Do not write bare `"custom"` and do not encode a mixed-source plan as prose here; write the prose to top-level `image_notes.value`.
- `image_notes` is the initial strategy note shown under the image source chips. Use it for page-role guidance and constraints: which source applies where, what to avoid, which user assets are authoritative, how realistic / abstract the imagery should be, and what can remain as placeholders. It is intent guidance, not a separate finite option.
- Final Stage 2 shows and submits `recommend.image_ai_path` as one of `auto` / `api` / `host-native` / `manual` only while its current `image_usage` includes `ai`; changing sources refreshes that production control on the same page.
- **Color candidates carry the user-facing core `palette`**: `background`, `secondary_bg`, `primary`, `accent`, `secondary_accent`, and `body_text`. The page renders every role as a labelled swatch with its HEX value visible, and offers per-role override inputs for precise single-role edits, plus a **Custom color card with a free-text box** — the user can describe the palette in words or paste HEX values instead of filling each role; this writes `color: { "name": "custom", "custom": "<text>" }` to `result.json` for the AI to interpret. Legacy `text` is accepted as an alias for `body_text`, but new files should write `body_text`. Strategist derives secondary text, borders, state colors, and visual-style neutral tiers while writing `design_spec.md`, then projects the machine values to `spec_lock.md`; those are not user-facing confirmation choices.
- **Candidate display text may be multilingual**: color / typography candidates can provide `name_zh` / `name_zh_tw` / `name_en` / `name_ja` and `note_zh` / `note_zh_tw` / `note_en` / `note_ja`; the page falls back to legacy `name` / `note` when present. Otherwise localized fields resolve in the page language first, then by the locale's fallback order: zh → en → ja → zh_tw, en → zh → ja → zh_tw, ja → en → zh → zh_tw, or zh_tw → zh → en → ja. Include all four variants in newly authored multilingual candidates so a persisted UI-language choice never hides recommendation text.
- **Typography candidates** use concrete heading/body `primary`; non-English decks also use `english`, while English-primary decks omit it. `cjk` / `latin` remain legacy aliases. Localized `name` labels the pair and `css` only previews. Bundles differ overall; font pairs may repeat without blocking. Fixed pairs require `fixed: true`. Catalog `fonts` supplies language-filtered dropdowns plus Other without limiting recommendations; edits mark Custom and refresh the preview. Include topic samples. [`canvas-formats.md`](../../references/canvas-formats.md) § "Typography Scale Start" is the single owner of initial body anchors and sanity bands; the browser mirrors that rule, and submitted values remain px.
- **Per-role size override** (parallel to color's per-role HEX override): besides `body_size`, the page exposes editable inputs for `title` / `subtitle` / `annotation`. The browser applies one documented deterministic dependency chain: PPT uses `reading mode → body baseline`, non-PPT uses `canvas → body baseline`, then every canvas uses `body baseline → unpinned role sizes` (role ramp: `body ×` the §g ratios). Changing reading mode updates a PPT body and all unpinned roles locally; changing body updates unpinned roles locally. Editing body or a role pins that value, so later reading-mode changes do not overwrite it. A font-only selection preserves current sizes; applying a different complete direction, or using the active card's explicit restore action, restores that direction's typography baseline and derived unpinned sizes. This is a browser-only state update: it performs no fetch and asks the backend to author no new recommendations. Each role input is labelled as px and shows an approximate pt equivalent (`1px = 0.75pt`) for orientation. The final values are written to `result.json` as `typography.sizes: { "title", "subtitle", "annotation" }` in **px** — every canvas, no pt and no `sizes_pt` provenance. These confirmed values are Strategist input anchors: the completed page plan may add recurring roles, and downstream execution owns bounded per-occurrence treatment. Candidate `sizes` remain accepted for compatibility; fresh Stage 2 preserves a candidate `body_size` as its baseline and derives only missing or unpinned role sizes from the same local ramp before first render.
- **`delivery_purpose` compatibility key / Reading mode** (enumerable, PPT only) decides where meaning is carried, not merely how large type is: `text` makes pages self-contained with complete sentences, short prose, captions, tables, and necessary detail; `balanced` shares explanation between page and presenter; `presentation` uses one idea, concise claims, and visual evidence while speech / notes carry the detail. It therefore governs page grammar, granularity, density / rhythm, and note burden. Reading-mode cards intentionally show **no px value**; the typography section owns the separately visible body / role sizes and applies any local default. It is surfaced in Stage 2 beside the visual system, separate from communication intent. `recommend.delivery_purpose` pre-selects one; `result.json` retains the key, while `spec_lock.md` uses canonical `consumption_mode`. Non-PPT canvases omit it.
- **Combined style preview** — a compact live "overall impression" strip sits just above the color section and is **sticky**: it pins under the topbar so it stays visible while the user scrolls through the color / icon / typography sections, keeping the picking controls and their combined effect on screen together. It applies the currently selected color palette **and** typography (heading sample in `primary` over `background`, body sample in `body_text`, an `accent` bar, a `secondary_bg` chip) and repaints on every color / HEX-override / font / `body_size` change. It does not replace the per-candidate swatches or font samples (those stay for picking); it is deliberately an abstract style chip, **not** a slide-layout preview — page layout preview remains the live-preview server's job (Step 6). No schema field; it derives entirely from the existing color + typography selections.
- **Generated-image direction** appears only for current `image_usage: ai`, but all three custom project candidates already exist in `design_directions` before that toggle. Turning AI on reveals those candidates immediately without a backend rerun, followed by the 20 fixed system styles. Selecting a project candidate expands its behavior editor in that card; a fixed preset submits its id, while a project candidate submits `rendering: "custom"` + edited non-empty `behavior`. Turning AI off omits `image_strategy` from the final result without deleting the authored recommendation candidates. Catalog-based custom behavior names exact ids for optional `image_rendering_references`; a novel behavior has none. The left preview follows selection. No image palette is written; deck colors remain authoritative, and legacy `image_strategy.palette` is ignored.
- **`design_directions`** is the canonical Stage-2 starting set: exactly three top-down, project-fit bundles with stable ids, localized copy, custom mode/style/rendering, icons, complete language-aware typography, and HEX `background`, `secondary_bg`, `primary`, `accent`, `secondary_accent`, `body_text`. The `selected` card carries the persistent Recommended marker and is applied first. A custom direction card uses its localized style-summary note—or the required behavior fallback—instead of requesting a preset-style preview. Newly authored notes may borrow localized catalog display labels where useful or use concise natural language freely; they never force an approximate label or expose internal catalog ids. Clicking an inactive card applies every field it owns; projected custom fields can then be edited in place and all lower controls may diverge. The active card shows an adjusted state and exposes an explicit restore action for its immutable authored bundle. `result.json` stores the edited current components, never a direction id.
- `recommend.generation_mode` and `refine_spec` mirror [`generate-pptx`](../../workflows/generate-pptx.md) Step 4. `split` / `true` are explicit opt-ins. Refinement adds no UI stage: after Gate 1 it stops before the lock for unrestricted chat revision until approval.
- `content_divergence` is a **free-text** Stage-1 source-treatment field. Blank means a balanced default; facts stay sourced at every level. Strategist consumes it while authoring §IX and records it in `design_spec.md §I`; it is not written to `spec_lock.md`. Beautify sends `{ "value": "keep source wording and page structure verbatim", "locked": true }`, so the UI displays it read-only and the server restores it on every staged submit. Template-fill does not use this confirmation flow and does not surface it.
- `lang` is the soft UI-language default (`zh` / `zh-TW` / `en` / `ja`); the persisted user choice wins. It never sets `primary_language`.
### Output — `result.json` (written on submit, read by the AI)
```json
{
"primary_language": "zh-CN",
"canvas": "ppt169",
"page_count": "12-15",
"audience": "...",
"communication_intent": "Report progress and expose risk first; then obtain an investment decision",
"audience_outcome": "The committee compares the options and chooses one funded path",
"core_message": "Fund option B now to protect the launch date at acceptable incremental cost",
"delivery_context": "Primary: presenter-led 20-minute leadership review; secondary: reader-led approval copy shared afterward",
"artifact_afterlife": "Approval record, hand-off reference, and audit trail",
"content_divergence": "freely restructure and expand within the source",
"mode": "custom",
"mode_behavior": "...",
"visual_style": "custom",
"visual_style_behavior": "...",
"color": { "name": "...", "palette": { "background": "#...", "secondary_bg": "#...", "primary": "#...", "accent": "#...", "secondary_accent": "#...", "body_text": "#..." } },
"icons": "tabler-outline",
"typography": { "name": "...", "heading": { "primary": "...", "english": "...", "css": "..." }, "body": { "primary": "...", "english": "...", "css": "..." }, "body_size": 24, "body_size_unit": "px", "sizes": { "title": 42, "subtitle": 32, "annotation": 18 } },
"delivery_purpose": "balanced",
"formula_policy": "mixed",
"image_usage": ["ai", "provided"],
"image_notes": "封面和章节页用 AI 主视觉;产品页优先用户素材,缺口页可用占位符。",
"image_ai_path": "auto",
"image_strategy": { "name": "方案 A", "rendering": "custom", "visual": "...", "mood": "...", "behavior": "..." },
"proactive_speaker_notes": true,
"proactive_custom_animations": false,
"proactive_narration_audio": false,
"generation_mode": "continuous",
"refine_spec": false,
"stage": "final",
"status": "confirmed",
"confirmed_at": "2026-06-15T11:44:44"
}
```
The shape above is final for Strategist confirmation. It intentionally contains
no template-selection field: `template_selection.json`, its agent handoff, and
the installed project-local state own the parallel Stage-1 decision. The proactive-execution values
are independent flat booleans in `result.json`; old recommendations and results
that omit them resolve to `true / false / false`. They remain raw evidence even
when `proactive_speaker_notes` is `false` and `proactive_narration_audio` is
`true`; Strategist owns the effective dependency resolution described above.
Selected custom values use `mode: custom` + `mode_behavior`, `visual_style:
custom` + `visual_style_behavior`, or `image_strategy.rendering: custom` +
`behavior`. During Design Spec and lock authoring, Strategist projects optional
`mode_references`, `visual_style_references`, or
`image_rendering_references` only when that confirmed behavior actually uses
named catalog sources. These lists have no fixed item limit; each item must own
a distinct executable contribution, while genuinely novel custom behavior has
no reference list.
The Stage-1 intermediate write retains the communication contract for Stage 2.
**Final-result consumption contract.** A final result is the user-confirmed input contract for the Strategist's Design Spec, not another recommendation input. After the final wait, Generate Step 4 reads the complete final object exactly once and retains it while Strategist writes and audits `design_spec.md` against every explicitly present field. Normal lock authoring and downstream execution do not reopen `result.json`; the completed Design Spec is the durable authority. Only after that audit passes does Strategist author `spec_lock.md` from the Design Spec plus current execution context, selecting stable anchors and routing rather than copying every field or enumerating every legal color/font. Every value must be consumed at the semantic type owned by [`strategist.md`](../../references/strategist.md) §1 and its field owner: do not omit or substitute it, and do not silently strengthen or weaken its type. If a confirmed requirement cannot be honored, the owning workflow reports or pauses under failure recovery; it never deletes the requirement to keep the pipeline moving.
- Bespoke mode / style prose lives only in the required behavior sibling; image custom prose lives in `image_strategy.behavior`. Canvas / icons retain free-text edge cases, color / typography retain `name: "custom"`, and image usage remains a source-id array plus `image_notes`.
- `image_ai_path` and `image_strategy` appear only with `image_usage: ai` and remain confirmed downstream. The page is default; explicit/failure chat fallback keeps identical fields. `image_ai_path` selects the Step 5 path, and [`strategist-image.md`](../../references/strategist-image.md) §2 retains the selected rendering or custom behavior as the deck-level image identity anchor; individual prompts still adapt subject, composition, and atmosphere within it.
- Stage-1 **Confirm contract & template choice** writes the Stage-1 `result.json` and `template_selection.json` together, then keeps the page open while the agent installs the selection. A successful Stage-1 wait is explicitly intermediate and cannot end the task. In the same active run, the agent runs `--complete-template-selection`, writes fresh Stage 2 only after that handoff, and enters the final wait while the page keeps polling. Stage-2 **Confirm final plan** saves the final `result.json` and shuts the server down (auto-close). The AI reads each receipt at its owning boundary; chat fallback mirrors the same decisions without UI artifacts. Either way, Step 4 ends with `--shutdown` so a never-confirmed page cannot retain its selected port ahead of Step 6 live preview.
## Scope
- Confirmation surface only — Strategist authors every recommendation; the page never generates deck content.
- No SVG / layout preview here — that is the live preview server's job (`workflows/stages/live-preview.md`, Step 6).
@@ -0,0 +1,592 @@
# Conversion Tools
> **Design boundary**: use native-Python converters for supported formats,
> invoke Pandoc only for explicit fallback formats, and let web conversion use
> `curl_cffi` when available for sites that reject Python's default TLS
> fingerprint.
Source conversion tools turn PDFs, documents, slide decks, and web pages into Markdown before project creation.
Default workflow entry: use `source_to_md.py` unless a backend-specific
diagnostic or forced route is needed.
## Shared Output Contract
All `source_to_md` converters keep their existing Markdown output behavior and
now also write a lightweight sidecar profile when conversion succeeds:
| Output | Convention |
|---|---|
| Markdown | `<stem>.md` beside the local source unless `-o` selects another path |
| Asset directory | `<stem>_files/` when the backend extracts images or media |
| Image manifest | `<stem>_files/image_manifest.json` when image metadata is available |
| Conversion profile | `<stem>.conversion_profile.json` beside the Markdown output |
The conversion profile is metadata only. It records the converter, source path,
Markdown structure counts, asset directory, image manifest path, and image
count. Downstream PPT workflows still use the Markdown and image manifest as the
content/asset contract; the profile is for inspection and debugging.
## `source_to_md.py`
Unified dispatcher for ad hoc explicit-source conversion. It auto-detects each
listed input file or URL and calls the existing backend converter, so backend
behavior remains the source of truth.
Routing is centralized in `source_to_md/_dispatcher.py` and reused by
`project_manager.py import-sources`; do not add a second type-to-backend table.
```bash
python3 scripts/source_to_md.py paper.pdf
python3 scripts/source_to_md.py paper.pdf report.docx deck.pptx
python3 scripts/source_to_md.py ./sources
python3 scripts/source_to_md.py ./pdfs/*.pdf
python3 scripts/source_to_md.py ./decks/*.pptx
python3 scripts/source_to_md.py report.docx -o report.md
python3 scripts/source_to_md.py ./sources -o ./markdown # explicit separate output directory
python3 scripts/source_to_md.py workbook.xlsx --json
python3 scripts/source_to_md.py deck.pptx
python3 scripts/source_to_md.py https://example.com/article -o article.md
```
Useful options:
- `-t pdf|doc|excel|pptx|web|markdown|text` forces a route when extension
detection is not enough.
- `--json` prints a compact machine-readable result after success when the
output path is known. With multiple inputs, each successful conversion prints
its own JSON line after that source finishes.
- `--images all|filtered|none`, `--no-images`, and `--filter-images` map to the
existing PDF image mode. They are intentionally PDF-only until other backends
expose the same behavior natively.
- Unknown backend-specific flags are passed through to each selected converter.
- `-o/--output` selects one Markdown file for one input, or an output directory
for multiple inputs / directory inputs.
For multi-source project intake, use `project_manager.py import-sources` with
all source paths / URLs. For local files, the default is to keep generated
Markdown/profile outputs beside the original source. `source_to_md.py` and the
backend converters support single files, explicit multi-file inputs, and
non-recursive directory inputs.
## `source_to_md/pdf_to_md.py`
Recommended first choice for native PDFs.
```bash
python3 scripts/source_to_md/pdf_to_md.py book.pdf
python3 scripts/source_to_md/pdf_to_md.py book.pdf -o output.md
python3 scripts/source_to_md/pdf_to_md.py book.pdf appendix.pdf
python3 scripts/source_to_md/pdf_to_md.py ./pdfs
python3 scripts/source_to_md/pdf_to_md.py ./pdfs -o ./markdown # explicit separate output directory
# Image extraction control (default: filtered)
python3 scripts/source_to_md/pdf_to_md.py book.pdf --images filtered # size/quality filters applied
python3 scripts/source_to_md/pdf_to_md.py book.pdf --images all # extract all images, no filtering
python3 scripts/source_to_md/pdf_to_md.py book.pdf --images none # skip all images (text only)
```
Use cases:
- Native PDFs exported from Word, PowerPoint, LaTeX, or similar tools
- Privacy-sensitive documents that should stay local
- Fast first-pass extraction before falling back to OCR-heavy tools
Prefer MinerU or another OCR/layout tool when:
- The PDF is scanned or image-based
- Multi-column layout parsing is poor
- Encoding is garbled
Dependency:
```bash
pip install PyMuPDF
```
## `source_to_md/doc_to_md.py`
Hybrid converter: pure-Python for the common formats, pandoc fallback for the rest.
Native path (no external binary required):
- `.docx` — via `mammoth`; text-only tables are preserved as pipe Markdown, and OMML / Office Math equations (Word-native or MathType "Convert to Office Math") are rewritten to inline LaTeX. Classic MathType OLE objects carry no OMML and are kept only as their preview image.
- `.html` / `.htm` — via `markdownify` + `beautifulsoup4`
- `.epub` — via `ebooklib` + `markdownify`
- `.ipynb` — via `nbconvert`
Pandoc fallback (only if you need these):
- `.doc`, `.odt`, `.rtf`, `.tex`/`.latex`, `.rst`, `.org`, `.typ`
```bash
python3 scripts/source_to_md/doc_to_md.py lecture.docx
python3 scripts/source_to_md/doc_to_md.py lecture.docx -o output.md
python3 scripts/source_to_md/doc_to_md.py lecture.docx notes.html
python3 scripts/source_to_md/doc_to_md.py ./docs
python3 scripts/source_to_md/doc_to_md.py ./docs -o ./markdown # explicit separate output directory
python3 scripts/source_to_md/doc_to_md.py notes.epub
python3 scripts/source_to_md/doc_to_md.py paper.tex -o paper.md # uses pandoc
```
Dependencies:
```bash
# Native path — always required
pip install mammoth markdownify ebooklib nbconvert beautifulsoup4
# Fallback path — only for .doc/.odt/.rtf/.tex/.rst/.org/.typ
# macOS: brew install pandoc
# Ubuntu: sudo apt install pandoc
# Windows: https://pandoc.org/installing.html
```
All paths produce the same output convention: `<input>.md` plus a sibling `<input>_files/` directory containing extracted images with relative references.
On success, a sibling `<input>.conversion_profile.json` is also written.
## `source_to_md/excel_to_md.py`
Excel workbook converter for presentation source intake.
Supported formats:
- `.xlsx`
- `.xlsm`
Unsupported by default:
- `.xls` — resave as `.xlsx` first
```bash
python3 scripts/source_to_md/excel_to_md.py report.xlsx
python3 scripts/source_to_md/excel_to_md.py report.xlsx -o output.md
python3 scripts/source_to_md/excel_to_md.py report.xlsx budget.xlsm
python3 scripts/source_to_md/excel_to_md.py ./workbooks
python3 scripts/source_to_md/excel_to_md.py ./workbooks -o ./markdown # explicit separate output directory
python3 scripts/source_to_md/excel_to_md.py report.xlsm --max-rows 200 --max-cols 40
```
Behavior:
- preserves workbook and sheet structure in Markdown
- exports visible sheets only
- trims empty outer rows and columns
- propagates merged-cell labels for readable Markdown tables
- exports formula cells as cached values; it does not recalculate formulas
- writes `<input>.conversion_profile.json` after successful conversion
Dependency:
```bash
pip install openpyxl
```
CSV/TSV files are already plain-text table sources and do not require this converter.
## `source_to_md/ppt_to_md.py`
Structured PowerPoint-to-Markdown converter for Open XML slide decks.
Supported formats include:
- `.pptx`, `.pptm`
- `.ppsx`, `.ppsm`
- `.potx`, `.potm`
```bash
python3 scripts/source_to_md/ppt_to_md.py sales_deck.pptx
python3 scripts/source_to_md/ppt_to_md.py sales_deck.pptx -o output.md
python3 scripts/source_to_md/ppt_to_md.py sales_deck.pptx appendix.pptx
python3 scripts/source_to_md/ppt_to_md.py ./decks
python3 scripts/source_to_md/ppt_to_md.py ./decks -o ./markdown # explicit separate output directory
python3 scripts/source_to_md/ppt_to_md.py template.ppsx -o notes/template.md
```
Behavior:
- extracts slide text in reading order
- converts PowerPoint tables to Markdown tables; cell-internal line breaks become `<br>` so they cannot break the pipe-table row structure
- transcribes category charts as category × series tables and scatter/bubble charts as typed X/Y[/size] point tables
- preserves every readable chart dimension or series and emits `[Chart data warning: <reason>]` for missing caches/count mismatches; `[Chart data unavailable: <reason>]` is reserved for charts with no readable points (and unsupported ChartEx), so XY data is never flattened into a misleading category table
- transcribes SmartArt semantic nodes as hierarchical Markdown; unreadable diagram data emits an explicit placeholder and conversion warning
- exports embedded pictures to a sibling `_files/` directory
- appends speaker notes when present
- writes `<input>.conversion_profile.json` after successful conversion
Dependency:
```bash
pip install python-pptx
```
Legacy `.ppt` is not parsed directly. Resave it as `.pptx` or export it to PDF first.
## `pptx_intake.py`
Standard enrichment layer for PPTX sources. It complements `ppt_to_md.py` rather
than replacing it: Markdown remains the normalized content source, while intake
artifacts provide source facts for Strategist and standalone PPTX workflows.
```bash
python3 scripts/pptx_intake.py deck.pptx -o projects/demo/analysis
```
Outputs (per source deck, prefixed by file stem):
- `<stem>.identity.json` — canvas size/aspect, theme palette/fonts, observed colors/fonts
- `<stem>.slide_library.json` — text slots, geometry, native tables, native chart display caches, and SmartArt nodes/connections
- `source_profile.json` — the single multi-deck index: a compact Strategist-facing digest per deck (over identity, tables, charts, SmartArt, and page types) under `decks[]`, with prefixed artifact pointers
`project_manager.py import-sources` runs this automatically for PPTX/PPTM/PPSX/PPSM/POTX/POTM inputs and stores the bundle directly under `analysis/`. Multi-deck per project: importing several PPTX files gives each its own `<stem>.*` artifacts and a `decks[]` entry in the shared `source_profile.json` index (re-importing the same stem replaces its entry). The beautify profile and Fill Native PPTX route stay single-deck and read one chosen deck's `<stem>.*` artifacts.
Usage boundary:
- Standard generation uses these fields as facts and recommendation candidates; it does not inherit source slide coordinates or page order by default.
- Beautify promotes selected identity/content fields into locked constraints after confirmation and redraws SmartArt meaning with ordinary editable shapes.
- Template-fill uses the slide library as the native PPTX fill contract; SmartArt is inventory-only and remains unchanged.
## `pptx_to_svg.py`
Reconstruct a PPTX package as editable SVG views by reading OOXML directly.
```bash
python3 scripts/pptx_to_svg.py deck.pptx --inheritance-mode both
python3 scripts/pptx_to_svg.py deck.pptx --inheritance-mode layered
python3 scripts/pptx_to_svg.py deck.pptx --inheritance-mode flat
python3 scripts/pptx_to_svg.py deck.pptx --strict
```
| Mode | Output |
|---|---|
| `both` (default) | Layered master/layout/slide SVGs under `svg/`, plus self-contained slides under `svg-flat/` |
| `layered` | Only the layered `svg/` view and inheritance metadata |
| `flat` | One self-contained slide SVG per page under `svg/` |
For Office pictures that carry both a raster compatibility preview on
`a:blip` and an editable SVG relationship in `asvg:svgBlip`, import resolves
the SVG relationship first. The raster relationship is used only when the SVG
relationship or media part cannot be read. The template manifest uses the same
relationship preference for asset identity; its existing missing-media gate
remains strict rather than silently treating the raster preview as the
template's canonical asset.
### Import compatibility and recovery boundary
Import is tolerant by default because the source deck is user-owned or comes
from third-party authoring tools. Recovery happens at the narrowest safe
boundary: first omit only an unsupported property or feature; if that is not
possible, replace only the affected object with a visible diagnostic
placeholder; omit a background without discarding its page. Corrupt ZIP/XML or
missing required package structure remains fatal because no safe local recovery
exists. Pass `--strict` for parser development or contract verification when
the first unsupported/malformed source construct should stop conversion.
Every successful run writes `<output>/conversion-report.json`. Its stable
top-level fields are `schemaVersion`, `source`, `mode`, `summary`, and
`diagnostics`. Each diagnostic records a reason `code`, source `message`, chosen
`fallback`, package `part_path`, and—when available—`slide_index`, `shape_id`,
`shape_name`, and `shape_kind`. The command also prints a bounded warning
summary instead of a raw Python traceback.
In the detailed native-object notes below, “fails closed” or “error” describes
the native replacement claim or strict mode. Default tolerant deck import
retains the usable fallback/object and records the degradation; it does not
discard unrelated shapes, pages, or the entire deck.
### Native table and chart import claims
Supported text-grid tables and conservative classic-chart caches carry a
`data-pptx-replace-with` claim beside their SVG fallback, with the replacement
payload in a child `<metadata type="application/json">`. The parent claim
selects the table or chart schema. Table import requires
exact physical row/grid topology and accepts canonical rectangular merges,
safe solid/no-fill per-side borders, plain multi-paragraph cells, and a closed
run-rich paragraph schema.
Each run requires `text` and may use only `bold`, `italic`, `underline`,
`strike`, `color`, `font_size`, one `font_family`, `lang`, and `alt_lang`.
Presentation-only source run XML without a non-empty `effectLst` / `effectDag`
normalizes. A table-cell run effect disables native replacement and adds a
blocking effect diagnostic. Relationship-bearing text, extensions,
noncanonical/overlapping merges, nonblank merge slaves, unsafe border XML,
non-solid fills, structural line breaks/fields/tabs/bullets, and broken text
topology remain fallback-only.
Markers remain dormant
unless a later export uses `--native-charts-and-tables`. That opt-in is
data-object-first: the default fallback still exports as editable DrawingML
shapes, while the opt-in supplies a data source and PowerPoint's
chart/table-specific object model.
The native-object route may normalize styling or omit marker-local details not represented by the
payload, and export reports that risk without disabling an otherwise supported
active marker. Unsupported tables keep their
rendered SVG table; unsupported charts keep a baked preview when one exists.
For the currently supported parsed classic families (column/bar/line/area,
pie/doughnut, scatter, and bubble), a chart without a baked preview receives a
deterministic readable fallback marked
`data-pptx-fallback-kind="normalized"`. Unknown style XML disables the native
replacement claim or falls back to a diagnostic object in tolerant mode;
common solid/no-fill/line/marker forms and scheme colors are normalized for the
SVG fallback and core payload colors, while native opt-in may still normalize
unmodeled alpha, line, marker, or no-fill details. Common General, decimal,
grouped, percent, and simple currency-prefix data-label formats render
deterministically; an unknown Excel format program keeps the active payload but
does not claim a normalized fallback. Active types outside the current renderer
continue to use an explicit placeholder marked
`data-pptx-fallback-kind="placeholder"`. Validation and export report that
reconstruction-only fallback as a warning. Default export keeps the
placeholder; when the same group has a valid active chart replacement payload,
`--native-charts-and-tables` may still reconstruct the PowerPoint-native chart.
Invalid or contradictory fallback declarations remain errors. Fallback-only
replacement capability uses `data-pptx-replacement-status` and remains a
warning when the SVG fallback itself is complete. Imported table/chart groups
under this contract carry `data-pptx-import-source="pptx"`, whether active or
fallback-only; generated authoring omits this provenance attribute.
Active imported markers also carry `data-pptx-fallback-sha256`, computed over
their canonical fallback plus reachable document-level SVG fragment definitions.
A later visible edit, reachable definition change, local reference-target
change, or marker transform makes the replacement metadata stale. The mandatory
quality checker reports the mismatch; default export keeps the edited fallback,
while `--native-charts-and-tables` fails before replacement so it cannot discard that edit.
`visibility:hidden` content, marker-local unused definitions, and explicitly
referenced document-level target roots (even when hidden) are included
conservatively; marker-local `display:none` subtrees are excluded, and external
file bytes are not read.
Generated authoring and reusable templates omit import provenance and do not
preseed a static fallback hash; that state is normal and does not warn. A legacy
imported marker that still carries PPTX import provenance but lacks the hash
remains native-compatible and warns in the checker/native route that stale
detection is unavailable.
Legacy `data-pptx-native*`, `data-pptx-visual-status`, and
`data-pptx-route-status` spellings remain read-compatible. New importer output
and generated SVG use the replacement/fallback names above. The old
`--native-objects` option remains a compatibility alias for
`--native-charts-and-tables`.
For table style `{5C22544A-7EE6-4342-B048-85BDC9FD1C3A}`, the importer resolves
the normalized `wholeTbl`, `firstRow`, `band1H`/`band2H`, theme color/font, and
direct-format override subset. Other built-in/custom style families remain
outside this guarantee.
The chart importer also accepts the verified column/line/area combo subset,
canonical four-series OHLC stock charts with shared numeric date caches, area
charts with numeric date axes, and verified scatter/bubble charts with a closed
pair of `axes.x` / `axes.y` value axes. Combo primary/secondary plots may retain
independent category caches and workbook ranges. Both the category/value and XY
contracts read back the supported kind/position/visibility, label-position,
number-format, min/max/major-unit, reverse, and major-gridline fields; the native
writer emits every field in those closed contracts. Scatter style is derived
from uniform effective series line/marker/smooth state. The normalized XY
fallback newly consumes only the two major-gridline flags, not the remaining
axis fields. The importer also accepts radar, safe `of_pie` `serLines`, the
closed axis/title/legend normalization cases, and bar/column `gapWidth` /
`overlap`. `gapWidth` must be one integer in `0..500` and `overlap` one integer
in `-100..100`; both normalize in native output, while malformed, duplicate, or
out-of-range values disable the native replacement claim in tolerant mode and
stop strict import. These additions do not expand the normalized
renderer.
Safe stock series style may pass the structural gate, while stock series,
`hiLowLines`, and up-down bar local styling can still normalize under the
data-object-first contract.
ChartEx import accepts exactly the validated treemap, sunburst, histogram,
pareto, box-whisker, waterfall, and funnel data models. Their supported
hierarchy/category/value/series/subtotal topology round-trips to native output.
Numeric caches must be non-empty and finite, with canonical non-negative counts
and indexes and exact contiguous point topology. Source style, axes, labels,
and binning details may normalize. This is not full `AxisSpec`, arbitrary
ChartEx import or presentation fidelity, arbitrary stock variants, other
date-axis chart families, or unlisted axis semantics. ChartEx native output
still consumes valid payload colors in its color-style part.
Exporter-canonical charts recover canonical solid series/slice colors and exact
one- or two-paragraph title styling; two paragraphs retain their `title` /
`subtitle` roles. This is not a general source-chart style round-trip guarantee.
Concrete slide SVGs resolve `<a:fld type="slidenum">` using the presentation's
`firstSlideNum` display numbering. Standalone master/layout SVGs keep the
literal field fallback because one shared part can serve multiple slides.
### Maintenance smoke checks
Run these checks from the repository root after changing `pptx_to_svg/` or its
CLI. They generate every required input under `/tmp`; do not replace them with
a committed `test_*.py` suite.
#### Healthy generated deck
```bash
python3 - <<'PY'
from pptx import Presentation
from pptx.enum.shapes import MSO_SHAPE
from pptx.util import Inches
presentation = Presentation()
slide = presentation.slides.add_slide(presentation.slide_layouts[6])
shape = slide.shapes.add_shape(
MSO_SHAPE.RECTANGLE,
Inches(1),
Inches(1),
Inches(3),
Inches(1),
)
shape.text = "PPTX import smoke check"
presentation.save("/tmp/ppt-master-smoke-healthy.pptx")
PY
python3 "skills/ppt-master/scripts/pptx_to_svg.py" \
"/tmp/ppt-master-smoke-healthy.pptx" \
--inheritance-mode flat \
-o "/tmp/ppt-master-smoke-healthy"
python3 -c "import json; from pathlib import Path; report = json.loads(Path('/tmp/ppt-master-smoke-healthy/conversion-report.json').read_text()); assert report['summary'] == {'slides': 1, 'warnings': 0}, report['summary']; print('OK: 1 slide, 0 warnings')"
```
Expected: both commands exit `0`; the assertion prints
`OK: 1 slide, 0 warnings`.
#### Tolerant/strict color-structure probe
Generate a two-shape PPTX, then add one foreign attribute to the first shape's
valid `a:srgbClr` node:
```bash
python3 -c '
from pathlib import Path
from zipfile import ZIP_DEFLATED, ZipFile
from pptx import Presentation
from pptx.dml.color import RGBColor
from pptx.enum.shapes import MSO_SHAPE
from pptx.util import Inches
base = Path("/tmp/ppt-master-color-smoke-base.pptx")
target = Path("/tmp/ppt-master-color-smoke.pptx")
presentation = Presentation()
slide = presentation.slides.add_slide(presentation.slide_layouts[6])
for left, color in ((1, (0x44, 0x72, 0xC4)), (4, (0xED, 0x7D, 0x31))):
shape = slide.shapes.add_shape(
MSO_SHAPE.RECTANGLE,
Inches(left),
Inches(1),
Inches(2),
Inches(1),
)
shape.fill.solid()
shape.fill.fore_color.rgb = RGBColor(*color)
presentation.save(base)
with ZipFile(base) as source, ZipFile(target, "w", ZIP_DEFLATED) as destination:
patched = False
for member in source.infolist():
payload = source.read(member)
if member.filename == "ppt/slides/slide1.xml":
old = b"<a:srgbClr val=\"4472C4\"/>"
new = b"<a:srgbClr val=\"4472C4\" legacy=\"1\"/>"
if old not in payload:
raise RuntimeError("probe color node was not generated")
payload = payload.replace(old, new, 1)
patched = True
destination.writestr(member, payload)
if not patched:
raise RuntimeError("slide XML was not patched")
print(target)
'
```
Run tolerant import and verify both the recovery report and the visible SVG:
```bash
python3 "skills/ppt-master/scripts/pptx_to_svg.py" \
"/tmp/ppt-master-color-smoke.pptx" \
--inheritance-mode flat \
-o "/tmp/ppt-master-smoke-color-tolerant"
python3 -c '
import json
from pathlib import Path
output = Path("/tmp/ppt-master-smoke-color-tolerant")
report = json.loads((output / "conversion-report.json").read_text())
diagnostics = report["diagnostics"]
svg = (output / "svg" / "slide_01.svg").read_text()
assert report["summary"] == {"slides": 1, "warnings": 1}, report["summary"]
assert len(diagnostics) == 1, diagnostics
assert diagnostics[0]["code"] == "color-structure-normalized", diagnostics[0]
assert diagnostics[0]["fallback"] == "retain recognized color attributes and modifiers", diagnostics[0]
assert diagnostics[0]["slide_index"] == 1, diagnostics[0]
assert diagnostics[0]["shape_name"] == "Rectangle 1", diagnostics[0]
assert "#4472C4" in svg and "#ED7D31" in svg
print("OK: tolerant import recovered #4472C4 and preserved #ED7D31")
'
```
Expected: both commands exit `0`; the importer reports one
`color-structure-normalized` warning and the assertion prints
`OK: tolerant import recovered #4472C4 and preserved #ED7D31`.
Run the same probe in strict mode:
```bash
python3 "skills/ppt-master/scripts/pptx_to_svg.py" \
"/tmp/ppt-master-color-smoke.pptx" \
--inheritance-mode flat \
--strict \
-o "/tmp/ppt-master-smoke-color-strict"
```
Expected: exit `1`, no traceback, and one error line:
```text
Error: PPTX-to-SVG conversion failed: Invalid DrawingML sRGB color structure
```
## `source_to_md/web_to_md.py`
Convert web pages to Markdown and download images locally.
```bash
python3 scripts/source_to_md/web_to_md.py https://example.com/article
python3 scripts/source_to_md/web_to_md.py https://url1.com https://url2.com
python3 scripts/source_to_md/web_to_md.py -f urls.txt
python3 scripts/source_to_md/web_to_md.py https://example.com -o output.md
python3 scripts/source_to_md/web_to_md.py https://example.com --emit-result /tmp/result.json
```
When `curl_cffi` is installed (included in `requirements.txt`), this script
automatically impersonates a modern Chrome TLS fingerprint, which lets it
fetch WeChat Official Accounts (`mp.weixin.qq.com`) and other sites that
block Python's default TLS fingerprint. No extra flags needed. If
`curl_cffi` is not available, it falls back to plain `requests`.
On success, the converter writes `<output>.conversion_profile.json` beside the
Markdown output.
`--emit-result` is for wrapper scripts that need the actual saved Markdown path
when the converter derives a title-based filename.
## Image Orientation Review
Run this review when the user requests orientation correction, converted text
asks the reader to rotate the device, or a downloaded asset is visibly
sideways. EXIF and dimensions may trigger review, but they cannot determine the
semantic direction of pixels that are already stored sideways.
Generate a labeled static contact sheet. This command previews the first frame
after EXIF normalization and does not modify source images:
```bash
python3 ${SKILL_DIR}/scripts/rotate_images.py sheet <images_directory>
```
The default output is
`<images_directory>/../analysis/<directory>_orientation_contact_sheet.jpg`.
Inspect it with the current multimodal agent, identify only visually confirmed
rotations, and write a temporary JSON list. `rotation` is clockwise degrees and
must be `90`, `180`, or `270`:
```json
[
{"path": "/absolute/path/to/sideways.jpg", "rotation": 270}
]
```
Apply the confirmed fixes and regenerate image facts:
```bash
python3 ${SKILL_DIR}/scripts/rotate_images.py fix /tmp/orientation_fixes.json
python3 ${SKILL_DIR}/scripts/analyze_images.py <images_directory>
```
GIF files are excluded: `sheet` does not list them, and `fix` rejects a batch
that references one so all GIF files remain unchanged.
Do not infer a rotation from prose, EXIF, or aspect ratio alone, and do not
launch the HTML `gen` command in source intake. `auto` remains an in-place EXIF
normalizer. `gen` is a compatibility UI that runs the same normalization before
writing HTML; neither belongs to source intake.
+316
View File
@@ -0,0 +1,316 @@
# Image Tools
> **Design boundary**: keep provider credentials explicit, keep in-pipeline
> acquisition manifest-driven, and treat external image references as authoring
> inputs while delivery writes self-contained SVG previews and native PPTX
> media.
Image tools cover formula rendering, prompt-based AI generation, web image search, image inspection, and Gemini watermark removal.
## `latex_render.py`
Manifest-driven LaTeX formula renderer. Default Generate has Strategist write
`images/formula_manifest.json` after Typography confirmation; Quick Generate
has the current agent write the same resource manifest without confirmation.
This script renders only those declared formulas to transparent PNGs and writes
dimensions back into the manifest.
```bash
python3 scripts/latex_render.py <project_path>
python3 scripts/latex_render.py <project_path> --dry-run
python3 scripts/latex_render.py <project_path> --providers codecogs,quicklatex,mathpad,wikimedia
```
Manifest shape:
```json
{
"providers": ["codecogs", "quicklatex", "mathpad", "wikimedia"],
"items": [
{
"id": "formula_001",
"latex": "E = mc^2",
"display": "block",
"color": "#1D1D1F",
"background": "#FFFFFF",
"transparent": true,
"dpi": 300,
"filename": "formula_001.png"
}
]
}
```
Output files land directly under `project/images/`. Formula filenames should use a shared `formula_` prefix, e.g. `formula_001.png`. The default provider chain is `codecogs,quicklatex,mathpad,wikimedia`; each provider is tried automatically until one succeeds, and the winning provider is recorded back into the manifest. `--providers` or manifest-level `providers` may override the order, but all four are available as no-key fallbacks. Formula PNGs are transparent by default. `background` is the temporary render matte and local background-removal reference; set `transparent: false` only when an opaque final formula asset is intentional. The script does not scan `spec_lock.md` or source documents for `$...$`; formula selection belongs to the active resource owner.
## `image_gen.py`
Unified image generation entry point.
This script is the **Path A** API/proxy executor for generated images. Default
Generate checks `design_spec.md §I / AI Image Acquisition Path` before manifest
mode: only `api` / `auto` permits Path A; a missing or unknown value fails
closed and returns to Step 4 recovery. Quick Generate has no Design Spec: use
the explicit active-context path when supplied, otherwise `auto` selects the
A → B → C chain defined in
[`image-generator.md`](../../references/image-generator.md) §7 without asking.
In either profile, `host-native` uses the host image tool directly and `manual`
uses the read-only Markdown sidecar.
```bash
python3 scripts/image_gen.py "A modern futuristic workspace"
python3 scripts/image_gen.py "Abstract tech background" --aspect_ratio 16:9 --image_size 4K
python3 scripts/image_gen.py "Concept car" -o projects/demo/images
python3 scripts/image_gen.py --list-backends
```
Backends are grouped into Core / Extended / Experimental tiers. Run `python3 scripts/image_gen.py --list-backends` for the current list.
Backend selection:
```bash
python3 scripts/image_gen.py "A cat" --backend openai
python3 scripts/image_gen.py "A cinematic portrait" --backend minimax
python3 scripts/image_gen.py "A product launch hero image" --backend qwen
python3 scripts/image_gen.py "科技感背景图" --backend zhipu
python3 scripts/image_gen.py "A product KV in cinematic style" --backend volcengine
```
Configuration sources:
1. Current process environment variables
2. First `.env` found in this order:
- Current working directory
- Skill directory (e.g. `~/.agents/skills/ppt-master/.env`)
- Clone repo root
- `~/.ppt-master/.env`
The active backend must always be selected explicitly via `IMAGE_BACKEND`.
Example `.env`:
```env
IMAGE_BACKEND=openai
OPENAI_API_KEY=sk-xxx
OPENAI_MODEL=gpt-image-2
# Optional proxy
# OPENAI_BASE_URL=http://127.0.0.1:3000/v1
# OpenAI-compatible provider knobs:
# OPENAI_SIZE_PRESET=auto
# OPENAI_RESPONSE_FORMAT=auto
# OPENAI_QUALITY=auto
# Allowed values: png / jpeg / webp
# OPENAI_OUTPUT_FORMAT=png
# jpeg/webp only, 0-100
# OPENAI_OUTPUT_COMPRESSION=80
# gpt-image-2: auto / opaque
# OPENAI_BACKGROUND=auto
# auto / low
# OPENAI_MODERATION=auto
```
Example process environment:
```bash
export IMAGE_BACKEND=openai
export OPENAI_API_KEY=sk-xxx
export OPENAI_MODEL=gpt-image-2
export OPENAI_OUTPUT_FORMAT=png
```
Current process environment wins over `.env`.
OpenAI backend notes:
- `gpt-image-2` is the default OpenAI model.
- Requests are sent with plain `requests.post()` to improve compatibility with
OpenAI-compatible proxies that block the OpenAI SDK's `httpx` transport.
- For `gpt-image-2`, `image_size=512px` means a low-quality draft preset, not a literal 512px edge. The model requires both edges to be multiples of 16px, a long:short ratio no greater than 3:1, and total pixels between 655,360 and 8,294,400.
- `OPENAI_BACKGROUND=transparent` is not supported by `gpt-image-2`; use `auto` or `opaque`.
- If `OPENAI_OUTPUT_FORMAT=jpeg` or `webp`, generated files use `.jpg` or `.webp` extensions instead of `.png`.
- OpenAI-compatible providers that reject OpenAI-specific fields can use `OPENAI_RESPONSE_FORMAT=omit`, `OPENAI_QUALITY=omit`, and `OPENAI_SIZE_PRESET=<preset>`. Valid response formats are `auto`, `b64_json`, `url`, and `omit`; valid size presets are `auto`, `legacy`, `gpt-image`, `gpt-image-2`, and `dall-e-2`.
Example `.env` for Agnes AI through the OpenAI-compatible backend:
```env
IMAGE_BACKEND=openai
OPENAI_API_KEY=your-agnes-key
OPENAI_MODEL=agnes-image-2.1-flash
OPENAI_BASE_URL=https://apihub.agnes-ai.com/v1
OPENAI_SIZE_PRESET=gpt-image-2
OPENAI_RESPONSE_FORMAT=omit
OPENAI_QUALITY=omit
```
Use provider-specific keys only (e.g. `GEMINI_API_KEY`, `OPENAI_API_KEY`). See `.env.example` in clone mode or `${SKILL_DIR}/.env.example` in skill-install mode for the full list per backend.
`IMAGE_API_KEY`, `IMAGE_MODEL`, and `IMAGE_BASE_URL` are intentionally unsupported.
If you keep multiple providers in one `.env` or environment, `IMAGE_BACKEND` must explicitly select the active provider.
Recommendation:
- Default to the Core tier for routine PPT work
- Use Extended only when you need a specific model style
- Treat Experimental backends as opt-in
Example `.env` for MiniMax image backend:
```env
IMAGE_BACKEND=minimax
MINIMAX_API_KEY=your-api-key
# Optional: override base URL (defaults to https://api.minimaxi.com, domestic China endpoint)
# Use https://api.minimax.io for overseas access
# MINIMAX_BASE_URL=https://api.minimax.io
# MINIMAX_MODEL=image-01
```
## `image_treat.py`
Create a non-destructive PNG derivative from one bitmap already prepared under
`<project_path>/images/`. Use this only when a slide needs a baked bitmap effect;
crop, mask, rotation, mirror, opacity, shadow, scrim, outline, and overlap remain
native SVG/PPT treatments. This tool does not perform semantic background
removal: use `slice_images.py --alpha` for flat-color keys, an already prepared
RGBA asset or the active host image editor for a standalone cutout, and
[`image-generator.md`](../../references/image-generator.md) §4.4 only for
registered subject/base layers.
```bash
python3 scripts/image_treat.py projects/demo hero.jpg \
--output hero_soft.png --brightness 0.9 --contrast 1.1 --blur 12
python3 scripts/image_treat.py projects/demo hero.jpg \
--output hero_duotone.png --duotone "#14213D" "#FCA311"
```
Supported operations are brightness, contrast, desaturation/grayscale,
duotone, and Gaussian blur. They compose in a fixed order: brightness →
contrast → tone treatment → blur. Desaturation, grayscale, and duotone are
mutually exclusive. At least one option must produce a real change; animated
or multi-frame sources are rejected rather than reduced to one frame.
Both input and output are bare filenames directly under `images/`; output must
be a new `.png` file. The tool keeps the EXIF-corrected display dimensions,
leaves any alpha mask unchanged, and never overwrites the source or an existing
derivative. If `images/image_sources.json` contains the source filename, the
new record inherits that legal provenance and records `derived_from` plus the
ordered `treatments`. Run `analyze_images.py` after all planned derivatives are
ready so the inventory reflects the files that SVG authoring will consume.
## `analyze_images.py`
Analyze objective image-file facts in a project directory before writing the
design spec or authoring SVG.
```bash
python3 scripts/analyze_images.py <project_path>/images
```
The tool does not resolve a canvas or recommend a left/right, top/bottom, or
other slide layout. Its atomic CSV records EXIF-corrected native dimensions and
`AspectRatio`, the objective aspect-ratio category, optional source
`SourceDisplayRatio`, format, actual transparent-pixel presence, usage count,
and bitmap/vector capability facts. An empty folder rewrites a header-only
report; unreadable supported files still refresh the report and produce a
non-zero exit.
Use this as the default factual inventory; it does not perform semantic image
understanding or choose composition. Generate planning follows the Strategist's
context-first boundary: source context, captions / alt text / titles, filenames,
user notes, and existing resource records come first. Only an already-selected
provided/web asset whose focal-safe crop, overlay contrast, or quiet region
remains materially ambiguous may be inspected for that placement; this never
reopens selection or provenance, never bulk-opens the image folder, and never
restores routine readback of AI-generated images.
## `image_search.py`
Zero-config web image search across openly-licensed providers. Sister tool to `image_gen.py` — used when the resource list row has `Acquire Via: web`.
```bash
python3 scripts/image_search.py "offshore wind farm" \
--filename cover_bg.jpg --slide 01_cover \
--orientation landscape -o projects/demo/images
```
For multiple web rows, `--batch images/image_queries.json` searches them concurrently (modest default, `--concurrency N` / `IMAGE_SEARCH_CONCURRENCY` to tune) instead of one call per row — the web sister of `image_gen.py --manifest`. Schema and status semantics: [`image-searcher.md`](../../references/image-searcher.md) §5.
Providers (Pexels / Pixabay are tried first when keyed; Openverse and Wikimedia are zero-config fallbacks):
| Provider | Config | Strength |
|---|---|---|
| `pexels` | recommended: `PEXELS_API_KEY` | modern stock photography, people, workplace, lifestyle |
| `pixabay` | recommended: `PIXABAY_API_KEY` | broad type coverage including photos and illustrations |
| `openverse` | zero-config | fallback aggregator: Wikimedia + Flickr + museums + rawpixel |
| `wikimedia` | zero-config | educational, scientific, geographic, historical |
Default search chain (when `--provider` is unset): configured Pexels, configured Pixabay, Openverse, then Wikimedia. Missing keyed credentials are silently skipped. Keyed providers broaden stock-photo coverage but are optional; zero-config providers remain valid.
`image_search.py` uses the same `.env` lookup order as `image_gen.py`, so skill installs can keep `PEXELS_API_KEY` / `PIXABAY_API_KEY` in `~/.ppt-master/.env`.
Query guidance:
Keep the Design Spec §VIII `Reference` as the full visual/crop intent; write a separate concise provider query for this CLI. Start with the shortest phrase that preserves identity, but retain exact multi-word names and necessary disambiguators beyond four words.
| Case | Pattern |
|---|---|
| Generic stock concept | `boardroom meeting` |
| China-specific landmark | Precise official place/identity name plus necessary geography |
| Avoid | Negative prompt wording such as `not tourist snapshot` |
License filter:
- **Default**: search all providers with `cc0,pdm,pexels,pixabay,cc by,cc by-sa` allowed together. The chosen image may be `no-attribution` or `attribution-required`; Executor adds an inline credit only when needed.
- `--strict-no-attribution` restricts the search to `cc0,pdm,pexels,pixabay` — useful for full-bleed hero images or templates that cannot host a credit element.
Pin a provider, refuse attribution, or override the manifest path:
```bash
# Pin Wikimedia
python3 scripts/image_search.py "Olympics opening ceremony" \
--filename event.jpg --provider wikimedia \
--orientation landscape -o projects/demo/images
# Strict mode — refuse CC BY / CC BY-SA
python3 scripts/image_search.py "abstract gradient" \
--filename hero.jpg --strict-no-attribution \
-o projects/demo/images
```
Suitability & manual replacement (a web top hit is metadata-relevant, not guaranteed visually right):
- By default only the best match is downloaded, plus a downscaled review copy at `images/.review/<stem>.jpg` (the placed asset stays full-resolution).
- For exact subjects (landmarks, people, companies, products), use `--require-terms` or batch `required_terms` so visually plausible but wrong metadata is rejected before ranking. Example: `--require-terms Chongqing --require-terms "Jiefangbei|Liberation Monument"`. Keep proper-name / geography anchors; do not broaden to generic terms like `canyon`, `stone pillar`, or `ancient town` just to improve coverage.
- `--save-candidates` (with `--max-candidates`, default 4) keeps an opt-in escalation pool under `candidates/<stem>/`; review it, then `--promote candidate_03.jpg --filename <name>.jpg`.
- `--from-url <url> --filename <name>.jpg` downloads a user-chosen image URL and replaces the target (recorded `license_tier: manual`) — the model-agnostic manual path; works even without a multimodal model.
Full review / escalation flow: [`image-searcher.md`](../../references/image-searcher.md) §5.
Output:
- Image saved to the specified output directory (auto-converts webp → jpg via Pillow when the filename extension demands)
- `image_sources.json` manifest with full provenance (provider, license, license_tier, author, source URL, dimensions, attribution_text)
- Manifest is idempotent on `filename` and written atomically; damaged existing provenance blocks replacement
Allowed licenses (default): CC0, Public Domain, Pexels License, Pixabay Content License, CC BY, CC BY-SA. Auto-rejected: CC BY-NC, CC BY-ND, CC BY-NC-SA, CC BY-NC-ND, all rights reserved, unknown.
The full role-level reference (intent → query translation, on-slide attribution contract) is in [`references/image-searcher.md`](../../references/image-searcher.md).
## `gemini_watermark_remover.py`
Remove Gemini watermark assets after manual download.
```bash
python3 scripts/gemini_watermark_remover.py <image_path>
python3 scripts/gemini_watermark_remover.py <image_path> -o output_path.png
python3 scripts/gemini_watermark_remover.py <image_path> -q
```
Notes:
- Requires `scripts/assets/bg_48.png` and `scripts/assets/bg_96.png`
- Best used after downloading “full size” Gemini images
Dependencies:
```bash
pip install Pillow numpy
```
@@ -0,0 +1,273 @@
# Mask and Gradient Maintenance Smoke
Run this manual smoke from the repository root after changing gradient
validation, gradient import/export, native background promotion, icon
expansion, or mask rejection. It keeps XML in memory except for temporary SVG
fixtures; do not turn it into a test framework or example deck.
```bash
python3 - <<'PY'
import math
import re
import sys
import tempfile
from pathlib import Path
from xml.etree import ElementTree as ET
scripts = Path("skills/ppt-master/scripts").resolve()
sys.path.insert(0, str(scripts))
from pptx_to_svg.color_resolver import ColorPalette
from pptx_to_svg.fill_to_svg import _angle_to_unit_endpoints, resolve_fill
from svg_quality_checker import SVGQualityChecker
from svg_to_pptx.drawingml.converter import (
SvgNativeConversionError,
convert_svg_to_slide_shapes,
)
from svg_to_pptx.drawingml.styles import build_gradient_fill
from svg_to_pptx.drawingml.utils import (
parse_project_linear_gradient_coordinate,
project_gradient_errors,
project_mask_errors,
)
SVG_NS = "http://www.w3.org/2000/svg"
def svg(fragment):
return ET.fromstring(
f'<svg xmlns="{SVG_NS}" viewBox="0 0 1280 720" '
f'data-pptx-page-role="content">{fragment}</svg>'
)
valid = svg(
"""<defs>
<linearGradient id="linear">
<stop offset="0" stop-color="#2563EB"/>
<stop offset="100%" stop-color="#F97316" stop-opacity="0.4"/>
</linearGradient>
<radialGradient id="radial" cx="0.25" cy="0.7" r="0.8">
<stop offset="0" stop-color="#FFFFFF"/>
<stop offset="1" stop-color="#0F172A"/>
</radialGradient>
</defs>
<rect id="bg" x="0" y="0" width="1280" height="720"
fill="url(#linear)"/>"""
)
assert not project_gradient_errors(valid)
linear, radial = list(valid.find(f"{{{SVG_NS}}}defs"))
linear_xml = build_gradient_fill(linear)
radial_xml = build_gradient_fill(radial)
assert '<a:lin ang="0" scaled="1"/>' in linear_xml
assert '<a:alpha val="40000"/>' in linear_xml
assert '<a:path path="circle">' in radial_xml
assert (
'<a:fillToRect l="25000" t="70000" r="75000" b="30000"/>'
in radial_xml
)
radial.set("fx", "0.8")
radial.set("fy", "0.2")
focused_xml = build_gradient_fill(radial)
assert (
'<a:fillToRect l="80000" t="20000" r="20000" b="80000"/>'
in focused_xml
)
native_gradient = ET.fromstring(
f'<root xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main">'
f'{focused_xml}</root>'
)[0]
restored = resolve_fill(native_gradient, None)
assert 'cx="0.5" cy="0.5" r="0.5" fx="0.8" fy="0.2"' in restored.defs[0]
assert '<a:fillToRect l="80000" t="20000" r="20000" b="80000"/>' in (
build_gradient_fill(ET.fromstring(restored.defs[0]))
)
radial.set("fx", "0")
radial.set("fy", "0")
outside_focus_errors = project_gradient_errors(valid)
assert any(
"must lie within the canonical circle" in error
for error in outside_focus_errors
), outside_focus_errors
try:
build_gradient_fill(radial)
except ValueError as exc:
assert "must lie within the canonical circle" in str(exc)
else:
raise AssertionError("outside radial focus reached DrawingML")
radial.set("fx", "0.8")
radial.set("fy", "0.2")
diagnostics = []
palette = ColorPalette(
None,
None,
strict=False,
diagnostic_sink=lambda code, message, fallback: diagnostics.append(
(code, message, fallback)
),
)
outside_native_xml = focused_xml.replace(
'l="80000" t="20000" r="20000" b="80000"',
'l="0" t="0" r="100000" b="100000"',
)
outside_native_gradient = ET.fromstring(
f'<root xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main">'
f"{outside_native_xml}</root>"
)[0]
normalized = resolve_fill(outside_native_gradient, palette)
assert " fx=" not in normalized.defs[0]
assert " fy=" not in normalized.defs[0]
assert any(
code == "path-gradient-focus-normalized"
for code, _message, _fallback in diagnostics
)
with tempfile.TemporaryDirectory(prefix="ppt-master-gradient-smoke-") as tmp:
source = Path(tmp) / "gradient.svg"
source.write_text(
ET.tostring(valid, encoding="unicode"),
encoding="utf-8",
)
trace = []
slide_xml, *_ = convert_svg_to_slide_shapes(source, trace_out=trace)
assert slide_xml.count("<p:bg>") == 1
assert "<a:gradFill>" in slide_xml
assert trace[0]["summary"]["promoted_backgrounds"] == 1
assert any(
event.get("decision") == "native-background"
for event in trace[0]["events"]
)
invalid_gradients = [
(
"""<linearGradient id="single">
<stop offset="0" stop-color="#2563EB"/>
</linearGradient>""",
"requires at least two direct <stop> children",
),
(
"""<linearGradient id="descending">
<stop offset="1" stop-color="#2563EB"/>
<stop offset="0" stop-color="#F97316"/>
</linearGradient>""",
"offsets must be non-decreasing",
),
(
"""<linearGradient id="zero" x1="0.5" y1="0.5" x2="0.5" y2="0.5">
<stop offset="0" stop-color="#2563EB"/>
<stop offset="1" stop-color="#F97316"/>
</linearGradient>""",
"linear gradient axis must not collapse to one point",
),
]
for definition, expected in invalid_gradients:
errors = project_gradient_errors(svg(f"<defs>{definition}</defs>"))
assert any(expected in error for error in errors), errors
mask_cases = [
"""<defs><mask id="fade"><rect width="1" height="1"/></mask></defs>""",
"""<rect width="100" height="100" mask="url(#fade)"/>""",
"""<rect width="100" height="100" style="mask: url(#fade)"/>""",
]
for fragment in mask_cases:
errors = project_mask_errors(svg(fragment))
assert any("unsupported SVG mask" in error for error in errors), errors
checker = SVGQualityChecker()
with tempfile.TemporaryDirectory(prefix="ppt-master-mask-smoke-") as tmp:
for index, fragment in enumerate(mask_cases, start=1):
source = Path(tmp) / f"mask-{index}.svg"
source.write_text(
ET.tostring(svg(fragment), encoding="unicode"),
encoding="utf-8",
)
checked = checker.check_file(str(source))
assert any("mask" in error.lower() for error in checked["errors"])
try:
convert_svg_to_slide_shapes(source)
except SvgNativeConversionError as exc:
assert "invalid project mask" in str(exc)
else:
raise AssertionError(f"native export accepted {source.name}")
with tempfile.TemporaryDirectory(prefix="ppt-master-icon-mask-smoke-") as tmp:
project = Path(tmp)
icon_dir = project / "icons" / "imported"
icon_dir.mkdir(parents=True)
(icon_dir / "masked.svg").write_text(
f"""<svg xmlns="{SVG_NS}" viewBox="0 0 24 24">
<defs>
<mask id="fade">
<rect x="0" y="0" width="24" height="24" fill="#FFFFFF"/>
</mask>
</defs>
<rect x="0" y="0" width="24" height="24"
fill="#000000" mask="url(#fade)"/>
</svg>""",
encoding="utf-8",
)
source = project / "icon-mask.svg"
source.write_text(
ET.tostring(
svg(
"""<use data-icon="imported/masked"
x="20" y="20" width="24" height="24"/>"""
),
encoding="unicode",
),
encoding="utf-8",
)
checked = checker.check_file(str(source))
assert any(
"Icon imported/masked" in error and "mask" in error.lower()
for error in checked["errors"]
)
try:
convert_svg_to_slide_shapes(source)
except SvgNativeConversionError as exc:
assert "invalid project mask" in str(exc)
else:
raise AssertionError("native export accepted a masked icon")
x1, y1, x2, y2 = _angle_to_unit_endpoints(30)
assert any(value < 0 or value > 1 for value in (x1, y1, x2, y2))
for value in (x1, y1, x2, y2):
assert math.isclose(
parse_project_linear_gradient_coordinate(str(value)),
value,
abs_tol=1e-9,
)
roundtrip = svg(
f"""<defs>
<linearGradient id="roundtrip"
x1="{x1:.9f}" y1="{y1:.9f}" x2="{x2:.9f}" y2="{y2:.9f}">
<stop offset="0" stop-color="#2563EB"/>
<stop offset="1" stop-color="#F97316"/>
</linearGradient>
</defs>"""
)
assert not project_gradient_errors(roundtrip)
roundtrip_gradient = roundtrip.find(
f"{{{SVG_NS}}}defs/{{{SVG_NS}}}linearGradient"
)
angle = int(
re.search(
r'<a:lin ang="(\d+)"',
build_gradient_fill(roundtrip_gradient),
).group(1)
) / 60000
assert math.isclose(angle, 30, abs_tol=0.01), angle
print("Mask and gradient smoke: passed")
PY
```
The three invalid-gradient cases, the outside-circle radial focus, all three
direct mask forms, and a mask hidden inside a `data-icon` asset must produce
the named shared-validator errors in both Checker and direct export. The legal
cases must retain stop alpha, round-trip an in-circle focus, center an imported
outside-circle focus with a diagnostic, promote the full-canvas gradient to
one native `p:bg`, default an unpositioned linear gradient to horizontal, and
recover approximately 30 degrees from the importer's out-of-unit-box endpoint
form.
@@ -0,0 +1,302 @@
# Multilingual Text Maintenance Smoke
Run this manual smoke from the repository root after changing Confirm UI
language handling, DrawingML text export, native tables/charts, notes, or
document metadata. It uses only temporary files and does not add an automated
test suite.
```bash
PYTHONPATH="skills/ppt-master/scripts:skills/ppt-master/scripts/confirm_ui" python3 - <<'PY'
import json
import zipfile
from pathlib import Path
from tempfile import TemporaryDirectory
from xml.etree import ElementTree as ET
from pptx import Presentation
from confirm_ui.server import create_app
from language_tags import (
LanguageTagError,
language_uses_rtl,
normalize_language_tag,
)
from svg_to_pptx.drawingml.context import ConvertContext
from svg_to_pptx.drawingml.converter import convert_svg_to_slide_shapes
from svg_to_pptx.native_objects import _build_native_chart
from svg_to_pptx.native_objects.table import _build_native_table
from svg_to_pptx.pptx_package.builder import create_pptx_with_native_svg
from svg_to_pptx.pptx_package.cli import _declared_primary_language
from svg_to_pptx.pptx_package.notes import create_notes_slide_xml
def reject_language(value):
try:
normalize_language_tag(value)
except LanguageTagError:
return
raise AssertionError(f"invalid language accepted: {value}")
canonical = {
"ES_mx": "es-MX",
"RU_ru": "ru-RU",
"AR_sa": "ar-SA",
"HE_il": "he-IL",
"HI_in": "hi-IN",
"TH_th": "th-TH",
"KO_kr": "ko-KR",
"fil_ph": "fil-PH",
"zh_hans": "zh-Hans",
"de-CH-1901": "de-CH-1901",
"en-u-nu-latn": "en-u-nu-latn",
}
for raw, expected in canonical.items():
assert normalize_language_tag(raw) == expected
for raw in ("und", "zh", "en--US", "Arabic", "x-private"):
reject_language(raw)
assert language_uses_rtl("ar-Arab-SA")
assert not language_uses_rtl("ar-Latn-SA")
assert language_uses_rtl("en-Arab-US")
samples = {
"en-US": "Summary 2026",
"zh-Hans": "年度总结 2026",
"ja-JP": "年間まとめ 2026",
"ko-KR": "연간 요약 2026",
"es-ES": "Resumen 2026",
"ru-RU": "Итоги 2026",
"ar-SA": "ملخص 2026",
"he-IL": "סיכום 2026",
"hi-IN": "सारांश 2026",
"th-TH": "สรุป 2026",
}
rtl_languages = {"ar-SA", "he-IL"}
with TemporaryDirectory(prefix="ppt-master-multilingual-smoke-") as tmp:
root = Path(tmp)
# Confirm UI canonicalizes Stage 1 and persists the same project language.
project = root / "confirm-project"
confirm = project / "confirm_ui"
confirm.mkdir(parents=True)
recommendation = {
"stage": "stage1",
"lang": "en",
"primary_language": "AR_sa",
"audience": {"value": "Team"},
"communication_intent": {"value": "Explain"},
"audience_outcome": {"value": "Understand"},
"core_message": {"value": "Result"},
"delivery_context": {"value": "Meeting"},
"artifact_afterlife": {"value": ""},
"content_divergence": {"value": ""},
"recommend": {"canvas": "ppt169"},
}
(confirm / "recommendations.stage1.json").write_text(
json.dumps(recommendation),
encoding="utf-8",
)
app = create_app(str(project), idle_timeout=0)
app.testing = True
client = app.test_client()
response = client.get("/api/recommendations")
assert response.status_code == 200
assert response.get_json()["primary_language"] == "ar-SA"
response = client.post(
"/api/confirm",
json={
"stage": "stage1",
"primary_language": "ar-SA",
"canvas": "ppt169",
"audience": "Team",
"communication_intent": "Explain",
"audience_outcome": "Understand",
"core_message": "Result",
"delivery_context": "Meeting",
"artifact_afterlife": "",
"content_divergence": "",
},
)
assert response.status_code == 200
result = json.loads((confirm / "result.json").read_text(encoding="utf-8"))
assert result["primary_language"] == "ar-SA"
# The execution lock is the only export-time project-language source.
lock_project = root / "lock-project"
lock_project.mkdir()
lock_template = """# Execution Lock
## communication
{language}- audience: team
- objective: explain
- core_message: result
"""
lock = lock_project / "spec_lock.md"
lock.write_text(
lock_template.format(language="- primary_language: ES_mx\n"),
encoding="utf-8",
)
assert _declared_primary_language(lock_project) == "es-MX"
lock.write_text(lock_template.format(language=""), encoding="utf-8")
assert _declared_primary_language(lock_project) is None
for index, (language, sample) in enumerate(samples.items(), 1):
stem = f"slide-{index}"
svg = root / f"{stem}.svg"
svg.write_text(
'<svg xmlns="http://www.w3.org/2000/svg" '
'viewBox="0 0 1280 720">'
'<rect x="0" y="0" width="1280" height="720" fill="#FFFFFF"/>'
f'<text x="100" y="140" font-size="42" '
f'font-family="Arial" fill="#111111">{sample}</text>'
"</svg>",
encoding="utf-8",
)
slide_xml, *_ = convert_svg_to_slide_shapes(
svg,
index,
verbose=False,
primary_language=language,
)
assert f'lang="{language}"' in slide_xml
assert ("rtl=\"1\"" in slide_xml) == (language in rtl_languages)
assert ("<a:rtl val=\"1\"/>" in slide_xml) == (
language in rtl_languages
)
ascii_svg = root / f"{stem}-ascii.svg"
ascii_svg.write_text(
'<svg xmlns="http://www.w3.org/2000/svg" '
'viewBox="0 0 1280 720">'
'<text x="100" y="140" font-size="42">2026 AI</text>'
"</svg>",
encoding="utf-8",
)
ascii_xml, *_ = convert_svg_to_slide_shapes(
ascii_svg,
index + 20,
verbose=False,
primary_language=language,
)
assert f'lang="{language}"' in ascii_xml
assert 'rtl="1"' not in ascii_xml
assert "<a:rtl val=\"1\"/>" not in ascii_xml
context = ConvertContext(primary_language=language)
marker = ET.Element("g")
table = _build_native_table(
marker,
context,
{
"x": 10,
"y": 10,
"width": 600,
"height": 180,
"columns": [sample, "2026 AI"],
"rows": [["A", "B"]],
"style": {"font_family": "Arial"},
},
)
assert f'lang="{language}"' in table.xml
assert all(slot in table.xml for slot in ("<a:latin ", "<a:ea ", "<a:cs "))
chart_context = ConvertContext(primary_language=language)
_build_native_chart(
marker,
chart_context,
{
"x": 10,
"y": 220,
"width": 600,
"height": 300,
"type": "column",
"title": sample,
"categories": ["A", "B"],
"series": [{"name": sample, "values": [1, 2]}],
"style": {"font_family": "Arial"},
"show_legend": True,
},
)
chart_xml = next(
value.decode("utf-8")
for part, value in chart_context.package_files.items()
if part.startswith("ppt/charts/chart") and not part.endswith(".rels")
)
assert f'<c:lang val="{language}"/>' in chart_xml
assert f'lang="{language}"' in chart_xml
notes_xml = create_notes_slide_xml(
1,
sample + "\n2026 AI",
language,
)
assert f'lang="{language}"' in notes_xml
assert ("rtl=\"1\"" in notes_xml) == (language in rtl_languages)
output = root / f"{index}.pptx"
assert create_pptx_with_native_svg(
[svg],
output,
canvas_format="ppt169",
verbose=False,
transition=None,
notes={stem: sample + "\n2026 AI"},
pptx_structure="flat",
structure_name="multilingual-smoke",
primary_language=language,
)
with zipfile.ZipFile(output) as archive:
assert archive.testzip() is None
packaged_slide = archive.read(
"ppt/slides/slide1.xml"
).decode("utf-8")
packaged_notes = archive.read(
"ppt/notesSlides/notesSlide1.xml"
).decode("utf-8")
core = archive.read("docProps/core.xml").decode("utf-8")
assert f'lang="{language}"' in packaged_slide
assert f'lang="{language}"' in packaged_notes
assert f"<dc:language>{language}</dc:language>" in core
assert len(Presentation(str(output)).slides) == 1
# A legacy lock with no language field keeps the previous per-run path.
legacy_svg = root / "legacy.svg"
legacy_svg.write_text(
'<svg xmlns="http://www.w3.org/2000/svg" '
'viewBox="0 0 1280 720">'
'<text x="100" y="140" font-size="42">한국어 2026</text>'
"</svg>",
encoding="utf-8",
)
legacy_output = root / "legacy.pptx"
assert create_pptx_with_native_svg(
[legacy_svg],
legacy_output,
canvas_format="ppt169",
verbose=False,
transition=None,
pptx_structure="flat",
structure_name="legacy-smoke",
)
with zipfile.ZipFile(legacy_output) as archive:
assert archive.testzip() is None
legacy_slide = archive.read(
"ppt/slides/slide1.xml"
).decode("utf-8")
assert 'lang="ko-KR"' in legacy_slide
print("Multilingual text smoke: passed")
PY
```
Expected output:
```text
Multilingual text smoke: passed
```
The RTL contract is paragraph `a:pPr rtl="1"` plus run-level `a:rtl` only
when the run contains strong RTL characters. Do not use `rtlCol`; it controls
column order, not paragraph direction.
@@ -0,0 +1,279 @@
# PPTX Animation Core
The shared animation core owns the object-effect vocabulary, trigger
semantics, OOXML timing writer, semantic read-back, and package validation for
PowerPoint OOXML. Per-element animation remains opt-in: generated PPTX export
defaults to `none`, exactly as before this validation upgrade.
## 1. Ownership
| Concern | Owner |
|---|---|
| Effect registry, timing writer, and read-back | `scripts/pptx_animations.py` |
| Sidecar parsing and SVG target discovery | `svg_to_pptx/animation_config.py` |
| SVG group-to-shape mapping | `svg_to_pptx/drawingml/converter.py` |
| Generated PPTX resolution and validation | `svg_to_pptx/pptx_package/builder.py` |
| Narration timing merge | `svg_to_pptx/pptx_package/narration.py` |
| Public authoring contract | `references/animations.md` |
| Customization stage | `workflows/stages/customize-animations.md` |
**Hard rule**: only the generated SVG-to-PPTX route writes object
animations. Direct-PPTX routes preserve source animations and run structural
package validation; they do not resolve or author animation effects.
---
## 2. Domain Model
`groups.<id>` accepts either one backward-compatible effect object or one
non-empty `effects[]` array; the forms are exclusive and every array row names
`effect`. Both expand into the same row model, so repeated shape targets are
valid. Legacy rows also accept `trigger`; omitted row settings inherit the
resolved slide animation.
One resolved row contains these fields:
| Field | Meaning |
|---|---|
| Target | Positive PowerPoint shape id written to `p:spTgt@spid` |
| Effect | One canonical PowerPoint-authored preset class / id / subtype / behavior-tree signature |
| Trigger | Row-specific `on-click`, `with-previous`, or `after-previous`; omitted values inherit the resolved slide Start mode |
| Trigger shape | Optional different top-level group; maps to PowerPoint `On Click of` |
| Duration | Finite positive schedule duration; scalable native behavior trees preserve their internal timing ratios |
| Delay | Finite non-negative row offset; shape-trigger rows use it as `TriggerDelayTime` |
| Order | Positive integer sidecar order; ties retain stable SVG group order, then `effects[]` index |
| Effect options | Effect-specific `direction`, `amount`, `color`, `font_name` (one installed PowerPoint face, required for Change Font; not a CSS list), `relative`, or `size` values from PowerPoint `EffectParameters` |
| Timing options | Repeat count/span, auto-reverse, rewind, accelerate/decelerate, bounce-end ratio, and restart policy |
| Completion / cue | Optional dim/hide behavior and packaged `.m4a`/`.mp3`/`.wav` sound |
Modes resolve before XML writing:
| Mode | Resolution |
|---|---|
| `auto` | Generic entrance only: deterministic semantic mapping from the SVG group id |
| `mixed` | Generic entrance only: deterministic cycle over canonical PowerPoint entrance presets |
| `random` | Generic entrance only: stable seeded choice from the same canonical entrance pool |
| `none` | No object-animation sequence |
The same effective input produces the same `random` choices. When enabled,
`--conversion-trace` records each resolved row and effect, so a generated deck
can be audited without replaying the resolver.
`animation_config.py scaffold` is neutral: object defaults are `none`, and
empty `{}` group placeholders inherit no motion until populated.
Bundled sound discovery/materialization is a workflow concern, not part of the
animation core. After the SVG and object-motion solution are complete,
`sound_sync.py` may copy only selected namespaced ids into
`<project>/sounds/`; new sidecars then reference those project-relative WAV
paths. Existing low-level project-relative/absolute `.m4a`, `.mp3`, and `.wav`
inputs remain compatible. The core never resolves a library id or reads
`templates/sounds/` directly; see [`animations.md`](../../references/animations.md)
§2.2.
---
## 3. Canonical Registry and Compatibility Inputs
The canonical registry contains 203 PowerPoint-authored presets:
| Category | Key prefix | Count | Example |
|---|---|---:|---|
| Entrance | `entrance_*` | 53 | `entrance_bounce` |
| Emphasis | `emphasis_*` | 33 | `emphasis_spin` |
| Motion path | `path_*` | 64 | `path_circle` |
| Exit | `exit_*` | 53 | `exit_faded_zoom` |
The 29 established short names remain valid only as compatibility inputs.
Normalization resolves them to canonical PowerPoint-authored presets before
selection, XML writing, read-back, tracing, or validation.
| Compatibility input | Canonical preset |
|---|---|
| `appear`, `cut` | `entrance_appear` |
| `fade` | `entrance_fade` |
| `fly`, `fly_left`, `fly_right`, `fly_top` | `entrance_fly` |
| `zoom` | `entrance_zoom` |
| `wipe`, `wipe_left`, `wipe_right`, `wipe_up`, `wipe_down` | `entrance_wipe` |
| `split`, `blinds`, `checkerboard`, `dissolve`, `random_bars`, `peek` | matching `entrance_*` preset |
| `wheel`, `box`, `circle`, `diamond`, `plus`, `strips`, `wedge`, `stretch`, `expand`, `swivel` | matching `entrance_*` preset |
`cut` maps to `entrance_appear` because current PowerPoint exposes no separate
Cut object-animation preset. Old Fly/Wipe names desugar to the canonical effect
plus `effect_options.direction`; legacy `wheel` desugars to
`entrance_wheel` plus `amount: 4`. New output never writes those aliases.
Together with the 29 accepted compatibility names, the public input surface
contains 232 keys. New selections, generated sidecars, conversion traces,
writers, and documentation examples use canonical keys; short names exist only
at compatibility input boundaries.
The shipped `pptx_animation_presets.json` contains the PowerPoint-authored
`p:cTn` row for every native effect. Complex effects use combinations of
`p:set`, `p:anim`, `p:animClr`, `p:animEffect`, `p:animMotion`, `p:animRot`,
and `p:animScale`; reducing them to one filter would silently change the
effect. `pptx_animations.py --list` prints the full categorized public
registry; `pptx_animations.py --describe <effect>` prints that effect's exact
option values and shared timing/completion contract.
Native presets map to the object-capable `MsoAnimEffect` values. Media play,
pause, stop, and play-from-bookmark are excluded because they require a
media/bookmark target rather than an SVG-derived shape. Exit effects use the
same entrance-capable `MsoAnimEffect` identity with PowerPoint's exit flag and
serialize as `presetClass="exit"`.
Paragraph/text-range build controls are likewise outside this writer: generated
targets are top-level SVG groups, not paragraph ranges. For that target model,
the public contract covers all PowerPoint effect parameters, timing modifiers,
completion controls, sound, and object-trigger linkage; Speed and smooth
start/end remain derived rather than duplicated.
**Hard rule — no downgrade**:
- Keep all 29 established short names accepted as compatibility inputs.
- Reject an unknown effect, mode, or trigger; never substitute another value.
- Reject booleans and non-finite, out-of-range, or invalidly ordered values.
- Reject a missing slide, missing group, or structural-layer target.
- Keep the generated-route default at `none`; validation does not opt a deck in.
---
## 4. Target Resolution
Generated object animation targets top-level SVG content groups. Explicit SVG
semantics are authoritative; the group-id chrome heuristic is only a fallback
for marker-free legacy SVGs.
| Target state | Behavior |
|---|---|
| Ordinary content group | Animatable; a legacy block resolves one row and `effects[]` may resolve several rows against the same final shape |
| Legacy chrome-like id | Skipped unless explicitly named in `animations.json` |
| Explicit sidecar group override | May override only the legacy chrome-name heuristic |
| `data-pptx-layer` or explicit static role/placeholder | Structural and never animatable |
An explicit sidecar entry cannot turn a Master/Layout/Slide structural layer or
an explicitly marked static page-frame role/placeholder into an animation
target. This boundary preserves PPTX structure even when a legacy id resembles
content.
---
## 5. OOXML Rules
The writer emits animation timing after `p:transition` and before `p:extLst`.
Normally this is one root `p:timing`; nonzero `bounce_end` uses PowerPoint's
native `mc:AlternateContent` with a p14 Choice and non-bounce Fallback. Each
branch contains a `tmRoot`, a `mainSeq` when ordinary Start rows exist, one
`interactiveSeq` per trigger-shape row, unique branch-local `p:cTn@id` values,
and same-slide `p:spTgt` references.
Trigger mapping:
| Public trigger | Object row `p:cTn@nodeType` |
|---|---|
| `on-click` | `clickEffect` |
| `with-previous` | `withEffect` |
| `after-previous` | `afterEffect` |
A row-level `trigger_shape` resolves to a different shape id and writes
PowerPoint's native `interactiveSeq` with `onClick` shape conditions. Its row
remains `clickEffect`; row `delay` becomes `TriggerDelayTime`. Ordinary rows
remain in `mainSeq` and keep the slide Start mode.
Row `trigger` overrides slide Start in both forms. `trigger_shape` implies
`on-click` and conflicts with an explicit non-`on-click` Start. Repeated
`p:spTgt@spid` values are valid distinct Animation Pane rows. Ordinary rows
retain page-wide `order`; trigger-shape rows retain their relative order in
separate `interactiveSeq` branches and do not interleave with `mainSeq`.
The writer does not emit `p:bldP` for grouped content or pictures. Microsoft
defines `p:bldP@spid` for a text-bearing `p:sp`; using it for `p:grpSp` or
`p:pic` creates an invalid build reference. Package validation still accepts a
valid source `p:bldP` that targets a text-bearing shape.
Direct-PPTX preserve mode also tolerates an unchanged legacy `p:bldP` that
targets an existing group/picture. Earlier PPT Master exports wrote this form;
the direct routes fingerprint and preserve it instead of blocking those decks.
New generated output never writes it, and generated-package validation remains
strict.
`entrance_appear` is the visibility-flip exception: its `p:set` behavior is
always 1ms. The configured positive duration remains the row's scheduling span
used when computing the next `after-previous` offset; read-back verifies the
1ms behavior and the resulting timeline offset separately. The compatibility
inputs `appear` and `cut` normalize to this canonical preset.
Other native presets with a
finite duration scale every finite behavior duration and start delay
proportionally, preserving multi-step timing such as bounce and teeter.
PowerPoint-authored instantaneous emphasis presets keep their `indefinite`
behavior duration; their configured duration remains the scheduling span for
the next `after-previous` row.
---
## 6. Validation and Read-Back
Before export, `animation_config.py validate` uses the writer's effect-behavior
test for `bounce_end` and resolves declared sound paths against the project
root. Missing paths, non-files, and unsupported audio extensions fail this
project-level preflight; field-only validation remains filesystem-independent.
Generated export reads every slide back before packaging and compares each
requested row with the serialized result:
- row count and row order, including stable repeated-target rows;
- trigger, optional trigger shape, and shape target;
- resolved effect key, preset class, filter, `presetID`, and `presetSubtype`;
- exact effect options, repeat/reverse/rewind/acceleration/bounce/restart
semantics, completion behavior, sound relationship, and playback span;
- native behavior-tree signature, serialized behavior duration, and computed
timeline offset (`entrance_appear` and instantaneous native presets use the
exceptions above).
After packaging, validation scans every slide part for root timing placement,
duplicate or malformed `p:cTn` ids, missing `p:spTgt` shapes, invalid build
targets, and unsupported generated effect tuples. A mismatch fails export
before the requested output file replaces an existing deck.
Narration injection parses and merges the slide DOM. It adds audio timing under
the existing `tmRoot`, allocates fresh ids, and preserves object animation.
For bounce timing it updates both p14 Choice and Fallback; unsupported nested
timing containers still fail safely instead of being duplicated.
Direct-PPTX routes run the structural package validator with generated-effect
enforcement disabled. This permits preservation of source/extension effects and
legacy group build rows while still rejecting corrupt timing IDs or missing
targets. Template fill and native enhancement fingerprint the source
object-animation tree before and after their allowed edits; any semantic change
fails. These routes have no object-animation write ownership.
The conversion trace is also the authoritative input for downstream video
motion. `video_motion_plan.py` preserves the resolved effect/options, direction,
row order, base and repeat-aware playback duration, absolute offset, object
bounds, and narration-derived slide advance while adding only renderer-specific enhancement parameters. Video
renderers must not bypass this read-back result and infer motion from sidecar
delay values alone.
---
## 7. Compatibility Scope
The compatibility contract covers PowerPoint OOXML and PowerPoint read-back.
Other presentation applications may interpret timing trees or filter values
differently; the exporter does not make an unconditional Keynote guarantee.
Official references:
- [Microsoft `MsoAnimEffect` enumeration](https://learn.microsoft.com/en-us/office/vba/api/powerpoint.msoanimeffect)
- [Microsoft `Sequence.AddEffect`](https://learn.microsoft.com/en-us/office/vba/api/powerpoint.sequence.addeffect)
- [Microsoft `Effect.Exit`](https://learn.microsoft.com/en-us/office/vba/api/powerpoint.effect.exit)
- [Microsoft animation-filter implementation notes](https://learn.microsoft.com/en-us/openspecs/office_standards/ms-oe376/a96dab70-2e72-4319-928d-0eb4b275ce58)
- [Microsoft `p:bldP` implementation restrictions](https://learn.microsoft.com/en-us/openspecs/office_standards/ms-oe376/40d17b6d-30c0-4c10-b042-b2597824a820)
- [Open XML SDK time-node values](https://learn.microsoft.com/en-us/dotnet/api/documentformat.openxml.presentation.timenodevalues?view=openxml-3.0.1)
- [Open XML SDK shape target](https://learn.microsoft.com/en-us/dotnet/api/documentformat.openxml.presentation.shapetarget?view=openxml-3.0.1)
See [`pptx-transitions.md`](./pptx-transitions.md) for the symmetric page-motion
core, MCE handling, and slide-advance contract.
See [`video-motion-plan.md`](./video-motion-plan.md) for the downstream
animation-to-video contract.
@@ -0,0 +1,326 @@
# PPTX Transition Core
The shared transition core owns page-transition vocabulary, slide-advance
semantics, OOXML mutation, MCE preservation, package timing enablement, and
read-back validation for every PPTX route.
## 1. Ownership
| Concern | Owner |
|---|---|
| Page transition registry | scripts/pptx_transitions.py |
| In-slide object animation | scripts/pptx_animations.py |
| Generated PPTX adapter | svg_to_pptx/pptx_package/builder.py |
| Template Fill adapter | template_fill_pptx/transitions.py |
| Native Enhance adapter | native_enhance_pptx_core.py |
| Public workflow | references/animations.md |
**Hard rule**: adapters resolve route policy, then call the shared core. They
must not build, replace, or patch a transition with route-local XML or regex.
---
## 2. Domain Model
| Layer | Meaning | OOXML |
|---|---|---|
| Enter | How the current slide appears from the preceding slide | Native effect, Effect Options, and duration |
| Sound | Optional cue played with the current slide's transition | `p:sndAc/p:stSnd/p:snd` with an embedded WAV relationship |
| Advance | How the current slide leaves for the next slide | advClick and advTm |
Enter policy:
| Policy | Behavior |
|---|---|
| preserve | Keep the source visual transition, including unknown extensions |
| replace | Write the requested supported effect |
| none | Write no visual effect |
Advance mode:
| Mode | Behavior |
|---|---|
| preserve | Keep source advClick and advTm |
| click | Click advance only |
| after | Timed advance only |
| both | Click or timed advance, whichever occurs first |
| narration | Timed advance from narration lead-in, audio duration, and page-tail padding; click disabled |
**Hard rule**: enter=none may coexist with a sound and/or timed advance. The
valid result is a non-visual `p:transition` with `p:sndAc`, advance attributes,
or both, and no visual-effect child.
---
## 3. Compatibility Contract
The native registry covers the complete current PowerPoint transition gallery:
12 Subtle effects, 29 Exciting effects, and 7 Dynamic Content effects. New
selection, sidecars, plans, conversion traces, help, and writers use only these
48 native keys.
Eight established low-level names remain valid at input boundaries. They
normalize to one native effect plus native `effect_options`; they are not a
second transition registry:
| Compatibility input | Native request |
|---|---|
| `strips` | `wipe` with `direction: right` |
| `circle` | `shape` with `shape: circle` |
| `diamond` | `shape` with `shape: diamond` |
| `plus` | `shape` with `shape: plus` |
| `newsflash` | `flash` |
| `pull` | `uncover` |
| `wedge` | `clock` with `style: wedge` |
| `wheel` | `clock` with `style: clockwise` |
Standard PresentationML effects use a direct `p:transition` carrier:
| Effect | Required primary child and attributes |
|---|---|
| fade | p:fade |
| push | p:push dir=r |
| wipe | p:wipe dir=r |
| split | p:split |
| cut | p:cut |
| random_bars | p:randomBar dir=vert |
| shape | p:circle |
| uncover | p:pull dir=r |
| cover | p:cover dir=r |
| dissolve | p:dissolve |
| checkerboard | p:checker |
| blinds | p:blinds dir=vert |
| clock | p:wheel spokes=1 |
| random | p:random |
| box | p:zoom |
| comb | p:comb |
Office 2010 effects use a `p14` Choice with a `p:fade` Fallback:
| Effect | Required primary child and attributes |
|---|---|
| reveal | p14:reveal dir=r |
| flash | p14:flash |
| ripple | p14:ripple |
| honeycomb | p14:honeycomb |
| glitter | p14:glitter |
| vortex | p14:vortex dir=r |
| shred | p14:shred dir=out |
| switch | p14:switch dir=r |
| flip | p14:flip dir=r |
| gallery | p14:gallery dir=r |
| cube | p14:prism dir=r |
| doors | p14:doors dir=vert |
| zoom | p14:warp dir=in |
| pan | p14:pan dir=r |
| ferris_wheel | p14:ferris dir=r |
| conveyor | p14:conveyor dir=r |
| rotate | p14:prism dir=r isContent=1 |
| window | p14:window |
| orbit | p14:prism dir=r isContent=1 isInverted=1 |
| fly_through | p14:flythrough |
Office 2012 effects use a `p15` Choice with a `p:fade` Fallback:
| Effect | Required primary child and attributes |
|---|---|
| fall_over | p15:prstTrans prst=fallOver invX=1 |
| drape | p15:prstTrans prst=drape invX=1 |
| curtains | p15:prstTrans prst=curtains |
| wind | p15:prstTrans prst=wind |
| prestige | p15:prstTrans prst=prestige |
| fracture | p15:prstTrans prst=fracture |
| crush | p15:prstTrans prst=crush |
| peel_off | p15:prstTrans prst=peelOff invX=1 |
| page_curl | p15:prstTrans prst=pageCurlSingle invX=1 |
| airplane | p15:prstTrans prst=airplane |
| origami | p15:prstTrans prst=origami |
`morph` uses `p159:morph option=byObject` in an Office 2015 Choice with a
`p:fade` Fallback. `none` is the explicit no-visual-effect input and therefore
is not a registry entry.
### 3.1 Native Effect Options
Use `effect_options` only with an explicit native `effect`. Omitted options use
the PowerPoint-authored `default` reported by `--describe-transition`:
| Effect | Supported options |
|---|---|
| `morph` | `morph_by`: `object`, `word`, `character` |
| `fade` | `style`: `smoothly`, `through_black` |
| `push`, `wipe`, `vortex`, `cube`, `pan`, `rotate`, `orbit` | `direction`: `left`, `right`, `up`, `down` |
| `split` | `orientation`: `horizontal`, `vertical`; `direction`: `out`, `in` |
| `reveal` | `direction`: `right`, `left`; `through_black`: boolean |
| `cut` | `through_black`: boolean |
| `random_bars`, `blinds`, `doors` | `orientation`: `vertical`, `horizontal` |
| `checkerboard` | `direction`: `across`, `down` |
| `comb`, `window` | `orientation`: `horizontal`, `vertical` |
| `shape` | `shape`: `circle`, `diamond`, `plus` |
| `uncover`, `cover` | `direction`: `left`, `right`, `up`, `down`, `up_left`, `up_right`, `down_left`, `down_right` |
| `fall_over`, `drape`, `wind`, `peel_off`, `airplane`, `origami` | `direction`: `right`, `left` |
| `page_curl` | `direction`: `right`, `left`; `pages`: `single`, `double` |
| `clock` | `style`: `clockwise`, `counterclockwise`, `wedge` |
| `ripple` | `origin`: `center`, `up_left`, `up_right`, `down_left`, `down_right` |
| `glitter` | `shape`: `diamond`, `hexagon`; `direction`: `right`, `left`, `up`, `down` |
| `shred` | `pattern`: `strips`, `rectangle`; `direction`: `out`, `in` |
| `switch`, `flip`, `gallery`, `ferris_wheel`, `conveyor` | `direction`: `right`, `left` |
| `box`, `zoom` | `direction`: `out`, `in` |
| `fly_through` | `direction`: `in`, `out`; `bounce`: boolean |
| All other native effects | No Effect Options |
Example:
~~~json
{
"transition": {
"effect": "page_curl",
"effect_options": {
"direction": "left",
"pages": "double"
},
"duration": 0.6,
"sound": "sounds/bigsoundbank/<file>.wav"
}
}
~~~
`transition.sound` is optional and accepts a `.wav` path resolved by the
generated-project adapter. A bundled-library choice is synced on demand only
after the visual transition plan is complete, then referenced by its
project-relative `sounds/<namespace>/<file>.wav` path. The adapter packages the
file and passes the shared core an embedded relationship id/name; the core
never reads `templates/sounds/` or resolves library ids.
Inspect the exact contract, including compatibility desugaring:
~~~bash
python3 skills/ppt-master/scripts/pptx_animations.py --describe-transition page_curl
python3 skills/ppt-master/scripts/pptx_animations.py --describe-transition diamond
~~~
Read-back reports the canonical native effect, its complete effective options,
the raw OOXML child, and raw attributes. This makes option loss a validation
failure rather than a silent downgrade.
**Hard rule — no downgrade**:
- Never rename or remove an established effect.
- Never omit its established direction or split attributes.
- Reject an unknown requested effect; never substitute fade.
- Preserve an unknown source effect when the route selects preserve.
- An extension counts as successful only when the primary Choice contains the
requested effect. A fallback alone is not success.
### 3.2 Deterministic Morph Identity
The generated route may add an explicit `slides.<destination>.morph` block to
bind direct-root SVG groups across adjacent slides. The sidecar stable key is
lowered to the same top-level `p:cNvPr@name="!!<key>"` on both final
Slide-local objects. This does not create an Animation Pane row and does not
change either object's numeric shape id.
The full plan is resolved before any SVG conversion so a source group named by
the following slide remains a stable top-level target. Names are written only
after flat/structured/preserve processing has finished; structured slide-shape
roster expectations are then refreshed. Package read-back requires:
- the declared source to be the immediately preceding public slide;
- exactly one `!!<key>` object on each side;
- the same OOXML object container type on both sides;
- Morph by object on the destination; and
- no structural target, same-slide name collision, group/key conflict, or
undeclared shared `!!` name on a Morph edge.
Morph without an explicit pair block retains PowerPoint's automatic matching
behavior. Explicit pairing is generated-route authoring; direct-PPTX routes
continue to preserve existing object names and transition XML.
---
## 4. Route Mapping
| Route | Default enter | Default advance | Compatibility note |
|---|---|---|---|
| Generated PPTX CLI | fade, 0.4s; no sound | click | auto-advance maps to both; an optional sidecar sound is project-local |
| Recorded narration | Preserve resolved enter | narration | none remains visually none |
| Template Fill | preserve source | preserve source | explicit effects replace; legacy advance_after maps to both |
| Native Enhance | Confirmed global/per-slide plan effect | Confirmed timing module | With audio off, an enabled global transition or explicit global `none` applies to all pages; with audio on, the scope flag controls non-narrated pages |
Template Fill changes source transitions only when its CLI or per-slide plan
selects a replacement, removal, or timed advance. Native Enhance uses its
confirmed plan. The public `create_pptx_with_native_svg` Python API retains its
legacy 0.5s default; the generated-deck CLI explicitly passes 0.4s.
---
## 5. OOXML Rules
**Slide child order**:
~~~text
p:cSld
p:clrMapOvr
p:transition or transition mc:AlternateContent
p:timing
p:extLst
~~~
One slide may contain at most one logical transition carrier:
- one direct p:transition; or
- one root-level mc:AlternateContent whose Choice/Fallback branches contain
p:transition.
Mutation rules:
| Operation | Direct transition | AlternateContent |
|---|---|---|
| preserve | Leave unchanged | Leave wrapper and branches unchanged |
| advance-only | Patch direct attributes | Patch Choice and Fallback identically |
| replace | Replace the direct carrier | Remove the whole wrapper, then write one carrier |
| none | Remove visual effect; retain a non-visual carrier when sound or timing is needed | Remove the whole wrapper; write a non-visual carrier when sound or timing is needed |
**Sound placement**: for a direct transition, append `p:sndAc` inside
`p:transition` after the visual-effect child, when present. For
`mc:AlternateContent`, write the same sound action into both Choice and
Fallback transition carriers so older Office consumers do not lose the cue.
The slide relationship targets one packaged WAV part; replacing or removing a
requested generated sound must not leave a dangling relationship.
**MCE prefix rule**: Requires and Ignorable values contain textual prefix
names. Serialization must retain bindings for those exact names. Renaming an
effect prefix without updating these attributes corrupts compatibility.
**Package timing rule**: when a route writes advTm, set
ppt/presProps.xml p:presentationPr/p:showPr useTimings=1. Do not write showPr
into ppt/presentation.xml.
---
## 6. Validation and Read-Back
Reject:
- unknown effect names;
- options without an explicit native effect;
- unknown option fields or values for the selected effect;
- non-finite values, including NaN and Infinity;
- duration less than or equal to zero;
- a missing/non-file transition sound, a non-WAV transition sound, or an
unresolved/dangling sound relationship;
- negative advance or narration padding;
- booleans passed as numeric API values;
- multiple logical transition carriers;
- unresolved MCE Requires or Ignorable prefixes.
- invalid forced-Morph adjacency, identity uniqueness, object type, or
destination effect.
Read-back must report the canonical native effect and complete effective
options, while keeping the primary Choice child separate from the fallback. It
must also report raw effect attributes, carrier type, duration, click mode, and
automatic advance time, plus the transition sound relationship/name when
present. Package validation must run after writing, not only before mutation.
Use inline smoke commands and gitignored projects/_smoke_* artifacts. Do not
add a tests directory or test_*.py files.
+284
View File
@@ -0,0 +1,284 @@
# Project Tools
> **Import boundary**: move only sources already under the repository's
> `projects/` tree. Copy every other local path, even when `--move` is supplied.
> Use `--copy` to preserve a projects-local source.
Project tools create, validate, and inspect the standard PPT Master workspace.
## `project_manager.py`
Main entry point for project setup and validation.
```bash
python3 scripts/project_manager.py init <project_name> --format ppt169
python3 scripts/project_manager.py import-sources <project_path> <source1_or_dir> [<source2_or_dir> ...]
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 validate <project_path>
python3 scripts/project_manager.py info <project_path>
python3 scripts/project_manager.py page-context <project_path> P07 [--pretty] [--record-usage]
python3 scripts/project_manager.py page-context-report <project_path>
```
Notes:
- `init --quick-generate`: `svg_output/` plus
`validation/workflow.log`; no README
- Files outside `projects/` are always copied into `sources/`
- `--move` applies only to sources under the repository's `projects/` tree
- A directly supplied supported bitmap is also copied into `images/` with a
collision-safe basename while its original remains archived in `sources/`
- Directory inputs are expanded non-recursively. After Step 1 conversion,
pass the source file/directory once when generated Markdown lives beside the
original source. If Step 1 used `-o` to write Markdown elsewhere, pass both
the original source path/directory and the Markdown output path/directory.
- A projects-local supplied source directory left strictly empty after import
(or empty from the start) is removed; every directory outside `projects/`
remains untouched. `--copy` never removes directories.
- Files already under `projects/` move into `sources/` by default. Pass `--copy`
to preserve them in place.
- `--move` and `--copy` are mutually exclusive.
- Normal Generate authoring reads `templates/design_spec_reference.md`, writes
the complete `design_spec.md` from scratch, then reads
`templates/spec_lock_reference.md` and writes the complete lock projection.
It does not call either scaffold command.
- Optional `scaffold-spec` creates `design_spec.md` from
`templates/scaffolds/design_spec.md`; `scaffold-lock` creates `spec_lock.md`
from `templates/scaffolds/spec_lock.md`. Both substitute project/canvas
metadata deterministically and refuse to overwrite an existing artifact.
- `validate` parses the existing Markdown artifacts against
`templates/schemas/design_spec.schema.json` and
`templates/schemas/spec_lock.schema.json`. It reports missing sections and
fields, illegal enums, malformed page keys, and unmet conditional sections.
When optional custom reference lists are present, it also requires every id
to resolve to the matching mode, visual-style, or image-rendering catalog,
rejects duplicates, and rejects reference rows on non-custom selections;
it does not rewrite either artifact or compare their values for textual
equality. It also does not prove final-confirmation → Design Spec fidelity or
Design Spec/context → lock semantic fidelity; Generate Step 4 owns those two
gates before this structural validation. Validation reads the planning
artifacts only and never reopens `confirm_ui/result.json`; the final result is
consumed once into the Design Spec before validation begins. The design schema is structural lint for
the human-readable brief; the lock schema owns machine execution values. For
structured template use, strict input prototypes must match their assigned
Master/Layout; adaptive input prototypes retain the assigned Master while a
new output Layout already declared by Strategist is cross-validated after its
generated SVG exists. Versioned
Direct-authored current artifacts and optional scaffolds carry the schema
marker. Markerless legacy artifacts are left on
their prior validation path with a warning;
malformed or unsupported markers are errors.
- PPTX-family inputs are enriched automatically under `analysis/` with
per-deck `<stem>.identity.json` / `<stem>.slide_library.json` plus the shared
multi-deck index `source_profile.json` (`decks[]`).
Multi-deck per project: several PPTX imports each get their own `<stem>.*`
artifacts and a `decks[]` entry; re-importing the same stem replaces its entry.
### On-demand page execution view
`page-context` projects `design_spec.md` and `spec_lock.md` into one compact
current-page view on stdout. The default command is read-only; `--pretty`
changes JSON formatting only. Before projection it revalidates the machine lock
and selected template-root identities; design-brief values are not treated as
a second lock. Slide headings at H3–H6 remain readable by the projector. Normal
generation retains the complete planning artifacts once per valid execution
context and does not invoke this command before every page; use it only for an
explicit diagnostic, routing check, or context-usage measurement.
Each invocation deliberately includes the bounded `global` anchor set as a
cross-page continuity view, not a color/font allowlist. `lock_source` binds that projection to the current
`spec_lock.md` SHA. `page_context` contains the current §IX brief, rhythm,
resources, and conditional template/chart assignment. `reference_set` contains
`kind`, scoped path, SHA, and `once-per-execution-context` policy for the
project/template Design Specs and selected prototype/chart SVGs. The project
Design Spec additionally carries
`same_context_edit_policy: targeted-readback-and-rebind`: when the current main
agent makes a bounded repair in a valid uncompacted context that preserves
roster/order/identity/communication, it reads back only the exact changed
fragments and validates them before continuing. Fresh, compacted, external,
unknown, or mismatched changes require one complete Design Spec and lock read.
The deprecated `--bundle` flag remains accepted as a compatibility no-op. It
never appends a Design Spec, prototype SVG, chart SVG, manifest, or text-slot
sidecar to stdout.
The projection keeps project-specific forbidden rules; universal SVG and icon
rules remain in the always-loaded execution core. Image rows are selected from
the current §IX brief, explicit §VIII page assignments, and mirror prototype
references. When those sources assign images elsewhere but not to the current
page, the view excludes those assigned images. Any still-unassigned legacy
image remains in a compatibility subset; `confirmed-none` is emitted only when
all locked images have a deterministic assignment elsewhere.
Mirror materialization may publish deterministic
`ppt-master.template-text-slots.v2-min` diagnostics. They are not page-context
or model inputs. The complete SVG remains the sole template authority; checker
and structured export validate output attributes, text/tspan topology, and
referenced-resource hashes against it internally.
`--record-usage` writes a derived snapshot to
`analysis/page-context/P<NN>.usage.json`. It hashes every input, measures the
exact compact stdout, and records the reference fingerprints. `tiktoken` is
loaded lazily with `o200k_base`; when unavailable, the command still succeeds
and records bytes, characters, hashes, and `tokens: null`.
`page-context-report` summarizes only fresh snapshots and identifies stale or
token-unavailable pages plus unique referenced files. Telemetry may be partial;
it does not measure once-loaded references, source reads, or other session
context.
Common formats:
- `ppt169`
- `ppt43`
- `xiaohongshu`
- `moments`
- `story`
- `banner`
- `a4`
Examples:
```bash
python3 scripts/project_manager.py init my_presentation --format ppt169
python3 scripts/project_manager.py scaffold-spec projects/my_presentation_ppt169_20251116 # optional
python3 scripts/project_manager.py scaffold-lock projects/my_presentation_ppt169_20251116 # optional
python3 scripts/project_manager.py validate projects/my_presentation_ppt169_20251116
python3 scripts/project_manager.py info projects/my_presentation_ppt169_20251116
python3 scripts/project_manager.py page-context projects/my_presentation_ppt169_20251116 P07 --record-usage
python3 scripts/project_manager.py page-context-report projects/my_presentation_ppt169_20251116
```
## `workflow_transcript.py` and `workflow_log.py`
Project initialization creates `validation/workflow.log` and records its own
milestone. Run later project-scoped Python tools normally:
```bash
python3 scripts/<tool>.py <project_path> <args...>
```
Their shared CLI bootstrap discovers the existing project log from the working
directory or command arguments. `workflow_transcript.py` records a UTC command
envelope plus explicit error/failure and receipt/report lines, bounded
warning/OK/stderr samples, limited summary context, and per-run omission counts;
no outer launcher or second Python process is used. It leaves full output on
the original console instead of copying it into the audit log. Commands before
project initialization are not backfilled. Binary-buffer writes, hidden child
output, and detached service activity are not recorded; Confirm UI and live
preview retain detailed output in their component `server.log` files. Their
shared detached-process launcher disables automatic workflow recording in the
long-running child while preserving the short foreground launcher's own record.
For a Python helper whose arguments and working directory do not identify the
active project, set the routing signal on the same command:
```bash
PPT_MASTER_PROJECT_PATH="<project_path>" python3 scripts/<helper>.py <args...>
```
This variable selects only the destination transcript; it does not authorize
the helper to read project artifacts or change its ownership.
Append a manual note only when an important audit detail has no owning command
output:
```bash
python3 scripts/workflow_log.py <project_path> "<material audit detail>"
```
Suitable notes include a material stage handoff or rework reason, a
user-approved exception, or a manual recovery choice. Do not duplicate
artifact contents, routine page progress, or private reasoning.
The log is append-only audit evidence. It is not a complete console transcript,
stage, quality, or artifact authority and is not read during normal generation
or resume. Inspect it only when the user explicitly requests a run review. An
automatic recording failure emits a warning but does not change the Python
tool's result; an explicit manual entry that cannot be written exits non-zero.
## `project_utils.py`
Shared helper module used by other scripts.
Typical use:
```python
from project_utils import get_project_info, validate_project_structure
```
You can also run it directly for quick checks:
```bash
python3 scripts/project_utils.py <project_path>
```
## `batch_validate.py`
Batch-check project structure and compliance.
```bash
python3 scripts/batch_validate.py examples
python3 scripts/batch_validate.py examples projects
python3 scripts/batch_validate.py --all
python3 scripts/batch_validate.py examples --export
```
Use this for repository-wide health checks before release or cleanup.
## `generate_examples_index.py`
Rebuild `examples/README.md` automatically.
```bash
python3 scripts/generate_examples_index.py
python3 scripts/generate_examples_index.py examples
```
## `pptx_template_import.py`
Unified PPTX preparation entry point for `/create-template`.
```bash
python3 scripts/pptx_template_import.py <template.pptx>
python3 scripts/pptx_template_import.py <template.pptx> -o <output_dir>
python3 scripts/pptx_template_import.py <template.pptx> --manifest-only
python3 scripts/pptx_template_import.py <template.pptx> --skip-manifest
python3 scripts/pptx_template_import.py <template.pptx> --embed-images
python3 scripts/pptx_template_import.py <template.pptx> --inheritance-mode both
python3 scripts/pptx_template_import.py <template.pptx> --inheritance-mode flat
python3 scripts/pptx_template_import.py <template.pptx> --inheritance-mode layered
```
Notes:
- Extracts reusable media assets from `ppt/media/`
- Summarizes slide size, theme colors, font metadata, and per-master theme metadata
- Resolves slide / layout / master relationships from OOXML relationships; every master and layout is included even when no sample slide currently references it
- Generates `manifest.json` (single source of truth for slide size, theme, per-master themes, assets, layouts, masters, placeholders, slides, SVG file paths, and page-type candidates), `native_structure.json`, `source_template.pptx`, `assets/`, `conversion-report.json`, and shape-level SVGs under `svg/`
- **SVG output defaults to the layered authoring source** (`--inheritance-mode layered`):
- `svg/` — layered template view for designers: every master and layout in the deck rendered once as `svg/master_*.svg` / `svg/layout_*.svg` (including ones no sample slide currently references); `svg/slide_NN.svg` contains only that slide's own shapes; `svg/inheritance.json` records parentage plus source-owned `showInheritedShapes` / `showMasterShapes` booleans.
- `svg-flat/` — optional verification view emitted only by `--inheritance-mode both`: each `slide_NN.svg` is self-contained (the effective visible Master/Layout contributions plus Slide-local content painted into one file), so opening any slide in isolation shows the full page like PowerPoint would. Background inheritance remains independent of inherited-shape visibility. Useful for previews, screenshots, and "did this slide actually render correctly" sanity checks.
- `manifest.json` records `svgFile` for slides / layouts / masters, `flatSvgFile` for slides when `svg-flat/` exists, placeholder type / index / geometry / base style, an asset map used by SVG `href` values, and common assets reused through slide / layout / master inheritance. Placeholder semantics keep `subTitle`, `obj`, `media`, and `dt` distinct as `subtitle`, `object`, `media`, and `date`.
- `conversion-report.json` owns tolerant source-recovery diagnostics; it is not a cache or a duplicate of the structural manifests
- Layered slide SVGs keep only the slide's own background; inherited master / layout backgrounds stay in the corresponding master / layout SVGs
- Placeholder guides are intentionally lightweight in `svg/` master / layout files; `svg-flat/` hides those guides and is the visual preview source
- Charts, SmartArt, diagrams, and OLE objects become typed placeholders in `svg/`; `svg-flat/` shows a preview image with a corner badge when one exists, otherwise a visible placeholder. Tables are converted into real SVG content.
- Pass `--inheritance-mode both` to add `svg-flat/`, or `--inheritance-mode flat` for the legacy round-trip view (single self-contained `svg/` tree without master/layout/inheritance files).
- SVG export reads OOXML directly via `pptx_to_svg` — no PowerPoint or Keynote dependency, runs on any platform
- `<image>` elements in `svg/` reference files in `assets/` directly; pass `--embed-images` to inline as data URIs instead
- External linked images and missing media are strict failures. Office vector media such as EMF / WMF are converted to PNG previews when the local toolchain can do so; otherwise the import fails instead of silently dropping content.
- Required in `/create-template` whenever the reference source is `.pptx`
- Default output directory is `<pptx_stem>_template_import/`
- Use `--manifest-only` when you explicitly want only the lightweight import output without slide SVG export
- Intended for template reference preparation, not for final 1:1 template delivery
Implementation note:
- Internal helpers for this workflow live under `scripts/template_import/`
## `error_helper.py`
Show standardized fixes for common project errors.
```bash
python3 scripts/error_helper.py
python3 scripts/error_helper.py missing_readme
python3 scripts/error_helper.py missing_readme project_path=my_project
```
@@ -0,0 +1,67 @@
# Prompt Audit — Budget and Governance Lint
> Maintainer-only, read-only. Audits the agent-facing Markdown corpus without modifying it and is intentionally not wired into CI or pre-commit hooks. Generation roles never load this doc, the tool, or its manifest.
## Run
```bash
python3 skills/ppt-master/scripts/prompt_audit.py # text summary
python3 skills/ppt-master/scripts/prompt_audit.py --json # stable JSON report
```
Requires `tiktoken` (not part of `requirements.txt` — end users never need it):
```bash
pip install 'tiktoken>=0.7.0'
```
Exit code `1` on any deterministic error. An unaccepted exact duplicate or schema projection is an actionable warning; heuristic near-duplicate candidates remain report-only information and do not enter `findings`. With `--json`, setup failures also use a stable `AUDIT_SETUP_ERROR` JSON envelope instead of a traceback or plain-text error.
## What It Checks
| Area | Failure class |
|---|---|
| Corpus and hot-file token ceilings | error on budget overflow |
| Declared load sets (route/stage scenarios) | error on budget overflow, unknown files, selector/registry drift |
| Load coverage | error when a corpus file is in no load set and has no `coverage.exempt` entry |
| Registry claims (layout patterns, modes, styles, renderings, types, charts) | error on ID/count/index drift |
| Markdown references and declared authority edges | error on broken links or unreferenced edges |
| Cross-file exact duplicates | warning until adjudicated via `duplicates.accepted` |
| Cross-file near duplicates | informational candidates in the report; no finding |
| Schema multi-definition | warning for each unaccepted owner-field / projection-path pair; accepted projections stay visible in the report |
| Accepted duplicate/schema drift | error when an accepted source or projection no longer matches |
## Manifest Maintenance — `prompt_audit_manifest.json`
The manifest is audit-only (`audit_only: true`, `runtime_consumed: false`); it is a lint fixture, never prompt context. It hand-transcribes the load rules stated in `SKILL.md` and the role/workflow docs, so **every change to read instructions in those docs must update the matching load set in the same change** — the coverage check catches unclassified files, but only humans can catch a changed read rule for an existing file.
- **New corpus file** → when no existing category exemption matches it, the audit fails with `LOAD_COVERAGE_GAP` until you add it to the load sets that read it or exempt it with a one-line reason. Exempt only material that never enters role context (for example, a legacy tombstone, generated maintenance asset, maintainer-only doc, or license notice); represent conditional runtime reads as incremental load sets.
- **Intentional exact duplicate** → run `--json`, copy the candidate's `kind`, `fingerprint`, and `paths` into `duplicates.accepted` with a reason. The acceptance identity is all three values, so separate path pairs with identical prose remain independently reviewable. Editing either reported raw block changes its fingerprint; stale acceptance fails with `DUPLICATE_ACCEPTED_STALE`.
- **Near duplicate** → inspect `duplicates.near` as heuristic maintenance information. It produces no warning. A maintainer may still record a stable intentional pair in `duplicates.accepted`; `--skip-near-duplicates` deliberately leaves accepted near pairs unchecked because that scan did not run.
- **Schema owner** → every configured field must have a field-local definition signal in its declared owner. Split fields into separate owner entries when they belong to different artifacts. Generic `key` / `value` prose elsewhere on a long line is not a grammar signal; explicit assignments, field-local grammar/format language, and forms such as `field ... one of ...` are.
- **Intentional schema projection** → run `--json`, then copy the field's `owner_fingerprint` and each reviewed projection's `path` / `fingerprint` into that `schema_grammars[]` entry. Classify every projection as `producer`, `consumer`, `reference`, or `compatibility`, and record a one-line reason:
```json
{
"source": "skills/ppt-master/templates/schemas/spec_lock.schema.json",
"fields": ["page_rhythm"],
"scan": ["skills/ppt-master/**/*.md"],
"accepted": [
{
"field": "page_rhythm",
"owner_fingerprint": "0123456789ab",
"projections": [
{
"path": "skills/ppt-master/references/executor-base.md",
"role": "consumer",
"fingerprint": "abcdef012345",
"reason": "Executor needs the selected page-rhythm key and closed values."
}
]
}
]
}
```
Acceptance is exact, not a path exemption. The owner fingerprint covers the configured field's owner contract; a projection fingerprint covers every current grammar-like line for that field in that path. Owner edits, projection edits, or projection removal fail with `SCHEMA_ACCEPTED_STALE`; a new field/path projection remains `SCHEMA_MULTIDEF_CANDIDATE`. The JSON report separates `schema_grammars[].open`, `.accepted`, and `.stale`, so adjudicated projections and drift remain auditable without turning stale accepted paths into duplicate warnings.
- **Budget ceilings** (`budget_policy: fixed_upper_bound`): budgets are stable, deliberately rounded limits rather than mirrors of the current token count. Establish a new ceiling with roughly 10% working headroom and round it up in 250-token increments below 10k, 1k increments below 100k, or 5k increments from 100k upward; the manifest loader enforces those increments. Once set, do not raise or lower a passing ceiling, including to restore headroom after prompt growth. Raise it only after the current audit reports `BUDGET_CORPUS`, `BUDGET_FILE`, or `BUDGET_LOAD_SET` against that exact ceiling; then choose the next rounded limit with comparable headroom, record the overflow-triggering scope in the same change, and leave it unchanged until another actual overflow.
@@ -0,0 +1,826 @@
# SVG Pipeline Tools
> **Maintenance boundary**: post-processing modules serve both the on-disk
> `svg_final/` preview and in-memory native PPTX conversion. Check both
> consumers before changing or removing a step.
These tools cover post-processing, SVG validation, speaker notes, recorded narration, and PPTX export.
The supported delivery contract has one PPTX path: `svg_output/` → the project SVG-to-DrawingML converter → native PPTX. The mandatory `finalize_svg.py` step separately creates self-contained `svg_final/` visual previews, which may be opened directly or inserted into PowerPoint as SVG pictures. There is no SVG-image PPTX output, and PowerPoint's manual Convert-to-Shape operation is unsupported.
## `svg_authoring_view.py`
Create a lightweight editable authoring IR bundle from one PPTX-imported SVG or
a directory of imported SVGs:
```bash
python3 scripts/svg_authoring_view.py <svg-file-or-directory> -o <output-dir> \
--projection-kind layered
```
The operation is non-destructive and refuses existing output files unless
`--force` is explicit. It never writes back to the source SVG. The JSON report
on stdout records original/projected byte counts and removals by category. The
output directory contains the editable SVGs, one model-readable
`authoring_summary.json`, and one tool-only `authoring_manifest.json`.
The projected copy:
- removes embedded `txbody` metadata;
- removes hidden native geometry carriers while retaining and unwrapping their
visible preview geometry;
- removes source-object identity/style/hash attributes that are only useful to
an exact import round trip;
- keeps visible paths, text, images, stable ids, Master/Layout root markers,
selected native-shape intent, and a document-local `data-pptx-source-ref` on
each imported logical object;
- rewrites relative local asset references for the projection's new location;
- compacts imported model-facing frames and safe transform page coordinates to
at most two decimals.
The summary stores the current SVG roster plus compact per-file canvas, size,
text, image, vector, placeholder, icon, and source-ref counts. Models read the
summary and editable SVGs; they do not read the machine manifest. The manifest
stores relative source/authoring filenames, source and initial authoring hashes,
and source element paths. It deliberately does not copy the opaque payload.
The authoring bundle is the editable source for template creation; the complete
imported SVG remains immutable native-payload backing. Final
`templates/*.svg` files are materialized and validated from that pair. The IR
directory itself is not a supported direct input to `svg_to_pptx.py`.
Regenerate the summary after direct edits that do not pass through one of the
in-place normalization tools:
```bash
python3 scripts/svg_authoring_view.py <authoring-dir> --refresh-summary
```
This projection is separate from canonical preset authoring. New project SVGs
and project-owned templates use the compact authored form: one atomic
`<g data-pptx-authoring="preset">` owns the preset intent and base paint, with
the registry-generated visible `<path>` layers as direct children. Quality
check and export rerender the locked registry to validate that group, so the
compact form has no hidden carrier, preview wrapper, or serialized preview
fingerprint. `pptx_to_svg.py` continues to emit the expanded carrier/preview
evidence required for import and round-trip decisions. The normative boundary
is owned by [`shared-standards-core.md`](../../references/shared-standards-core.md) §1.5, with
authoring guidance in
[`native-shape-authoring.md`](../../references/native-shape-authoring.md).
## Shape Boolean maintenance smoke
Run this manual smoke from the repository root after changing
`shape_boolean_svg.py`, preset geometry, path conversion, or custom-geometry
import/export. It uses only a gitignored `projects/_smoke_*` workspace and the
inline-smoke convention from [`code-style.md`](../../../../docs/rules/code-style.md)
§11; do not turn it into a test file or example deck.
```bash
python3 - <<'PY'
import re
import subprocess
import sys
import tempfile
import zipfile
from pathlib import Path
from xml.etree import ElementTree as ET
import pathops
project = Path(tempfile.mkdtemp(prefix="_smoke_shape_boolean_", dir="projects"))
scripts = Path("skills/ppt-master/scripts")
svg_output = project / "svg_output"
svg_output.mkdir()
(project / "spec_lock.md").write_text(
"""<!-- ppt-master-schema: spec-lock/v1 -->
# Execution Lock
## canvas
- viewBox: 0 0 1280 720
- format: ppt169
## communication
- audience:
- objective:
- core_message:
## mode
- mode: briefing
## visual_style
- visual_style: Boolean maintenance smoke
## colors
- bg: #FFFFFF
- primary: #2563EB
- accent: #F97316
- text: #0F172A
## typography
- font_family: Arial, sans-serif
- title_family: Arial, sans-serif
- body_family: Arial, sans-serif
- title: 36
- body: 20
## icons
- library: none
- inventory: none
## page_rhythm
- P01: dense
## pptx_structure
- mode: flat
## forbidden
- Unsupported SVG constructs
""",
encoding="utf-8",
)
def run_tool(script, *args):
result = subprocess.run(
[sys.executable, str(scripts / script), *map(str, args)],
capture_output=True, text=True,
)
assert result.returncode == 0, result.stderr or result.stdout
return result.stdout.strip()
preset = run_tool(
"preset_shape_svg.py", "render", "rightArrow",
"--id", "preset-source", "--frame", "500", "120", "240", "120",
"--fill", "#2563EB", "--stroke", "none",
)
source = project / "operands.svg"
source.write_text(
f"""<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1280 720">
<defs><clipPath id="clip"><rect width="80" height="80"/></clipPath></defs>
<g transform="translate(100 80) scale(1.2)">
<rect id="body" x="40" y="40" width="400" height="240" rx="20"
fill="#2563EB" stroke="#0F172A" stroke-width="5"
stroke-dasharray="10 4"/>
<circle id="cutout" cx="240" cy="160" r="70" fill="#F97316"/>
<text id="text-cutout" x="240" y="235" text-anchor="middle"
font-family="sans-serif" font-size="180" font-weight="700">01</text>
<text id="missing-font" x="240" y="235"
font-family="Definitely Missing Font" font-size="80">X</text>
<text id="nested-text" x="240" y="235"
font-family="sans-serif" font-size="80"><tspan>X</tspan></text>
<path id="open" d="M 40 320 L 260 320 L 260 420" fill="#2563EB"/>
<rect id="clipped" x="40" y="320" width="160" height="100"
clip-path="url(#clip)" fill="#2563EB"/>
<path id="imported" d="M 240 320 H 400 V 420 H 240 Z"
data-pptx-geometry-kind="custom" fill="#2563EB"/>
<rect id="dashoffset" x="440" y="320" width="120" height="100"
fill="#2563EB" stroke="#0F172A" stroke-dashoffset="2"/>
<rect id="non-scaling" x="500" y="40" width="150" height="120"
fill="#2563EB" stroke="#0F172A" stroke-width="5"
stroke-dasharray="10 4" vector-effect="non-scaling-stroke"/>
<circle id="non-scaling-cut" cx="625" cy="100" r="45" fill="#F97316"/>
<rect id="far" x="800" y="320" width="100" height="80" fill="#2563EB"/>
</g>
{preset}
<circle id="preset-cut" cx="690" cy="180" r="52" fill="#F97316"/>
</svg>
""",
encoding="utf-8",
)
operations = [
("union", "union", "preset-source", "preset-cut"),
("combine", "combine", "body", "cutout"),
("fragment", "fragment", "body", "cutout"),
("intersect", "intersect", "body", "cutout"),
("subtract", "subtract", "body", "cutout"),
("text-subtract", "subtract", "body", "text-cutout"),
]
expected_custom_shapes = 0
for index, (name, operation, first, second) in enumerate(operations, start=1):
fragment = run_tool(
"shape_boolean_svg.py", "render", source, "--operation", operation,
"--source", first, "--source", second, "--id", f"result-{name}",
)
paths = list(
ET.fromstring(
f'<svg xmlns="http://www.w3.org/2000/svg">{fragment}</svg>'
)
)
assert paths and all(path.tag.endswith("}path") for path in paths)
assert all(
token not in fragment
for token in ("clip-path=", "fill-rule=", "mask=", "transform=")
)
if operation == "fragment":
assert len(paths) > 1
assert [path.get("id") for path in paths] == [
f"result-{name}-{piece}"
for piece in range(1, len(paths) + 1)
]
else:
assert len(paths) == 1
assert paths[0].get("id") == f"result-{name}"
if operation == "combine":
assert all(path.get("stroke-width") == "6" for path in paths)
assert all(path.get("stroke-dasharray") == "12 4.8" for path in paths)
if operation == "subtract":
assert (paths[0].get("d") or "").count("M ") >= 2
expected_custom_shapes += len(paths)
(svg_output / f"{index:02d}_{name}.svg").write_text(
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1280 720" '
'data-pptx-page-role="content">'
'<rect x="0" y="0" width="1280" height="720" fill="#FFFFFF"/>'
f"{fragment}</svg>\n",
encoding="utf-8",
)
non_scaling = ET.fromstring(
run_tool(
"shape_boolean_svg.py", "render", source, "--operation", "union",
"--source", "non-scaling", "--source", "non-scaling-cut",
"--id", "result-non-scaling",
)
)
assert non_scaling.get("stroke-width") == "5"
assert non_scaling.get("stroke-dasharray") == "10 4"
assert non_scaling.get("vector-effect") == "non-scaling-stroke"
rejections = [
("union", "body", "open", "open subpath"),
("union", "body", "clipped", "uses clip-path"),
("union", "body", "imported", "PPTX import/round-trip metadata"),
("union", "body", "dashoffset", "stroke-dashoffset"),
("intersect", "body", "far", "produced no filled area"),
("subtract", "body", "missing-font", "cannot resolve an installed font"),
("subtract", "body", "nested-text", "child content is unsupported"),
]
for operation, first, second, expected_error in rejections:
rejected = subprocess.run(
[
sys.executable,
str(scripts / "shape_boolean_svg.py"),
"render", str(source), "--operation", operation,
"--source", first, "--source", second,
"--id", f"reject-{second}",
],
capture_output=True, text=True,
)
assert rejected.returncode != 0
assert expected_error in rejected.stderr, rejected.stderr
run_tool(
"svg_quality_checker.py", project,
"--quick-generate", "--format", "ppt169",
"--stage", "final", "--json",
)
pptx = project / "boolean-smoke.pptx"
run_tool("svg_to_pptx.py", project, "--quick-generate", "-o", pptx)
with zipfile.ZipFile(pptx) as archive:
slides = [
name
for name in archive.namelist()
if re.fullmatch(r"ppt/slides/slide\d+\.xml", name)
]
custom_shapes = sum(
archive.read(name).count(b"<a:custGeom>")
for name in slides
)
assert len(slides) == len(operations), slides
assert custom_shapes == expected_custom_shapes
readback = project / "readback"
run_tool(
"pptx_to_svg.py", pptx, "-o", readback,
"--inheritance-mode", "flat", "--strict",
)
slides = sorted((readback / "svg").glob("slide_*.svg"))
readback_custom_shapes = sum(
slide.read_text(encoding="utf-8").count('data-pptx-custgeom="')
for slide in slides
)
assert len(slides) == len(operations), slides
assert readback_custom_shapes == expected_custom_shapes
print(
f"Shape Boolean smoke: passed "
f"({expected_custom_shapes} custom shapes; {project})"
)
PY
```
The seven inline negative cases must return nonzero and match their expected
errors; every other command must pass. Open the printed
`boolean-smoke.pptx` path in PowerPoint: both shape-cut and text-cut Subtract
results must have real holes, and every Fragment sibling must remain separately
selectable.
## `compact_svg_coordinates.py`
Compact safe model-facing page-space coordinates without rewriting unrelated
SVG formatting:
```bash
python3 scripts/compact_svg_coordinates.py <svg-file-or-directory>
python3 scripts/compact_svg_coordinates.py <template-directory> \
--inplace --keep-native-frames
```
The default run is a dry-run JSON report. `--inplace` atomically replaces only
changed SVG files. The shared create-template final pass uses
`--keep-native-frames`: it compacts `data-pptx-bounds`, translation values,
rotation centers, and
matrix `e/f`, while preserving canonical
authored-preset or inline native frames. `svg_authoring_view.py` separately
compacts imported model-facing frames because unchanged mirror refs can recover
their exact coordinates from immutable lossless backing.
The compactor never rounds path/points geometry, normalized crop or nested
`viewBox` ratios, gradient offsets, opacity, scale arguments, rotation angles,
or matrix `a/b/c/d` coefficients. Type A mirror materialization invokes the
same compactor before native-record externalization; `standard` and `fidelity`
use the shared final pass before template validation.
## `extract_svg_assets.py`
Factor large vector subtrees out of lightweight authoring IR documents and
replace them with compact `<use data-icon>` references:
```bash
python3 scripts/extract_svg_assets.py <layered_svg_dir> \
--icons-dir <icons_dir> --icon-namespace imported \
--inplace --id-prefix layered
python3 scripts/extract_svg_assets.py <flat_svg_dir> \
--icons-dir <icons_dir> --icon-namespace imported \
--reuse-inventory <layered_inventory.json> \
--inplace --id-prefix flat
```
The first pass records a source fingerprint before namespacing each extracted
asset's internal ids. The second pass reuses a fingerprint-matched asset and
writes no duplicate SVG file. Unmatched flat-only subtrees still extract
normally. Use `--clean-stale` on both import-workspace passes to remove stale
generated files for their respective prefixes. In create-template workspaces,
`imported` is the fixed namespace: assets live once under `icons/imported/`, and
the working SVGs reference them as `data-icon="imported/<name>"`. Inventory
entries retain source refs from each extracted subtree, allowing expansion to
reconnect the authoring-manifest mapping. A rerun on an
already rewritten namespaced projection inventories those references and does
not progressively extract their remaining parent or sibling geometry. An
in-place pass over an authoring bundle refreshes `authoring_summary.json`
automatically.
## `mirror_template_materialize.py`
Compile one Type A PPTX import workspace into a deterministic structured mirror
template after the layered authoring IR has been reviewed and edited:
```bash
python3 scripts/mirror_template_materialize.py \
<import_workspace> <empty_template_workspace>
```
The command treats `<import_workspace>/authoring-svg/` as the sole editable
source. It reads the tool-only layered authoring manifest internally and
validates it against immutable lossless SVG
hashes, source PPTX hash, complete Master/Layout/Slide graph, inheritance
visibility facts, source-ref closure, and extracted-vector inventory before it
writes anything. It refuses a non-empty destination and stages the whole result
before atomic publication, so a failed preflight cannot leave a partial
template.
Materialization preserves source page order and emits one definition-only
`layout_<layout_key>.svg` for every source Layout unused by all source Slides.
It mechanically expands fixed Master/Layout group wrappers into direct atoms,
rehydrates only unchanged converter-supported Slide-local/slot refs, keeps the
current SVG fallback for edited refs, preserves explicit text hard breaks, and
removes every IR-only source ref. Imported axis-flipped groups retain their
geometry reflection while descendant SVG text receives a matching
counter-reflection, preserving PowerPoint's upright glyph appearance in browser
previews. Supported opaque `p:txBody`,
relationship-free `p:style`, and `a:custGeom` payloads are deduplicated into
`templates/native_payloads.json.gz`. Repeated native restoration attributes
are stored there as short `data-pptx-native-ref` records; page and
imported-vector SVGs retain only those record ids and content-hash payload
references. The native record referenced by an imported text placeholder
carrier owns its authoritative source frame, so the Slide-local frame can
differ from reusable Layout bounds without restoring long exact coordinates
inline. Structural Master/Layout, placeholder, layer, and editable-object
fields remain inline. Source `p:sldLayout@showMasterSp` and
`p:sld@showMasterSp` facts become canonical root
`data-pptx-show-master-shapes` and
`data-pptx-show-inherited-shapes` booleans.
Checker, template-structure validation, and export hydrate both store layers in
memory; legacy inline payload and v1 payload-only stores remain readable.
The published `ppt-master.template-execution-manifest.v1` roster points to one
compact `ppt-master.template-text-slots.v2-min` sidecar per prototype. Each text
slot contains only `selector`, `role`, `current_text`, `text_segments`, and
`tspan_count`; a top-level tool hash covers its selectors and immutable
text/tspan topology and attributes. These records are deterministic tool
diagnostics, not page-authoring inputs. Page-context emits only the complete
prototype's path and SHA for that reference, so the model reads the SVG once
per execution context and reuses it until the SHA changes. The model chooses
semantics and edits only existing visible text values, while checker and
structured export validate output attributes, text/tspan topology, and
referenced-resource hashes against
the prototype.
The output routes reusable vectors once to `icons/imported/`, bitmaps to
`images/`, and other referenced files to `templates/assets/`. The JSON report
reports payload occurrence, native-record, unique-byte, and compressed-store
counts and is written to stdout only. The command intentionally does not create
`templates/design_spec.md`; Template_Designer writes the package-specific rules
and page roster after materialization. This compiler is for Type A mirror materialization,
not `standard` / `fidelity`, loose Type B SVGs, ordinary generation, finalize,
or export.
## `extract_svg_pictures.py`
Normalize one deliberately selected complex SVG object into one PowerPoint
picture. The command accepts exact `<g id>` values only, writes each group as a
tight standalone SVG asset, embeds its local image/CSS dependencies, and
replaces the source group at the same parent index with one `<image>`. Native
export therefore emits one `p:pic` backed by SVG media.
```bash
python3 scripts/extract_svg_pictures.py \
"<workspace>/authoring-svg/<layered_svg_file>.svg" \
--select "<group_id>" \
--resource-root "<workspace>" \
--images-dir "<workspace>/picture-assets" \
--inplace
```
Imported PowerPoint groups normally provide `data-pptx-frame`, which is used
as the picture bounds. For a large standalone SVG without frame metadata, the
tool measures the selected group with Playwright; use repeated
`--bounds ID=x,y,width,height` values when browser measurement is unavailable
or when effect overflow needs an explicit frame. `--padding` expands the
chosen bounds. The generated `*_picture_asset_inventory.json` records the
bounds source, asset hash, copied definition ids, and embedded local resources.
Nested selections are accepted only through metadata-only `<g>` ancestors.
When an ancestor carries a transform, style, clip, opacity, or other visual
attribute, select that outer group instead; this prevents applying the ancestor
effect once inside the SVG asset and again to the replacement `<image>`.
Scripts, `foreignObject`, SVG animation, remote resources, and external SVG
fragment references fail closed; local image/CSS resources must stay inside
the declared `--resource-root` and are embedded into the asset.
An in-place rewrite inside an authoring bundle refreshes
`authoring_summary.json` automatically.
This operation belongs only to an explicit `create-template` normalization
decision in `standard` or `fidelity` mode. It does not choose groups, detect
repetition, infer a Master/Layout, or run during ordinary import, free
generation, mirror materialization, finalize, or export. Placeholder, native
single-shape, table/chart, icon-placeholder, and authored-preset groups are
rejected because they already own a different semantic route.
Do not confuse this tool with `extract_svg_assets.py`:
- `extract_svg_assets.py` is a model-readability optimization. It replaces
heuristic vector runs with `<use data-icon>`, then re-inlines them before
export so the PPTX still contains native shapes.
- `extract_svg_pictures.py` is an explicit representation change. It replaces
only named groups with `<image>`, so each result intentionally remains one
editable PowerPoint picture rather than individually editable paths.
## Recommended Pipeline
Run these steps one at a time. Wait for each command to exit successfully before
starting the next command.
When the effective Speaker Notes outcome in `design_spec.md §I` is enabled, run:
```bash
python3 scripts/total_md_split.py <project_path>
```
After `total_md_split.py` exits successfully, run:
```bash
python3 scripts/finalize_svg.py <project_path>
```
After `finalize_svg.py` exits successfully, run:
```bash
python3 scripts/svg_to_pptx.py <project_path>
```
When Speaker Notes is disabled, skip `total_md_split.py` and use
`python3 scripts/svg_to_pptx.py <project_path> --no-notes` for the final
command. This prevents stale files under `notes/` from being embedded.
Do not start another post-processing command while the current command is still
running. The canonical gates and success criteria are owned by
[`generate-pptx.md`](../../workflows/generate-pptx.md) Step 7.
## `finalize_svg.py`
Unified post-processing entry point. This is the preferred way to run SVG cleanup.
It aggregates:
- `embed_icons.py`
- static same-document `<use>` expansion from `svg_to_pptx/use_expander.py`
- `align_embed_images.py` (`crop-images` / `fix-aspect` / `embed-images` aliases route here)
- `flatten_tspan.py`
`svg_final/` remains a required Step 7.2 artifact even though the native exporter reads `svg_output/`. It is the self-contained visual reference and may be manually inserted as an SVG picture.
## `svg_to_pptx.py`
Convert project SVGs into PPTX.
```bash
python3 scripts/svg_to_pptx.py <project_path>
# Explicit compact image export:
python3 scripts/svg_to_pptx.py <project_path> --image-sizing display --image-scale 2 --image-quality 85
# Force original image bytes:
python3 scripts/svg_to_pptx.py <project_path> --no-image-optimize
python3 scripts/svg_to_pptx.py <project_path> --native-charts-and-tables
python3 scripts/svg_to_pptx.py <project_path> --pptx-structure structured # deck/layout template override
python3 scripts/svg_to_pptx.py <project_path> --pptx-structure flat # free-design/brand-only override
# Template-import visual round-trip diagnostic only:
python3 scripts/svg_to_pptx.py <template_import_output> -s svg-flat
# Post-processed-source comparison diagnostic only (never a release export):
python3 scripts/svg_to_pptx.py <project_path> -s final
python3 scripts/svg_to_pptx.py <project_path> --no-notes
python3 scripts/svg_to_pptx.py <project_path> -t none
python3 scripts/svg_to_pptx.py <project_path> --auto-advance 3
python3 scripts/svg_to_pptx.py <project_path> --animation mixed --animation-duration 0.8
python3 scripts/svg_to_pptx.py <project_path> --reflow-text # opt-in PowerPoint reflow
python3 scripts/svg_to_pptx.py <project_path> --no-merge # one text frame per visual line
python3 scripts/svg_to_pptx.py <project_path> --recorded-narration audio
python3 scripts/svg_to_pptx.py <project_path> --recorded-narration audio --animation-config animations.json
python3 scripts/svg_to_pptx.py <project_path> --recorded-narration audio --no-animations
```
Native image export defaults to `--image-sizing cap`: it preserves source bytes
when no resize or EXIF geometry normalization is required, and re-encodes only
images that require one of those transformations. The `display` command above
is an explicit compact export; `--no-image-optimize` disables all native image
optimization and forces original bytes.
The normal command reads `pptx_structure.mode` from `spec_lock.md`. For legacy
projects whose lock exists but predates that field, export emits one compatibility
warning and uses `flat`; no SVG regeneration is required. A missing `spec_lock.md`,
an explicit legacy/unknown mode, or a requested `structured` export without an
explicit current structured contract remains blocking.
Explicit direct generation may use the
[`quick-generate`](../../workflows/profiles/quick-generate.md) profile after the
current agent has converted/read sources, researched identified factual gaps,
and prepared the required images, icons, formulas, and resource manifests as
needed. That profile skips Strategist, Confirm UI, `design_spec.md`, and
`spec_lock.md`; it does not skip the resources required by the authored pages.
After the complete SVG roster exists, run its lockless final checker, then
export:
```bash
python3 scripts/svg_quality_checker.py <project_path> \
--quick-generate --stage final --json
python3 scripts/svg_to_pptx.py <project_path> --quick-generate
```
This direct-export flag takes `svg_output/` as its authored page source, resolves
valid project-local resources referenced by those pages, infers one consistent
canvas, uses flat converter-default package scaffolding, and does not read or
require `spec_lock.md`. Notes, motion, narration, native objects, conversion
trace, and other ordinary exporter capabilities remain available; notes,
custom object animation, and narration start off in Quick and may be enabled
when needed. The exporter refuses a missing, blocking, non-final, or stale
Quick final report before PPTX creation. Default-path output retains the normal
postflight report and `backup/` snapshot; explicit `-o` retains the ordinary
no-backup behavior. Existing source, analysis, image/icon/formula, and
resource-manifest artifacts remain untouched.
For generated-project narration, follow the
[`generate-audio`](../../workflows/stages/generate-audio.md) stage. It owns voice
selection, audio generation, and the narrated re-export workflow.
Behavior:
- Default output (either Generate profile, no `-o`):
- `exports/<project_name>_<timestamp>.pptx` — native editable pptx (canonical output)
- `validation/<project_name>_<timestamp>.report.json` — package postflight, quality-gate linkage, unresolved resource audit, and published part counts
- `backup/<timestamp>/svg_output/` — copy of authored SVG source for re-export without re-running the LLM
- `exports/` contains only final PPTX deliverables; machine-readable quality and postflight reports belong in `validation/`.
- The default Generate flow always runs `finalize_svg.py` before export. This directory is the self-contained SVG visual preview; it is not packaged as a second PPTX. Quick-generate deliberately skips it.
- In both Generate profiles, explicit `-o/--output` changes the native PPTX destination and skips `backup/`; the postflight report still uses the output stem under the project `validation/` directory.
- Postflight reruns ZIP integrity and published Slide count. Internal relationships,
structured-package validation, transitions, and animations are enforced before the
builder publishes the PPTX and are reported as `enforced-at-build`, not as repeated
postflight checks.
- `font_portability` warns when a complete font stack has no concrete family or when
the converter resolves its Latin / East Asian role to a typeface that normally
requires a custom installation. A recommended stack such as
`"Microsoft YaHei", Arial, sans-serif` does not warn merely because it ends with a
generic fallback.
- Multiline text export modes:
- Default: one editable frame retains authored breaks and disables PowerPoint wrapping. An ordinary generated frame uses PowerPoint's native resize-shape-to-fit-text behavior, so deleting a retained break expands the frame instead of leaving text outside it; imported exact frames and structured multiline placeholder carriers retain fixed-size behavior.
- `--reflow-text`: eligible same-size lines become flowing prose that PowerPoint may rewrap; a font-size change, list marker, or accepted larger gap remains a paragraph boundary. Legacy `--merge-paragraphs` aliases this mode.
- `--no-merge`: each dy-stacked line becomes an independent frame with its own placement.
- Detection is conservative: mixed-layout `<text>` falls back to per-line frames. Use `--reflow-text` only for resizable body copy and `--no-merge` only for independent line objects or absolute line positions.
- Native release export reads `svg_output/`. `-s final` is an explicit diagnostic override for comparing conversion behavior against post-processed SVGs; it does not change artifact ownership or create a supported release path.
- `svg_final/` may be opened directly or inserted into PowerPoint as an SVG picture. PowerPoint's manual Convert-to-Shape operation is outside the compatibility contract.
- On every SVG-authoring route, each file in `svg_output/` is the complete visible
page-design source. Templates and locks may guide authoring, but finalize/export
never use them to overlay visible content missing from the SVG. Notes, animation,
narration, transitions, and direct native-PPTX workflows keep their separate
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.
- 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.
- Omitting `--pptx-structure` reads `spec_lock.md`. Free-design, brand-only, and `template_reuse_scope: style` releases declare `mode: flat`, omit Master/Layout mappings and SVG structure metadata, and materialize one clean project-owned Master plus one Blank Layout from the current lock. Deck/layout templates use `mode: structured` only for `template_reuse_scope: mirror|layout`, with complete unique `pptx_masters` / `pptx_layouts` rosters and one `page_pptx_layouts` assignment per page. A template-backed Layout definition may remain unused by pages and still register in the final package.
- On structured template routes, every page root repeats Master/Layout keys and picker names. Master/Layout fixed visuals are direct semantic atoms. Ordinary layer `<g>` elements are invalid; one validated compact authored-preset `<g>` emitted by `preset_shape_svg.py` is the sole group exception because it compiles to one native shape.
- Every visible direct root `<g>` requires root-coordinate `data-pptx-bounds`; nested bounds are ignored. Frame/native metadata never replaces it; placeholder bounds also define the slot frame. Checker compares root bounds with `viewBox`, descendant text with its module using DrawingML wrapping headroom, and every estimable visible text carrier directly with the root `viewBox` before that headroom. Images, shapes, paths, `<use>`, effects, and object frames are excluded from module containment. Per side, ≤`1px` is ignored; module overflow ≤`5%` warns and >`5%` fails, while larger page text overflow always fails. Bounds never clip/reflow; unestimable visible text warns. A wholly off-canvas direct-root Morph endpoint may opt out of page containment with `data-pptx-morph-staging="true"`; it still needs valid module bounds, retained Morph uses an explicit pair, and partial overflow remains blocking.
- Missing root bounds fails on final pages/templates and under `--template-mode`; references warn until adapted.
- On structured template routes, each normal slot is a direct root `<g id>` with semantic type, positive design-zone bounds, and exactly one compatible carrier. Composite `object` slots use explicit proxy binding; zero-slot Layouts are valid. Flat pages keep all SVG objects Slide-local.
- Flat export maps locked typography/colors into a clean project-owned theme/Master, removes stock content placeholders and unused built-in Layouts, retains only the standard date/footer/slide-number capability hooks, and keeps one Blank Layout without promoting Slide content. Structured export additionally creates one reusable Layout per declared key and reopens the package to verify the full Presentation → Master → Layout → Slide graph, fixed-object order, placeholder identities/bounds, carrier bindings, hidden proxies, and zero-slot Layouts.
- Template `page_layouts` remains input provenance. Strict preserves the prototype contract; adaptive retains its Master and may use a new Layout identity only when Strategist declared it in the plan and lock. Construction cannot allocate or mutate Layout identity downstream.
- Legacy structured/template contracts using `baseline`, `template`, `preserve`, `layout_strategy`, `data-pptx-layout-kind`, `distilled`/`utility`, direct atomic placeholders, or incomplete Master identity are rejected with a pointer to [`create-template`](../../workflows/create-template.md). Create a new workspace and generate new structured SVG pages; do not upgrade the existing project in place. Explicit flat free-design/brand-only projects intentionally omit Master identity.
- Native output uses content-hash media filenames, so identical images are reused and different images cannot overwrite each other by sharing a basename.
- `[Content_Types].xml` is generated from the actual media extensions written into the PPTX. Unknown media extensions fail unless Python's `mimetypes` can identify them.
- Native export writes to a temporary file first and publishes the requested PPTX only after conversion succeeds. A failed conversion does not replace the main output file.
- `--conversion-trace` without a path writes `validation/<output_stem>.trace.json`. `--conversion-trace <path>` respects the explicit destination; relative paths are resolved from the project root, so `exports/<name>.trace.json` remains available when intentionally requested.
- Formal default and `--quick-generate` release export compute the exact SVG source fingerprint and refuse a missing, unreadable, unsupported, non-final, blocking, stale, or unverifiable final quality report before PPTX creation. A project without `validation/svg_quality_report.json` exits nonzero with the `not-provided` gate status; run the final checker against its current `svg_output/` first. An explicit non-`output` `--source` remains a diagnostic override and bypasses this release gate; postflight still records any verifiable report linkage.
- After publication, native export writes `validation/<output_stem>.report.json`. The report distinguishes authored Slides from internal Layout definitions, reruns ZIP integrity and published Slide-count checks, records slide/layout/master/notes part counts, labels relationship/structured/transition/animation validation as enforced at build time, links the final SVG quality report only when its SHA-256 source fingerprint matches the exact export inputs, and surfaces stale/unverified gates, unresolved template tokens, generic-only font stacks, and external image references. A matching final quality report with introduced warnings yields `passed-with-warnings` and a `quality_introduced_warnings=<N>` receipt instead of a clean `passed` claim.
- By default, a successful command also prints a compact receipt instead of requiring a report read: `[POSTFLIGHT] status=<...> quality_gate=<...> slides=<N> warning_categories=<N>`, followed by one compact line per warning category and the `[PPTX]` / `[REPORT]` paths. Resource-warning lines carry counts; a non-passing quality gate carries its status. Routine agents use this receipt and do not load either complete validation JSON into model context. Full reports remain cold audit artifacts; failure investigation and explicit audits extract only the required fields. `--quiet` keeps suppressing successful-run output.
- Before publishing structured template output, export reopens the temporary PPTX and validates the Slide → Layout → Master graph and registrations, Layout identity, placeholder identity, reusable bounds, and prompt/level-one sizes. A mismatch aborts publication. Flat release instead validates its single referenced Master/Layout shell and exact date/footer/slide-number hook roster before packaging.
- Authored SVG clip-path restrictions remain. Crop wrappers use an
overflow-hidden viewport; preview-safe shape clips target the inner image in
viewBox coordinates, while legacy imported wrapper clips remain compatible.
Both map to native picture crop/geometry when possible.
- The default Generate flow embeds speaker notes automatically unless `--no-notes` is used; Quick Generate defaults them off and enables them with `--with-notes`
- Recorded narration is opt-in:
- `notes_to_audio.py` uses `edge-tts` by default, or a configured cloud TTS provider (`elevenlabs`, `minimax`, `qwen`, `cosyvoice`), and generates one audio file per slide into `audio/`
- Narration text is read strictly from the matching `notes/*.md` file; the script only skips Markdown heading lines (`# ...`) and does not summarize, rewrite, or filter delivery notes
- `--recorded-narration audio` prepares PowerPoint's "recorded timings and narrations": every slide must have matching `m4a` / `mp3` / `wav` audio, `ffprobe` must read every duration, and `--animation-trigger on-click` is rejected
- `--recorded-narration audio` keeps speaker notes, embeds each matching audio file, and writes slide auto-advance timings from page-start lead-in + audio duration + page-tail padding. `--narration-start-floor` and `--narration-padding` are independent optional seconds; their defaults are `0.8` and `0.5`, and the post-transition lead-in is `max(0, start floor - transition duration)`
- While motion remains enabled, narrated export without an explicit `--animation-config` selects `<project>/narration_animations.json` when either animation sidecar exists; canonical-only cue synchronization therefore blocks until the derived file exists. Narration-independent custom motion explicitly passes `--animation-config animations.json`, even when a derived sidecar also exists
- Without animation sidecars, Generate narration may inherit base-report deck motion via `--inherit-motion-from`; direct low-level omission keeps legacy `fade` / no object builds. Use `--no-animations` to remove object/page motion while retaining narration timings
- Non-narrated export keeps the existing optional `<project>/animations.json` default
- Narration timing merges into the existing slide timing DOM. While motion remains enabled, object-animation rows and the resolved page transition are preserved rather than regenerated; inherited `-a none` suppresses object rows, and `--no-animations` removes both motion layers
- `--narration-audio-dir audio` is the lower-level embedding path: it embeds whatever files match and allows partial audio coverage
- Either narration flag names the default-flow export `<project_name>_<timestamp>_narrated.pptx`, telling it apart from silent exports in the same directory
- This is intended for direct PowerPoint video export with "Use recorded timings and narrations"
- Long-audio import and automatic long-audio splitting are not supported; keep narration assets page-level
- Voice choices can be listed with `python3 scripts/notes_to_audio.py --list-common-voices`, `python3 scripts/notes_to_audio.py --list-voices --locale zh-CN`, or provider-specific `--provider <name> --list-voices`
- Page transitions are controlled by `-t/--transition`; per-element object animations are controlled by `-a/--animation`
- Per-element animation applies to ordinary top-level SVG `<g id="...">` groups; each group is a PowerPoint shape-target anchor, not necessarily one Animation Pane row. Use one group per logical Slide-local content unit rather than targeting a group count. Master/Layout atoms and slot groups are structural and excluded; exact id tokens remain a fallback only when explicit structural roles are absent
- An explicit `animations.json` group entry may override the marker-free legacy chrome-name heuristic. It cannot override `data-pptx-layer` or an explicit static role/placeholder marker
- Start mode is set globally by `--animation-trigger`, mirroring PowerPoint's Start dropdown: `after-previous` (default, cascade with `--animation-stagger` spacing on slide entry), `on-click` (presenter-paced), or `with-previous` (all together on slide entry). A sidecar row may override it with `trigger`; the slide value is only the inherited Start mode
- `on-click` is for live presentations only; recorded narration rejects every row that resolves to it, including a row with `trigger_shape`, because the tool does not generate object-level click timings
- Flat SVG roots without top-level groups fall back to at most 8 visible primitives; beyond that, animation is skipped on the slide
- Per-element animation defaults to `none`. `auto` is opt-in (`-a auto`) and maps
generic entrance effects from the group's SVG id: information-dense elements
get a stable entrance (chart→wipe, card-/step-/pillar-→fly,
title/takeaway→fade); image-like and unmatched ids rotate through bounded
entrance pools.
- `mixed` (legacy) deterministically rotates through the canonical entrance pool; `random` selects from the same entrance pool with a stable seed from the effective deck input. `auto`, `mixed`, and `random` never choose emphasis, motion-path, or exit effects; select an explicit canonical `entrance_*`, `emphasis_*`, `path_*`, or `exit_*` key for those authored duties. `--conversion-trace` records each resolved effect when enabled
- `--animation-duration` controls the inherited per-row schedule length (default
`0.4`); scalable native effects preserve internal timing ratios, while
instantaneous presets keep their authored duration. `--animation-stagger`
supplies the default gap between successive non-trigger-shape rows in
`after-previous` mode (default `0.5`)
- Optional object-level overrides live in `<project>/animations.json` or a path passed via `--animation-config`; build and validate them with `animation_config.py scaffold|validate`. The scaffold is neutral (`defaults.animation.effect: none`, untouched groups `{}`). A populated group uses either the fully compatible legacy single-effect fields or a non-empty `effects[]`, never both; every `effects[]` row names an explicit effect
- Transition/object sound remains off by default. After SVG and visual motion are complete, discover bundled ids with `sound_sync.py list [--query <term>]` and copy only selected ids with `sound_sync.py <project> <namespace>/<sound_id> [...]`. `transition.sound` references a project-relative `.wav`; object-animation `sound` accepts the existing `.m4a`/`.mp3`/`.wav` path contract, while bundled selections use the synced project-relative `.wav`. With no selected cue, do not create `<project>/sounds/`. Export never resolves ids or reads `templates/sounds/` directly
- One `effects[]` row becomes one Animation Pane record on the group's shape target. Each row may independently set sequence `order`, `delay`, `duration`, `trigger`, and `trigger_shape`; ordinary rows use page-wide order, while `trigger_shape` rows keep relative order in separate interactive sequences and imply `on-click`
- Animation configuration is strict: unknown effects/modes/triggers, invalid finite/range/order values, missing slides/groups, and structural-layer targets fail export without fallback or silent omission
- Generated export reads every slide back and verifies animation row order, including repeated rows on one shape target, trigger, shape target, resolved effect tuple and native behavior signature, duration, and offset. Package validation then checks timing placement, `p:cTn` ids, and `p:spTgt` references before publication
- The animation writer does not emit paragraph/text-range builds (`p:bldP`), custom freeform motion paths, native Chart/SmartArt build sequences, or media playback commands for grouped SVG content. Direct-PPTX routes preserve source object animation and perform structural package validation only; they do not author effects
- The full registry, OOXML rules, and compatibility boundary are documented in [`pptx-animations.md`](./pptx-animations.md)
Dependency:
```bash
pip install python-pptx
```
## `total_md_split.py`
Split `total.md` into per-slide note files.
```bash
python3 scripts/total_md_split.py <project_path>
python3 scripts/total_md_split.py <project_path> -o <output_directory>
python3 scripts/total_md_split.py <project_path> -q
```
Requirements:
- Each section begins with `# `
- Heading text matches the SVG filename
- Sections are separated by `---`
## `svg_quality_checker.py`
Validate SVG technical compliance.
```bash
python3 scripts/svg_quality_checker.py examples/project/svg_output/01_cover.svg
python3 scripts/svg_quality_checker.py examples/project/svg_output
python3 scripts/svg_quality_checker.py examples/project
python3 scripts/svg_quality_checker.py examples/project --stage first-page
python3 scripts/svg_quality_checker.py examples/project --stage final --json
python3 scripts/svg_quality_checker.py examples/project --format ppt169
python3 scripts/svg_quality_checker.py --all examples
python3 scripts/svg_quality_checker.py examples/project --export
python3 scripts/svg_quality_checker.py path/to/template/templates --template-mode
```
Checks include:
- `viewBox`
- banned elements
- paint compatibility: unsupported values error; supported non-default spellings such as `rgba()` receive non-blocking recommendations for `#RRGGBB` plus explicit alpha
- line-break structure
- explicit Master/Layout/slot structure for reusable templates
- duplicate empty Layout contracts under different keys
Warnings are advisory: they require no modification or acknowledgement and do
not affect the command's zero exit status. Only errors block the quality gate.
`--stage first-page` resolves only the first authored SVG and permits an incomplete
future page roster. `--stage final` checks the complete project. With `--json`,
the final stage writes `validation/svg_quality_report.json`, while the first-page
stage writes `validation/svg_quality_first_page_report.json` so it cannot overwrite
the release gate (or use `--json-output`). The report separates
release failures (`blocking`), changed/new advisories (`introduced`),
prototype-identical diagnostics (`inherited`), and source-conversion losses
(`source-import`). It also fingerprints every checked SVG so postflight cannot
mistake a stale report for the current export gate. On a successful run, use the
checker exit status and terminal summary; do not load the complete JSON unless a
failure investigation or explicit audit requires targeted fields.
Template mode accepts the same compact canonical preset groups as generated
pages: one atomic `<g data-pptx-authoring="preset">` with direct visible paths.
It validates those paths dynamically against the locked registry and does not
require an import-style carrier, preview wrapper, fingerprint, or a separate
source-payload opt-in marker. Exact syntax remains owned by the linked
standards rather than this pipeline overview.
## `svg_position_calculator.py`
Analyze and review supported chart coordinates after SVG generation.
Use this after `svg_quality_checker.py` passes, and only for chart types supported by this script: `bar`, `pie` / `donut`, `radar`, `line` / `area` / `scatter`, and `grid`. Area charts do not have a separate calculator mode: use `calc line` for the upper boundary points, then close the filled region to the plot area's bottom baseline (`y_max`) in the SVG.
### Calculate expected coordinates
```bash
python3 scripts/svg_position_calculator.py calc bar --data "A:185,B:142" --area "130,155,1200,480" --bar-width 120
python3 scripts/svg_position_calculator.py calc line --data "0:50,10:80,20:120" --area "120,120,1200,600" --y-range "0,150"
python3 scripts/svg_position_calculator.py calc pie --data "A:35,B:25,C:20" --center "420,400" --radius 200
python3 scripts/svg_position_calculator.py calc grid --rows 2 --cols 3 --area "50,150,1230,670"
```
For an area chart, use the line output as the top boundary:
```svg
M first_x,first_y ... L last_x,last_y L last_x,y_max L first_x,y_max Z
```
Manually compare the calculator output with the coordinates already present in the generated SVG. If coordinates differ, update the SVG from the `calc` output, rerun `svg_quality_checker.py`, then repeat the coordinate review. The tool intentionally does not rewrite SVG files automatically.
### Analyze (inspect existing SVG)
```bash
python3 scripts/svg_position_calculator.py analyze <svg_file>
```
Use this after SVG generation to inspect existing SVG geometry when manual comparison needs more context.
## Advanced Standalone Tools
### `flatten_tspan.py`
```bash
python3 scripts/svg_finalize/flatten_tspan.py examples/<project>/svg_output
python3 scripts/svg_finalize/flatten_tspan.py path/to/input.svg path/to/output.svg
```
### `align_embed_images.py`
```bash
python3 scripts/svg_finalize/align_embed_images.py path/to/slide.svg
python3 scripts/svg_finalize/align_embed_images.py --dry-run path/to/slide.svg
```
Use for rare single-file diagnostics when image `slice` / `meet` alignment and
Base64 embedding must be inspected outside `finalize_svg.py`. In normal project
runs, use `python3 scripts/finalize_svg.py <project_path>`; the old
`crop-images`, `fix-aspect`, and `embed-images` names remain accepted only as
`finalize_svg.py --only` aliases for the merged `align-images` step.
### `embed_icons.py`
```bash
python3 scripts/svg_finalize/embed_icons.py output.svg
python3 scripts/svg_finalize/embed_icons.py svg_output/*.svg
python3 scripts/svg_finalize/embed_icons.py --dry-run svg_output/*.svg
```
Replaces `<use data-icon="chunk-filled/name" .../>`, `<use data-icon="tabler-filled/name" .../>` and `<use data-icon="tabler-outline/name" .../>` placeholders with actual SVG path elements. Use for manual icon embedding checks outside `finalize_svg.py`.
## SVG Compatibility Contract
The always-on SVG authoring contract lives in
[`shared-standards-core.md`](../../references/shared-standards-core.md), with
advanced effects, native data objects, and structured PPTX metadata owned by
their conditionally loaded modules. This tool guide does not repeat accepted
syntax, rejected constructs, or conditional limits.
`svg_quality_checker.py` validates source SVG before finalization.
`finalize_svg.py` and native export apply the preprocessing required by that
contract, while native conversion fails on unsupported visual elements rather
than silently dropping them.
@@ -0,0 +1,87 @@
# Troubleshooting
## Validation Failed
1. Run:
```bash
python3 scripts/project_manager.py validate <project_path>
```
2. Fix missing files or invalid directories reported by the validator.
3. Re-run validation before post-processing or export.
## SVG Preview Looks Wrong
1. Check the file path and filename.
2. Confirm naming conventions are consistent.
3. Run the mandatory post-processing step when you need a self-contained preview:
```bash
python3 scripts/finalize_svg.py <project_path>
python3 -m http.server --directory <project_path>/svg_final 8000
```
`svg_final/` is the visual-preview artifact and may be inserted into PowerPoint as an SVG picture. Fix authored content in `svg_output/`, then regenerate `svg_final/`; do not edit the derived preview as the source.
## Speaker Notes Do Not Split
Check `total.md`:
- headings must start with `# `
- heading text must match SVG filenames
- sections must be separated by `---`
Then rerun:
```bash
python3 scripts/total_md_split.py <project_path>
```
## PPT Export Quality Issues
Preferred sequence:
```bash
python3 scripts/total_md_split.py <project_path>
python3 scripts/finalize_svg.py <project_path>
python3 scripts/svg_to_pptx.py <project_path>
```
Keep all three steps even though they have different consumers: Step 7.2 creates the mandatory `svg_final/` visual preview, while the supported native PPTX exporter reads `svg_output/` directly. Do not pass `-s final` for a release export; that override is diagnostic-only.
## Inserted SVG Does Not Convert Cleanly to Shapes
PowerPoint's manual Convert-to-Shape behavior is unsupported. `svg_final/` is supported only as a visual preview / SVG picture. Use the native PPTX generated from `svg_output/` when editable DrawingML shapes are required.
## Recorded Narration Missing
1. Generate audio after `total_md_split.py`, so filenames in `audio/` can match split `notes/*.md` files.
2. Export with the project-relative audio directory:
```bash
python3 scripts/notes_to_audio.py <project_path> --voice zh-CN-XiaoxiaoNeural
python3 scripts/svg_to_pptx.py <project_path> --recorded-narration audio
```
`--recorded-narration` prepares PowerPoint recorded timings and narrations. If it fails, check:
- every slide has a matching `m4a`, `mp3`, or `wav` file in `audio/`
- `ffprobe` is installed and can read each audio duration
- the deck is not using `--animation-trigger on-click`
Use `--narration-audio-dir audio` only when you intentionally want lower-level, partial audio embedding instead of PowerPoint recorded timings.
## Dependency Checklist
Most tools use the standard library. Install extra dependencies only when needed:
```bash
pip install -r requirements.txt
```
Important optional packages:
- `python-pptx` for PPTX export
- `edge-tts` for `notes_to_audio.py` recorded narration audio
- `Pillow` for image utilities
- `numpy` for watermark removal
- `PyMuPDF` for PDF conversion
- `google-genai` for Gemini image generation
@@ -0,0 +1,67 @@
# update_spec.py
> **Scope boundary**: this tool updates only deterministic global color and font
> substitutions, writes the authoritative `spec_lock.md` only after the SVG
> updates succeed, and relies on version control for rollback instead of
> creating parallel backups.
Propagate a `spec_lock.md` value change to both the lock file and every `svg_output/*.svg`. The single edit surface for bulk style tweaks after generation.
## Usage
```bash
python3 skills/ppt-master/scripts/update_spec.py <project_path> <section>.<key>=<value>
```
Bare `<key>=<value>` (no dot) is treated as `colors.<key>=<value>` for backward compat.
One invocation = one change. The tool:
1. Reads the old value from `<project_path>/spec_lock.md`
2. Plans and propagates the change into every `.svg` under `svg_output/`
3. Writes the new value into `spec_lock.md`; a global font replacement updates
every existing `typography.*_family` row together
4. Prints the list of files touched
## Examples
```bash
# swap the primary color deck-wide (bare key → colors.primary)
python3 skills/ppt-master/scripts/update_spec.py projects/acme_ppt169_20260301 primary=#0066AA
# explicit section.key form
python3 skills/ppt-master/scripts/update_spec.py projects/acme_ppt169_20260301 colors.accent=#FF6B35
# change the deck-wide font family
python3 skills/ppt-master/scripts/update_spec.py projects/acme_ppt169_20260301 \
'typography.font_family=Arial, "Microsoft YaHei", sans-serif'
```
## v2 scope
- **Supported**:
- `colors.*` — HEX value replacement across `svg_output/*.svg` (case-insensitive).
- `typography.font_family` — replaces the inner value of every
`font-family="..."` / `font-family='...'` attribute and sets all existing
`typography.*_family` lock rows to that universal family.
- **Not supported**: typography sizes, icons, images, canvas, forbidden — these involve attribute-scoped or semantic replacements whose risk/benefit does not warrant bulk propagation. Edit `spec_lock.md` and the affected SVGs by hand, or re-author the pages.
## When to use
- "Change the primary color across the whole deck" → one `update_spec.py` call
- "Switch the deck-wide font family" → one `update_spec.py` call
- "Switch an individual page's accent" → just edit that page's SVG directly
- "Re-design the palette / type system" → update `spec_lock.md` manually, then the Executor can regenerate affected pages
## Safety
- HEX values (e.g. `#005587`) are unique enough in SVG content that literal replacement is safe
- `font-family` substitution is scoped to the attribute; the outer quote character is preserved, and switched automatically if the new value contains the same quote
- a global font substitution rewrites all existing family-role lock rows in one
file write, so the universal SVG result cannot leave stale title/body roles
- The tool refuses non-HEX inputs, unknown keys, and unsupported sections
- No backups are created — the project folder should be under git so you can diff / revert
### Note on first `font-family` update
The script writes the `spec_lock.md` value verbatim into every SVG's `font-family` attribute. If the Executor generated SVGs with quote-flattened font names (e.g. `font-family="Microsoft YaHei, Arial, sans-serif"`) while `spec_lock.md` holds the quoted form (`"Microsoft YaHei", Arial, sans-serif`), the **first** substitution will normalize every SVG to match the `spec_lock.md` literal (e.g. `font-family='"Microsoft YaHei", Arial, sans-serif'`). The two forms are semantically equivalent (CSS and DrawingML parse them identically), but the normalization produces byte-level diffs across every SVG that contains text. Subsequent updates only touch files where the value actually changes.
@@ -0,0 +1,117 @@
# Video Motion Plan
`video_motion_plan.py` converts a resolved SVG-to-PPTX conversion trace into a
renderer-neutral motion plan. It is the handoff between PPT Master's canonical
custom animation and any SVG-native or post-production video renderer.
The planner exists to prevent a video path from reducing animation to delay
values. The conversion trace is authoritative because it already contains the
resolved object target, effect, direction tuple, order, duration, offset, slide
advance, and native object bounds after sidecar inheritance and narration
synchronization.
---
## 1. Generate a Resolved Source Trace
For a narrated deck, generate the trace from the narrated export so its offsets
and slide advances include the final audio timing:
```bash
python3 skills/ppt-master/scripts/svg_to_pptx.py <project_path> \
--recorded-narration audio \
--conversion-trace \
-o <project_path>/validation/video_motion_source.pptx
```
Then build the motion plan:
```bash
python3 skills/ppt-master/scripts/video_motion_plan.py \
<project_path>/validation/video_motion_source.trace.json \
-o <project_path>/validation/video_motion_plan.json \
--style adaptive \
--force
```
`restrained` and `dynamic` are explicit intensity alternatives. `adaptive` is
the default and adjusts enhancement strength from page role, animated-object
count, and object area.
---
## 2. Authority and Locks
The downstream renderer must preserve:
- object identity and SVG group id;
- object order;
- source effect;
- semantic direction;
- resolved start time and slide timing anchor.
The video layer may optimize only the declared `optimizer_scope` parameters:
easing, travel distance, opacity, scale, mask feather, blur, motion blur, and
overshoot. These additions can make motion feel more cinematic without
rewriting the presentation's choreography.
Do not derive a video plan directly from raw `animations.json` or
`narration_animations.json`. Raw sidecars may still contain inheritance,
`auto`, timing modes, or narration-relative values. Always use the resolved
conversion trace.
---
## 3. Effect Mapping
Each object keeps `source_effect` and receives a compatible video family:
| Source effect | Video family |
|---|---|
| `entrance_appear` | `hard_reveal` |
| `entrance_fade` | `soft_fade` |
| `entrance_dissolve` | `grain_dissolve` |
| `entrance_fly`, `entrance_ascend` | `directional_slide` |
| `entrance_wipe`, `entrance_peek` | `soft_mask_reveal` |
| `entrance_zoom`, `entrance_expand`, `entrance_stretch` | `focus_scale` |
| `entrance_split` | `split_mask` |
| `entrance_box`, `entrance_circle`, `entrance_diamond`, `entrance_plus` | `shape_mask` |
| `entrance_blinds`, `entrance_checkerboard`, `entrance_random_bars`, `entrance_strips`, `entrance_wedge`, `entrance_wheel` | `pattern_reveal` |
| `entrance_swivel` | `soft_swivel` |
This mapping is an enhancement contract, not permission to substitute an
unrelated effect. A directional slide remains directional; a wipe remains a
mask reveal. The retired short effect names remain readable in older
conversion traces, but new traces record canonical PowerPoint keys.
---
## 4. Output Contract
The output schema is `ppt-master.video-motion-plan.v1`. Each slide records:
- SVG source and canvas size;
- page role;
- slide duration and its source;
- resolved transition;
- ordered animated objects.
Each object records:
- `group_id`, native `shape_id`, and order;
- source effect and trigger;
- absolute `start_ms` and `duration_ms`;
- native `bounds_emu` and normalized area;
- renderer parameters under `video`.
The planner rejects click-triggered animation because a rendered video has no
interactive click event. Re-export with click-free `after-previous` or
`with-previous` timing first.
---
## 5. Current Boundary
This script owns the semantic handoff and deterministic enhancement policy. It
does not encode video by itself. A renderer that consumes the plan must report
unsupported families instead of silently falling back to delay-only fades.
@@ -0,0 +1,107 @@
# Visualization Candidate Recall
`visualization_recall.py` gives Default Strategist or the Quick Generate main
agent one bounded deterministic shortlist across the live Chart and Table
family registries. It exposes the selected full catalog only when the
caller explicitly requests semantic review. The tool reads these indexes on
every invocation and maintains no second category or keyword index:
- `templates/charts/charts_index.json`
- `templates/tables/tables_index.json`
Qualitative Structure does not enter recall. Default records its relationship
model in §IX; Quick keeps the same decision in active context. Both load
`executor-structure.md` and compose the shapes for the current page.
## Recall candidates
Describe one page's information shape with 3-8 concise English semantic tags.
Translate source-language or industry terms into structural meaning first.
```bash
python3 skills/ppt-master/scripts/visualization_recall.py recall \
--page P03 \
--tag "time series" \
--tag "three metrics" \
--tag "direction over time" \
--limit 6
```
Use `--family chart|table` only when the page semantics already make that
boundary certain; the default `all` preserves unified Chart/Table recall.
`--limit` accepts 3-8 and defaults to 6. Read the returned JSON unfiltered:
`tail`, `head`, `grep`, or another truncator can discard higher-ranked
candidates. `confidence` reports lexical strength only and never decides fit.
At `high` / `medium`, retain `no-template-match` when none fits. At `low` /
`none`, select a fitting bounded candidate directly; otherwise rerun the same
command once with `--semantic-fallback`, compare the returned selection rules
semantically, and only then retain `no-template-match`. The full-catalog review
is a narrow low-confidence no-match gate, not a routine recall step.
| Field | Contract |
|---|---|
| `page` | Input `P<NN>` page key |
| `family_filter` | Requested family or `all` |
| `semantic_tags` | Deduplicated input tags |
| `confidence` | Lexical recall strength; never a selection decision |
| `candidates` | Ranked family/key references, SVG paths, summaries, scores, and matched tags |
| `semantic_fallback` | Selected live catalogs, present only with `--semantic-fallback` |
| `no_template_match` | Explicit fallback; blocked at low/none until semantic fallback review |
The scorer treats the key and summary Pick clause as positive evidence and the
Skip clause as negative evidence. A term found only in Skip cannot make a
candidate eligible. Unicode input is NFKC-normalized before matching. The
active profile owner still applies semantic judgment and prefers the most
specific valid information structure.
## Validate selected references
Validate every selected reference before Default writes Design Spec §VII and
`spec_lock.md page_visualizations`, or before Quick opens it for immediate use:
```bash
python3 skills/ppt-master/scripts/visualization_recall.py validate \
chart/line_chart table/record_table
```
The command is read-only. It exits `0` when every supplied reference resolves
to a registered SVG and `1` otherwise. New planning supplies canonical
`family/key`. When validating an existing legacy mapping, opt into bare-key
resolution explicitly; every key must resolve uniquely:
```bash
python3 skills/ppt-master/scripts/visualization_recall.py validate \
--legacy-bare pros_cons_chart
```
A Default `no-template-match` page appears in neither §VII nor
`page_visualizations`; record its custom fallback in §IX. A qualitative
Structure is not a no-match case: describe its relationships in §IX and build
it without catalog lookup.
## Selection boundary
- Default records `Page | Family | Template | Usage` for each positive
selection and projects `family/key` into `page_visualizations`.
- Usage is one concise page-local purpose; detailed adaptation remains in §IX.
- Quick keeps the selected reference and purpose only in active context.
- Structure is a separate runtime information model, never a catalog candidate
or `page_visualizations` entry.
- Never serialize `no-template-match`, empty tables, summaries, paths, or
runners-up into planning artifacts.
- Open only the selected SVG for its mapped page. It is a flexible reference,
not a type, geometry, style, or native-replacement lock.
## Legacy compatibility
`chart_recall.py` remains a compatibility wrapper for existing callers. It
uses the same scorer and live Chart/Table registries, preserves bare-key
validation and the historical JSON shape, and resolves live candidates to their
current family paths. New prompts and automation use `visualization_recall.py`.
When `validate --legacy-bare` receives one of the 36 retired canonical Structure
bare keys, the shared resolver returns `kind=legacy-structure-intent`,
`family=structure`, and the original key without `path` or `reference`. This
compatibility result is a semantic hint for old `page_charts`, not a live catalog
entry, and never participates in recall.