Sync third-party and MCP marketplace plugins

Constraint: Public skills are published only by explicit administrator action unless they are tracked third-party market sources.
Confidence: high
Scope-risk: narrow
Directive: Keep private/internal skills out of the public marketplace and preserve normal incremental market Git history.
Tested: Marketplace validation passed.
This commit is contained in:
KeyInfo Bot
2026-06-21 00:01:37 +08:00
parent 1dd22bbb93
commit 8f32dfa7a1
46 changed files with 1252 additions and 114 deletions
+2 -2
View File
@@ -69,8 +69,8 @@
"repo": "https://github.com/hugohe3/ppt-master.git", "repo": "https://github.com/hugohe3/ppt-master.git",
"ref": "main", "ref": "main",
"adapter": "claude-skill", "adapter": "claude-skill",
"commit": "ae6d9096d64d0291933c7a3e6bba2ae4eb533741", "commit": "c948611f54ae8aae089083bf18b02b10bdb79957",
"syncedAt": "2026-06-19T16:00:00Z" "syncedAt": "2026-06-20T16:00:01Z"
}, },
{ {
"id": "next-skills", "id": "next-skills",
@@ -3,5 +3,5 @@
"name": "playwright浏览器自动化操作", "name": "playwright浏览器自动化操作",
"version": "20260605", "version": "20260605",
"keySource": "none", "keySource": "none",
"syncedAt": "2026-06-19T16:01:21Z" "syncedAt": "2026-06-20T16:01:37Z"
} }
+1 -1
View File
@@ -1,6 +1,6 @@
# PPT Master — AI generates natively editable PPTX from any document # PPT Master — AI generates natively editable PPTX from any document
[![Version](https://img.shields.io/badge/version-v2.10.0-blue.svg)](https://github.com/hugohe3/ppt-master/releases) [![Version](https://img.shields.io/badge/version-v2.11.0-blue.svg)](https://github.com/hugohe3/ppt-master/releases)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![GitHub stars](https://img.shields.io/github/stars/hugohe3/ppt-master.svg)](https://github.com/hugohe3/ppt-master/stargazers) [![GitHub stars](https://img.shields.io/github/stars/hugohe3/ppt-master.svg)](https://github.com/hugohe3/ppt-master/stargazers)
[![AtomGit stars](https://atomgit.com/hugohe3/ppt-master/star/badge.svg)](https://atomgit.com/hugohe3/ppt-master) [![AtomGit stars](https://atomgit.com/hugohe3/ppt-master/star/badge.svg)](https://atomgit.com/hugohe3/ppt-master)
@@ -2,8 +2,8 @@
"sourceId": "ppt-master", "sourceId": "ppt-master",
"repo": "https://github.com/hugohe3/ppt-master.git", "repo": "https://github.com/hugohe3/ppt-master.git",
"ref": "main", "ref": "main",
"commit": "ae6d9096d64d0291933c7a3e6bba2ae4eb533741", "commit": "c948611f54ae8aae089083bf18b02b10bdb79957",
"adapter": "claude-skill", "adapter": "claude-skill",
"sourcePath": "skills/ppt-master", "sourcePath": "skills/ppt-master",
"syncedAt": "2026-06-19T16:00:00Z" "syncedAt": "2026-06-20T16:00:01Z"
} }
@@ -45,6 +45,7 @@ description: "多格式源文档到高质量 SVG 页面再导出 PPTX 的多阶
| `${SKILL_DIR}/scripts/source_to_md/doc_to_md.py` | Documents to Markdown — native Python for DOCX/HTML/EPUB/IPYNB, pandoc fallback for legacy formats (.doc/.odt/.rtf/.tex/.rst/.org/.typ) | | `${SKILL_DIR}/scripts/source_to_md/doc_to_md.py` | Documents to Markdown — native Python for DOCX/HTML/EPUB/IPYNB, pandoc fallback for legacy formats (.doc/.odt/.rtf/.tex/.rst/.org/.typ) |
| `${SKILL_DIR}/scripts/source_to_md/excel_to_md.py` | Excel workbooks to Markdown — supports .xlsx/.xlsm; legacy .xls should be resaved as .xlsx | | `${SKILL_DIR}/scripts/source_to_md/excel_to_md.py` | Excel workbooks to Markdown — supports .xlsx/.xlsm; legacy .xls should be resaved as .xlsx |
| `${SKILL_DIR}/scripts/source_to_md/ppt_to_md.py` | PowerPoint to Markdown | | `${SKILL_DIR}/scripts/source_to_md/ppt_to_md.py` | PowerPoint to Markdown |
| `${SKILL_DIR}/scripts/pptx_intake.py` | Standard PPTX intake enrichment — canvas / identity / slide geometry / tables / native chart data |
| `${SKILL_DIR}/scripts/source_to_md/web_to_md.py` | Web page to Markdown (supports WeChat via `curl_cffi`) | | `${SKILL_DIR}/scripts/source_to_md/web_to_md.py` | Web page to Markdown (supports WeChat via `curl_cffi`) |
| `${SKILL_DIR}/scripts/project_manager.py` | Project init / validate / manage | | `${SKILL_DIR}/scripts/project_manager.py` | Project init / validate / manage |
| `${SKILL_DIR}/scripts/icon_sync.py` | Copy chosen library icons into `<project>/icons/` at selection time; missing names reported + non-zero (re-pick gate) | | `${SKILL_DIR}/scripts/icon_sync.py` | Copy chosen library icons into `<project>/icons/` at selection time; missing names reported + non-zero (re-pick gate) |
@@ -85,6 +86,21 @@ For complete tool documentation, see `${SKILL_DIR}/scripts/README.md`.
| `live-preview` | `workflows/live-preview.md` | Browser-based live preview — auto-started during generation and re-enterable any time the user mentions "live preview", "preview", "看效果", or wants to click/select a slide element | | `live-preview` | `workflows/live-preview.md` | Browser-based live preview — auto-started during generation and re-enterable any time the user mentions "live preview", "preview", "看效果", or wants to click/select a slide element |
| `visual-review` | `workflows/visual-review.md` | Per-page rubric-based visual self-check — run only when the user explicitly asks for a visual re-pass on the generated SVGs (between Executor and post-processing). Opt-in only; never invoked by the main pipeline. | | `visual-review` | `workflows/visual-review.md` | Per-page rubric-based visual self-check — run only when the user explicitly asks for a visual re-pass on the generated SVGs (between Executor and post-processing). Opt-in only; never invoked by the main pipeline. |
### PPTX Route Boundary
When the user provides an existing `.pptx`, route by the role of the source deck:
| User intent | Route | Contract |
|---|---|---|
| Preserve the deck's page split, page order, and per-slide wording; improve layout / hierarchy / whitespace | `beautify` | Source page count and order are 1:1; text and data values are frozen; visual identity is inherited after confirmation |
| Treat the deck as source material; rethink the story, merge / split / drop / reorder pages, or change page count | Main pipeline | `ppt_to_md` + PPTX intake provide content facts and candidates; Strategist may re-architect freely |
| Reuse the deck's native design with new material | `template-fill` | Clone selected source slides and replace text / table / chart data directly in OOXML; no SVG generation |
| Harvest the deck as a reusable future template | `create-template` | Build a template package, not a one-off generated deck |
**Deciding axis (beautify vs main pipeline) — one question, one discriminator**: is the source's page split a finished artifact to preserve, or a draft structure to overturn? The concrete discriminator is **page count / order**: if it changes at all — any split, merge, drop, or reorder — it is the **main pipeline**, never beautify. Beautify is **strictly 1:1**: same page count, same order, text verbatim, only layout / hierarchy / whitespace redone. Edge case made explicit: "keep all the content but split a crowded page so it reads better" still changes page count, so it is the **main pipeline** (re-pagination is re-architecture), not beautify.
Ambiguous requests such as "make this PPT more professional" or "optimize this deck" MUST be clarified with one question before routing: "Should the original page count/order and each slide's wording be preserved, or should the deck be treated as source material and restructured into a new story?" Preserve → `beautify`; restructure → main pipeline.
--- ---
## Workflow ## Workflow
@@ -103,7 +119,7 @@ When the user provides non-Markdown content, convert immediately:
| DOCX / Word / Office document | `python3 ${SKILL_DIR}/scripts/source_to_md/doc_to_md.py <file>` | | DOCX / Word / Office document | `python3 ${SKILL_DIR}/scripts/source_to_md/doc_to_md.py <file>` |
| XLSX / XLSM / Excel workbook | `python3 ${SKILL_DIR}/scripts/source_to_md/excel_to_md.py <file>` | | XLSX / XLSM / Excel workbook | `python3 ${SKILL_DIR}/scripts/source_to_md/excel_to_md.py <file>` |
| CSV / TSV | Read directly as plain-text table source | | CSV / TSV | Read directly as plain-text table source |
| PPTX / PowerPoint deck | `python3 ${SKILL_DIR}/scripts/source_to_md/ppt_to_md.py <file>` | | PPTX / PowerPoint deck | `python3 ${SKILL_DIR}/scripts/source_to_md/ppt_to_md.py <file>` for Markdown content; after Step 2 `import-sources`, standard PPTX intake is also written to `<project>/analysis/` |
| EPUB / HTML / LaTeX / RST / other | `python3 ${SKILL_DIR}/scripts/source_to_md/doc_to_md.py <file>` | | EPUB / HTML / LaTeX / RST / other | `python3 ${SKILL_DIR}/scripts/source_to_md/doc_to_md.py <file>` |
| Web link | `python3 ${SKILL_DIR}/scripts/source_to_md/web_to_md.py <URL>` | | Web link | `python3 ${SKILL_DIR}/scripts/source_to_md/web_to_md.py <URL>` |
| WeChat / high-security site | `python3 ${SKILL_DIR}/scripts/source_to_md/web_to_md.py <URL>` (requires `curl_cffi`, included in `requirements.txt`) | | WeChat / high-security site | `python3 ${SKILL_DIR}/scripts/source_to_md/web_to_md.py <URL>` (requires `curl_cffi`, included in `requirements.txt`) |
@@ -144,6 +160,16 @@ Import source content (choose based on the situation):
| Has source files (PDF/MD/etc.) | `python3 ${SKILL_DIR}/scripts/project_manager.py import-sources <project_path> <source_files...> --move` | | Has source files (PDF/MD/etc.) | `python3 ${SKILL_DIR}/scripts/project_manager.py import-sources <project_path> <source_files...> --move` |
| User provided text directly in conversation | No import needed — content is already in conversation context; subsequent steps can reference it directly | | User provided text directly in conversation | No import needed — content is already in conversation context; subsequent steps can reference it directly |
For PPTX sources, `import-sources` automatically runs the standard intake enrichment:
```bash
python3 ${SKILL_DIR}/scripts/pptx_intake.py <project_path>/sources/<source.pptx> -o <project_path>/analysis
```
For each PPTX it writes `<stem>.identity.json` (canvas, theme palette/fonts, observed usage) and `<stem>.slide_library.json` (text slots, geometry, native tables, native chart caches), and merges that deck's Strategist-facing digest into the single multi-deck index `analysis/source_profile.json` (`decks[]`, one self-contained entry per source deck, with prefixed artifact pointers). In the main generation path these are source facts and recommendation candidates, not replica constraints; beautify and template-fill workflows decide separately which fields become locked constraints.
Multi-deck: several PPTX files may be imported into one main-pipeline project — each gets its own `<stem>.*` artifacts and a deck entry in `source_profile.json`. `source_profile.json` stays the single must-read index (one entry for a one-deck project, several for a combined-source project). Stems must be distinct; re-importing the same stem replaces that deck's entry. The beautify / template-fill workflows remain single-deck (1:1 to one chosen source deck) and read that deck's `<stem>.*` artifacts.
> ⚠️ **MUST use `--move`** (not copy): all source files — Step 1's generated Markdown, original PDFs / MDs / images — go into `sources/` via `import-sources --move`. After execution they no longer exist at the original location. Intermediate artifacts (e.g., `_files/`) are handled automatically. > ⚠️ **MUST use `--move`** (not copy): all source files — Step 1's generated Markdown, original PDFs / MDs / images — go into `sources/` via `import-sources --move`. After execution they no longer exist at the original location. Intermediate artifacts (e.g., `_files/`) are handled automatically.
**✅ Checkpoint — Confirm project structure created successfully, `sources/` contains all source files, converted materials are ready. Proceed to Step 3.** **✅ Checkpoint — Confirm project structure created successfully, `sources/` contains all source files, converted materials are ready. Proceed to Step 3.**
@@ -278,6 +304,10 @@ Read references/strategist.md
> ⚠️ **Mandatory gate**: before writing `design_spec.md`, Strategist MUST `read_file templates/design_spec_reference.md` and follow its full IXI section structure. See `strategist.md` Section 1. > ⚠️ **Mandatory gate**: before writing `design_spec.md`, Strategist MUST `read_file templates/design_spec_reference.md` and follow its full IXI section structure. See `strategist.md` Section 1.
**`<project_path>/analysis/` is the project's intermediate-analysis folder: the canonical home for machine-extracted source/asset facts — the PPTX intake bundle (`source_profile.json` index + per-deck `<stem>.identity.json` / `<stem>.slide_library.json`) and `image_analysis.csv`. It holds facts, not design contracts — `design_spec.md` / `spec_lock.md` stay at the project root.** The MUST-read contract covers only the **compact structured data files (`.json` / `.csv`)**; other artifacts that may live under `analysis/` (e.g. a beautify `source_svg_import/` vector reference package) are NOT bulk-read — they are read selectively only when a specific workflow step calls for them. Before the Eight Confirmations, Strategist MUST read the auto-extracted fact files already in `analysis/` — currently `source_profile.json` (PPTX intake), when present. This file is the multi-deck index: read it once for the `decks[]` digests (canvas / chart / table entries per source deck), then open a specific deck's `<stem>.identity.json` / `<stem>.slide_library.json` only if you need its full raw facts. Use these entries as **factual source context** (format default + content facts); when several decks are present, synthesize across all of them. The source's **palette / typography / visual identity are a reference, not a constraint**: the main pipeline may inherit them where they fit the content and the confirmed style, or design fresh where they don't — the Strategist's judgment, never an obligation to either keep or discard. (Template-fill preserves the native source design by editing cloned slides directly; beautify defaults to the source identity but still follows the confirmed values; the main pipeline treats source identity as reference only and defaults to fresh design.) (`image_analysis.csv` lands later, at the image-analysis step below, and is the authoritative regenerated image-fact view there — re-derived from the live `images/` folder, not a durable store.)
**Channel ownership — read each fact once from its owning channel.** In the main pipeline the **content contract is the Markdown** (`sources/<stem>.md`): text, tables, and chart data values all come from there (`ppt_to_md` now transcribes native chart data into Markdown tables). The `analysis/` chart / table entries are a **structural digest** for outline decisions (which slides carried charts, type, series names) — not a second copy of the values; do NOT also pull chart values from `<stem>.slide_library.json` in the main pipeline. The `<stem>.slide_library.json` full structured data is owned by the direct-PPTX workflows: template-fill uses it as the native fill contract; beautify uses it for native chart / table data while keeping slide text from the Markdown.
**Eight Confirmations** (full template: `templates/design_spec_reference.md`): **Eight Confirmations** (full template: `templates/design_spec_reference.md`):
**BLOCKING**: present the Eight Confirmations as a single bundled recommendation set and **wait for explicit user confirmation or modification** before outputting Design Specification & Content Outline. This is the single core confirmation point — once confirmed, all subsequent steps proceed automatically. **BLOCKING**: present the Eight Confirmations as a single bundled recommendation set and **wait for explicit user confirmation or modification** before outputting Design Specification & Content Outline. This is the single core confirmation point — once confirmed, all subsequent steps proceed automatically.
@@ -293,7 +323,7 @@ Read references/strategist.md
**Confirm UI Auto-Launch (Mandatory — default visual confirmation surface)**: by default the Eight Confirmations are presented through an interactive local page (color swatches, live font previews, candidate picks); the chat path is the always-valid fallback. Steps: **Confirm UI Auto-Launch (Mandatory — default visual confirmation surface)**: by default the Eight Confirmations are presented through an interactive local page (color swatches, live font previews, candidate picks); the chat path is the always-valid fallback. Steps:
1. Write the recommendations to `<project_path>/confirm_ui/recommendations.json` (full schema + field mapping: [`scripts/docs/confirm_ui.md`](scripts/docs/confirm_ui.md)). Two kinds of field: **enumerable** (canvas / mode / visual_style / icons / formula policy / generation mode; plus image usage with a Custom path; plus AI source only when image usage may include `ai`) — the page lists common options from `confirm_ui/static/catalogs.json`, so you only name the recommended canonical `id` in a `recommend` block (canvas may be a catalog id like `ppt169` or a custom size/prose; style = `mode` + `visual_style`, two independent picks; icon ids are real libraries such as `tabler-outline`, or `emoji` for system emoji; image usage uses `ai` / `web` / `provided` / `placeholder` / `none`, or a custom prose plan when several sources must be combined; never write bare `"custom"` for image usage — write the actual mixed plan, e.g. "AI cover + user product assets + web industry images"; write `image_ai_path` only when recommending `image_usage: "ai"` or a custom plan that includes AI); **generative** (color, typography, generated-image style) — author **≥3 candidates** each (creative recommendations always offer real choice, never a single silent option — same rule as strategist h.5; fewer than 3 only on the honest-shortfall exception, with a stated reason) (color: user-facing core `palette` with background/secondary_bg/primary/accent/secondary_accent/body_text; typography: CJK + Latin for `heading` and `body` with `css` preview stacks, plus `body_size` as the body baseline px; when recommending generated images, `image_strategy.candidates` with rendering × palette combinations from strategist h.5). `page_count` / `audience` are plain values. Only open fields show a Custom box: `canvas`, `mode`, `visual_style`, `icons`, `image_usage`, and typography custom text. Closed fields (`image_ai_path`, `formula_policy`, `generation_mode`, `refine_spec`) stay finite. Set `lang` to the page language; visible candidate text should match `lang`, or provide bilingual `name_zh` / `name_en` and `note_zh` / `note_en` fields. Reuse the same candidate thinking as strategist h.5. 1. Write the recommendations to `<project_path>/confirm_ui/recommendations.json` (full schema + field mapping: [`scripts/docs/confirm_ui.md`](scripts/docs/confirm_ui.md)). Two kinds of field: **enumerable** (canvas / mode / visual_style / icons / formula policy / generation mode; plus image usage with a Custom path; plus AI source only when image usage may include `ai`) — the page lists common options from `confirm_ui/static/catalogs.json`, so you only name the recommended canonical `id` in a `recommend` block (canvas may be a catalog id like `ppt169` or a custom size/prose; style = `mode` + `visual_style`, two independent picks; icon ids are real libraries such as `tabler-outline`, or `emoji` for system emoji; image usage uses `ai` / `web` / `provided` / `placeholder` / `none`, or a custom prose plan when several sources must be combined; never write bare `"custom"` for image usage — write the actual mixed plan, e.g. "AI cover + user product assets + web industry images"; write `image_ai_path` only when recommending `image_usage: "ai"` or a custom plan that includes AI); **generative** (color, typography, generated-image style) — author **≥3 candidates** each (creative recommendations always offer real choice, never a single silent option — same rule as strategist h.5; fewer than 3 only on the honest-shortfall exception, with a stated reason) (color: user-facing core `palette` with background/secondary_bg/primary/accent/secondary_accent/body_text; typography: CJK + Latin for `heading` and `body` with `css` preview stacks, plus `body_size` as the body baseline px; when recommending generated images, `image_strategy.candidates` with rendering × palette combinations from strategist h.5). `page_count` / `audience` / `content_divergence` are plain values (free text). Only open fields show a Custom box: `canvas`, `mode`, `visual_style`, `icons`, `image_usage`, and typography custom text. Closed fields (`image_ai_path`, `formula_policy`, `generation_mode`, `refine_spec`) stay finite. `content_divergence` is a **free-text** field shown under audience in §c — the user states in their own words how closely to follow the source vs how freely to reshape it (blank = balanced; facts stay sourced at every level). Write it as `content_divergence: { "value": "<prose or empty>" }`. It is consumed by Strategist when authoring `§IX`, recorded in `design_spec.md §I`, carries no page-count coupling, and is **not** written to `spec_lock.md`. Set `lang` to the page language; visible candidate text should match `lang`, or provide bilingual `name_zh` / `name_en` and `note_zh` / `note_en` fields. Reuse the same candidate thinking as strategist h.5.
2. Launch the page **in the background and wait for the browser confirmation** (the child server runs detached; the parent command returns after `result.json` is freshly written). **Run this command with a long tool timeout — 600000 ms** — so the `--wait` (≈590 s budget) can complete: 2. Launch the page **in the background and wait for the browser confirmation** (the child server runs detached; the parent command returns after `result.json` is freshly written). **Run this command with a long tool timeout — 600000 ms** — so the `--wait` (≈590 s budget) can complete:
```bash ```bash
python3 ${SKILL_DIR}/scripts/confirm_ui/server.py <project_path> --daemon --wait python3 ${SKILL_DIR}/scripts/confirm_ui/server.py <project_path> --daemon --wait
@@ -354,12 +384,14 @@ After the Eight Confirmations are approved and **before outputting `design_spec.
The formula renderer uses a provider fallback chain by default: `codecogs,quicklatex,mathpad,wikimedia`. The first three are color-aware; Wikimedia is an availability fallback. Formula PNGs are transparent by default: manifest `background` is the temporary render matte and transparency-removal reference, not a retained final background unless `transparent: false` is set for that item. Do not scan `spec_lock.md` for `$...$` or `$$...$$`. Dollar-delimited math in source material is only a signal for Strategist; the renderer consumes the explicit manifest. The formula renderer uses a provider fallback chain by default: `codecogs,quicklatex,mathpad,wikimedia`. The first three are color-aware; Wikimedia is an availability fallback. Formula PNGs are transparent by default: manifest `background` is the temporary render matte and transparency-removal reference, not a retained final background unless `transparent: false` is set for that item. Do not scan `spec_lock.md` for `$...$` or `$$...$$`. Dollar-delimited math in source material is only a signal for Strategist; the renderer consumes the explicit manifest.
If the user provided images or formula PNGs were rendered, run analysis **before outputting the design spec**: If the user provided images or formula PNGs were rendered, run analysis **before outputting the design spec**. It writes `analysis/image_analysis.csv` — the authoritative regenerated image-fact view in the `analysis/` folder, which MUST be read before authoring §VIII:
```bash ```bash
python3 ${SKILL_DIR}/scripts/analyze_images.py <project_path>/images python3 ${SKILL_DIR}/scripts/analyze_images.py <project_path>/images
``` ```
> ⚠️ **Image handling**: NEVER directly read / open / view image files (`.jpg`, `.png`, etc.). All image info comes from `analyze_images.py` output or the Design Spec's Image Resource List. > 🔁 **Image facts are regenerated on demand, never a durable store.** `images/` is a live working folder — pictures are extracted from the source at import, the user may drop or replace files at any time, and Step 5 writes web/AI images into it. The single source of truth is therefore the **current contents of `images/`**, and `analysis/image_analysis.csv` is a *regenerated view* of it, not a fact to keep in sync. Re-run `analyze_images.py <project_path>/images` immediately **before any step that reads image facts** so the view reflects the live folder: before the §h image-usage recommendation (see [strategist.md](references/strategist.md) §h), here before authoring §VIII, after Step 5 acquisition (so web/AI files join the view), and again any time the user says they added or replaced images. This is the staleness strategy — re-derive on use, no cache to invalidate.
> ⚠️ **Image handling**: NEVER directly read / open / view image files (`.jpg`, `.png`, etc.). All image info comes from `analyze_images.py` output (`analysis/image_analysis.csv`) or the Design Spec's Image Resource List.
**Output**: **Output**:
- `<project_path>/design_spec.md` — human-readable design narrative - `<project_path>/design_spec.md` — human-readable design narrative
@@ -368,6 +400,7 @@ python3 ${SKILL_DIR}/scripts/analyze_images.py <project_path>/images
**✅ Checkpoint — Phase deliverables complete, auto-proceed to next step**: **✅ Checkpoint — Phase deliverables complete, auto-proceed to next step**:
```markdown ```markdown
## ✅ Strategist Phase Complete ## ✅ Strategist Phase Complete
- [x] Read the auto-extracted facts already in `analysis/` (e.g. `source_profile.json`) before the Eight Confirmations
- [x] Eight Confirmations completed (user confirmed via Confirm UI `result.json` or chat fallback) - [x] Eight Confirmations completed (user confirmed via Confirm UI `result.json` or chat fallback)
- [x] Split-mode note appended below the eight items (heavy or normal variant) - [x] Split-mode note appended below the eight items (heavy or normal variant)
- [x] Spec-refinement opt-in line appended (default OFF; only the user's explicit request enters the refine-spec workflow) - [x] Spec-refinement opt-in line appended (default OFF; only the user's explicit request enters the refine-spec workflow)
@@ -395,13 +428,15 @@ Then **lazy-load the path-specific reference** for each row that actually needs
| Acquire Via | Load reference (only if any such row exists) | Run | | Acquire Via | Load reference (only if any such row exists) | Run |
|---|---|---| |---|---|---|
| `ai` | `references/image-generator.md` | `python3 ${SKILL_DIR}/scripts/image_gen.py --manifest <project_path>/images/image_prompts.json` | | `ai` | `references/image-generator.md` | `python3 ${SKILL_DIR}/scripts/image_gen.py --manifest <project_path>/images/image_prompts.json` |
| `web` | `references/image-searcher.md` | `python3 ${SKILL_DIR}/scripts/image_search.py ...` | | `web` | `references/image-searcher.md` | `python3 ${SKILL_DIR}/scripts/image_search.py ...` (≥2 web rows → `--batch images/image_queries.json`) |
| `user` / `placeholder` | (skip) | (skip) | | `user` / `placeholder` | (skip) | (skip) |
A deck with only `ai` rows never loads `image-searcher.md`; a deck with only `web` rows never loads `image-generator.md`. A mixed deck loads both, processes each row through its own path, and writes both `image_prompts.json` and `image_sources.json`. A deck with only `ai` rows never loads `image-searcher.md`; a deck with only `web` rows never loads `image-generator.md`. A mixed deck loads both, processes each row through its own path, and writes both `image_prompts.json` and `image_sources.json`.
> ⚠️ **In-pipeline ai path MUST use manifest mode** — even when only 1 ai row exists. Write `images/image_prompts.json` first, then run `image_gen.py --manifest`, then `image_gen.py --render-md` to produce the `image_prompts.md` sidecar. The positional form (`image_gen.py "prompt" ...`) is reserved for **out-of-pipeline one-off testing / single-image fixups** — it skips manifest + sidecar, leaving no audit trail. > ⚠️ **In-pipeline ai path MUST use manifest mode** — even when only 1 ai row exists. Write `images/image_prompts.json` first, then run `image_gen.py --manifest`, then `image_gen.py --render-md` to produce the `image_prompts.md` sidecar. The positional form (`image_gen.py "prompt" ...`) is reserved for **out-of-pipeline one-off testing / single-image fixups** — it skips manifest + sidecar, leaving no audit trail.
> ⚠️ **web path — batch multiple rows**: when ≥2 rows are `Acquire Via: web`, write all queries into `images/image_queries.json` and run `image_search.py --batch` once (concurrent acquisition, status written back), instead of one CLI call per row. A single web row may use the positional single-query form. See [image-searcher.md](references/image-searcher.md) §5.
> ⚠️ **Honor the confirmed image source**: the `ai` generation path (Path A = `image_gen.py` API / Path B = host-native tool / Offline Manual) is **not** auto-only — a confirmed choice other than `auto` wins, whether it came from chat (canonical) or, when the page was used, `result.json.image_ai_path`. `host-native` forces Path B even when `IMAGE_BACKEND` is configured; `api` forces Path A; `manual` forces offline. The `--manifest` command above is Path A. Full selection rule: [image-generator.md](references/image-generator.md) §7 Path Selection. > ⚠️ **Honor the confirmed image source**: the `ai` generation path (Path A = `image_gen.py` API / Path B = host-native tool / Offline Manual) is **not** auto-only — a confirmed choice other than `auto` wins, whether it came from chat (canonical) or, when the page was used, `result.json.image_ai_path`. `host-native` forces Path B even when `IMAGE_BACKEND` is configured; `api` forces Path A; `manual` forces offline. The `--manifest` command above is Path A. Full selection rule: [image-generator.md](references/image-generator.md) §7 Path Selection.
Workflow: Workflow:
@@ -409,6 +444,7 @@ Workflow:
1. Extract all rows with `Status: Pending` and `Acquire Via ∈ {ai, web}` from the design spec 1. Extract all rows with `Status: Pending` and `Acquire Via ∈ {ai, web}` from the design spec
2. Generate prompts (ai rows) and/or run search (web rows) per [image-base.md](references/image-base.md) §2 dispatch table 2. Generate prompts (ai rows) and/or run search (web rows) per [image-base.md](references/image-base.md) §2 dispatch table
3. Verify every row reaches a terminal status: `Generated` (ai success), `Sourced` (web success), or `Needs-Manual` 3. Verify every row reaches a terminal status: `Generated` (ai success), `Sourced` (web success), or `Needs-Manual`
4. Re-derive image facts now that web / AI files are in the folder — `python3 ${SKILL_DIR}/scripts/analyze_images.py <project_path>/images` — so `analysis/image_analysis.csv` reflects every acquired image (real measured sizes) before the Executor lays them out. Image facts are regenerated on use, never a stale store (see Step 4's image-facts note).
**✅ Checkpoint — Confirm acquisition attempted for every row**: **✅ Checkpoint — Confirm acquisition attempted for every row**:
```markdown ```markdown
@@ -417,6 +453,7 @@ Workflow:
- [x] image_prompts.md sidecar rendered (when any ai rows processed) - [x] image_prompts.md sidecar rendered (when any ai rows processed)
- [x] image_sources.json created (when any web rows processed) - [x] image_sources.json created (when any web rows processed)
- [x] Each row: status is `Generated` / `Sourced` / `Needs-Manual` (no `Pending` remaining) - [x] Each row: status is `Generated` / `Sourced` / `Needs-Manual` (no `Pending` remaining)
- [x] analyze_images.py re-run so image_analysis.csv covers the acquired web / AI images
``` ```
**Default — auto-proceed to Step 6.** Only when the user's Step 4 response explicitly opted into split mode (in chat or via Confirm UI `result.json` with `generation_mode: "split"`), output the Phase A hand-off below and stop this conversation: **Default — auto-proceed to Step 6.** Only when the user's Step 4 response explicitly opted into split mode (in chat or via Confirm UI `result.json` with `generation_mode: "split"`), output the Phase A hand-off below and stop this conversation:
@@ -460,6 +497,8 @@ python3 ${SKILL_DIR}/scripts/svg_editor/server.py <project_path> --live
**Pre-generation Batch Read (Mandatory)**: before the first SVG, batch-read every distinct layout SVG referenced in `spec_lock.page_layouts` and every distinct chart SVG referenced in `spec_lock.page_charts` (plus any §VII backup charts). One read per file, up front — do not re-read these during page generation. See executor-base.md §1.0. **Pre-generation Batch Read (Mandatory)**: before the first SVG, batch-read every distinct layout SVG referenced in `spec_lock.page_layouts` and every distinct chart SVG referenced in `spec_lock.page_charts` (plus any §VII backup charts). One read per file, up front — do not re-read these during page generation. See executor-base.md §1.0.
> Image facts: trust the `analysis/image_analysis.csv` regenerated at the end of Step 5. If `images/` changed since (the user swapped or added files), re-run `python3 ${SKILL_DIR}/scripts/analyze_images.py <project_path>/images` before laying images out — facts are re-derived on use, never a stale store (Step 4 image-facts note).
**Per-page spec_lock re-read (Mandatory)**: before **each** SVG page, `read_file <project_path>/spec_lock.md` and use only its colors / fonts / icons / images, plus the per-page `page_rhythm` / `page_layouts` / `page_charts` lookups (resolves to template SVGs already loaded in the batch read above). Resists context-compression drift on long decks. See executor-base.md §2.1. **Per-page spec_lock re-read (Mandatory)**: before **each** SVG page, `read_file <project_path>/spec_lock.md` and use only its colors / fonts / icons / images, plus the per-page `page_rhythm` / `page_layouts` / `page_charts` lookups (resolves to template SVGs already loaded in the batch read above). Resists context-compression drift on long decks. See executor-base.md §2.1.
> ⚠️ **Main-agent only**: SVG generation MUST stay in the current main agent — page design depends on full upstream context. Do NOT delegate to sub-agents. > ⚠️ **Main-agent only**: SVG generation MUST stay in the current main agent — page design depends on full upstream context. Do NOT delegate to sub-agents.
@@ -118,7 +118,7 @@ Before drawing each page, look up its entry in `page_rhythm` (key format `P<NN>`
| Tag | Layout discipline | | Tag | Layout discipline |
|-----|-------------------| |-----|-------------------|
| `anchor` | Structural page (cover / chapter / TOC / ending). Follow the matching template verbatim. | | `anchor` | Structural page (cover / chapter / TOC / ending). With a template, follow the matching template verbatim. In free design (no template), realize the page's §IX intent — for the cover deliver its `Cover impact` and for a closing page its `Closing impact` (the committed hook / takeaway + composition), never a default centered title + subtitle or a generic "Thank you" sign-off. |
| `dense` | Information-heavy. Card grids, multi-column layouts, KPI dashboards, tables, and charts are all permitted. This is the baseline behavior. | | `dense` | Information-heavy. Card grids, multi-column layouts, KPI dashboards, tables, and charts are all permitted. This is the baseline behavior. |
| `breathing` | Low-density impact page. Avoid **multi-card grid layouts** — do not organize content as multiple parallel rounded containers (3-card row, 4-card KPI grid, 2×2 matrix rendered as cards). Use naked text blocks, dividers, whitespace, or full-bleed imagery as the content structure. Single rounded visual elements (hero image corners, callouts, tags, one emphasis block) are fine — the rule is about grid structure, not about the `rx` attribute. Proportions follow information weight (not a preset ratio). Typical forms: hero quote, single large number with one-line interpretation, full-bleed image with floating caption, section transition. | | `breathing` | Low-density impact page. Avoid **multi-card grid layouts** — do not organize content as multiple parallel rounded containers (3-card row, 4-card KPI grid, 2×2 matrix rendered as cards). Use naked text blocks, dividers, whitespace, or full-bleed imagery as the content structure. Single rounded visual elements (hero image corners, callouts, tags, one emphasis block) are fine — the rule is about grid structure, not about the `rx` attribute. Proportions follow information weight (not a preset ratio). Typical forms: hero quote, single large number with one-line interpretation, full-bleed image with floating caption, section transition. |
@@ -331,7 +331,7 @@ Handle images by their status in the Design Spec's Image Resource List. Status e
**`no-crop` images**: when a `spec_lock.md images` entry ends with ` | no-crop`, size the container to the image's native ratio (from `analyze_images.py` or file dims) and use `preserveAspectRatio="xMidYMid meet"`. Untagged entries are croppable — default to `slice`. **`no-crop` images**: when a `spec_lock.md images` entry ends with ` | no-crop`, size the container to the image's native ratio (from `analyze_images.py` or file dims) and use `preserveAspectRatio="xMidYMid meet"`. Untagged entries are croppable — default to `slice`.
**Formula images**: rows with `Acquire Via: formula` or `Type: Latex Formula` MUST be treated as no-crop even if a legacy `spec_lock.md` forgot the flag. Use the dimensions from `design_spec.md §VIII`, `image_analysis.csv`, or `images/formula_manifest.json`; do not normalize all formulas to one height unless the spec explicitly states that layout choice. **Formula images**: rows with `Acquire Via: formula` or `Type: Latex Formula` MUST be treated as no-crop even if a legacy `spec_lock.md` forgot the flag. Use the dimensions from `design_spec.md §VIII`, `analysis/image_analysis.csv`, or `images/formula_manifest.json`; do not normalize all formulas to one height unless the spec explicitly states that layout choice.
### 6.1 Inline Attribution for Sourced Images (web path) ### 6.1 Inline Attribution for Sourced Images (web path)
@@ -496,8 +496,9 @@ Precedence:
Triggered automatically when `IMAGE_BACKEND` is not configured (or Path A fails) **and** the host provides a native image generation tool (Codex, Antigravity, Claude Code's image tool, and similar). No user prompting required — the agent detects the host capability and proceeds. The user may also explicitly name this path ("use Codex's image tool") to force it even when `IMAGE_BACKEND` is configured. Triggered automatically when `IMAGE_BACKEND` is not configured (or Path A fails) **and** the host provides a native image generation tool (Codex, Antigravity, Claude Code's image tool, and similar). No user prompting required — the agent detects the host capability and proceeds. The user may also explicitly name this path ("use Codex's image tool") to force it even when `IMAGE_BACKEND` is configured.
- Agent invokes the host's native image tool directly; prompts come from `items[].prompt` - Agent invokes the host's native image tool directly; prompts come from `items[].prompt`
- **Batch for speed, mind the rate**: when the host can run independent tool calls in parallel (e.g. Claude Code issues independent calls concurrently), fire several generations together in modest groups — a few rows at a time (~34), not the whole manifest at once — so their latency overlaps without flooding the host's image quota. When the host only runs tools serially, generate one row at a time. This mirrors Path A's default concurrency of 3.
- Outputs **must** land at `project/images/<filename-from-resource-list>` with dimensions matching the Image Resource List - Outputs **must** land at `project/images/<filename-from-resource-list>` with dimensions matching the Image Resource List
- After each placement, set the corresponding item's `status` to `Generated` in the manifest - Mark each item's `status` `Generated` in the manifest the moment its file lands — as each completes, not in one pass at the end (so an interrupted batch leaves accurate state)
- Executor downstream is path-agnostic — no spec change required between Path A and Path B - Executor downstream is path-agnostic — no spec change required between Path A and Path B
### Offline Manual Mode (C's third implementation mode) ### Offline Manual Mode (C's third implementation mode)
@@ -119,7 +119,37 @@ python3 scripts/image_search.py "<query>" \
| `--strict-no-attribution` | no | off | Restrict to no-attribution licenses; refuse CC BY / CC BY-SA | | `--strict-no-attribution` | no | off | Restrict to no-attribution licenses; refuse CC BY / CC BY-SA |
| `--manifest` | no | (default) | Override manifest path | | `--manifest` | no | (default) | Override manifest path |
**Pacing (mandatory)**: one search at a time. Wikimedia/Openverse expect identifying User-Agent and reasonable rate (~1 req/sec). Default pacing is fine. ### Batch mode (≥ 2 web rows) — preferred
When more than one row is `Acquire Via: web`, do **not** call the CLI once per row. Write all rows into one `image_queries.json` and run a single concurrent batch — the web sister of `image_gen.py --manifest`:
```bash
python3 scripts/image_search.py --batch <project_path>/images/image_queries.json \
-o <project_path>/images
```
`image_queries.json` schema (one item per web row):
```json
{
"items": [
{
"filename": "team.jpg",
"query": "executive boardroom meeting",
"slide": "03_team",
"purpose": "background",
"orientation": "landscape",
"status": "Pending"
}
]
}
```
Required per item: `filename`, `query`, `status` (`Pending`). Optional per-item overrides: `slide`, `purpose`, `orientation`, `provider`, `strict_no_attribution`, `min_width`, `min_height`.
The runner searches all `Pending` / `Failed` rows concurrently, appends each success to `image_sources.json` (the credit source of truth, idempotent on `filename`), and writes status back into `image_queries.json``Sourced` on success, `Needs-Manual` when the full provider/stage chain is exhausted. Status is saved after each completion, so an interrupted run preserves finished rows; re-running skips terminal rows. A single `web` row may still use single-query mode above.
**Pacing**: free providers (Wikimedia/Openverse) are rate-sensitive, so batch concurrency defaults to a modest **3** (`--concurrency N`, or `IMAGE_SEARCH_CONCURRENCY` env). Use `--concurrency 1` to restore strict one-at-a-time pacing. Single-query mode is one request at a time by nature.
--- ---
@@ -555,7 +555,7 @@ Best for: slides needing strong visual brand identity.
| Scenario | Recommended Technique | Avoid | | Scenario | Recommended Technique | Avoid |
|----------|-----------------------|-------| |----------|-----------------------|-------|
| Card / panel shadow (only when floating over photo/colored panel) | Filter soft shadow (`flood-opacity` 0.060.12, single light source) | Hard black shadow, full-page abundance | | Card / panel shadow (only when floating over photo/colored panel) | Filter soft shadow (`flood-opacity` 0.060.10, single light source) | Hard black shadow, full-page abundance |
| Equal peer cards in a grid | All flat (no shadow) | Lifting every card uniformly | | Equal peer cards in a grid | All flat (no shadow) | Lifting every card uniformly |
| Page-section background panel | Flat fill, no shadow | Treating panels as floating cards | | Page-section background panel | Flat fill, no shadow | Treating panels as floating cards |
| Accent / CTA button (one per page) | Colored shadow (same hue family, `flood-opacity` 0.120.20) | Generic gray shadow, applying to every button | | Accent / CTA button (one per page) | Colored shadow (same hue family, `flood-opacity` 0.120.20) | Generic gray shadow, applying to every button |
@@ -28,7 +28,7 @@ As a top-tier AI presentation strategist, receive source documents, perform cont
> >
> **One opt-in exception**: present the spec-refinement line alongside the split-mode note (SKILL.md Step 4). It is OFF by default — the above discipline holds unchanged. Only when the user *explicitly* asks to refine the spec do you hand off to the [refine-spec](../workflows/refine-spec.md) workflow, which produces the full spec first and stops for user review/revision of any part before generation. Never enter it unprompted. > **One opt-in exception**: present the spec-refinement line alongside the split-mode note (SKILL.md Step 4). It is OFF by default — the above discipline holds unchanged. Only when the user *explicitly* asks to refine the spec do you hand off to the [refine-spec](../workflows/refine-spec.md) workflow, which produces the full spec first and stops for user review/revision of any part before generation. Never enter it unprompted.
> **Default presentation surface — Confirm UI.** Deliver the bundled package through the interactive page: write your recommendations to `<project>/confirm_ui/recommendations.json`, then launch per [SKILL.md Step 4](../SKILL.md). You still author everything — enumerable fields name a recommended `id`; generative fields (color `palette`, CJK + Latin typography, generated-image style) each carry **≥3 distinct candidates** — creative recommendations always offer real choice, never a single silent option, same hard rule and thinking as h.5. Honest-shortfall exception (mirrors h.5): if the constraints genuinely cannot yield 3 non-conflicting options, present the smaller set and say why — never pad with duplicates or known-conflicting fillers. **Always also print the recommendations + URL in chat** as the always-valid fallback. On confirm, read `<project>/confirm_ui/result.json` (`generation_mode: "split"` / `refine_spec: true` are explicit user choices). Skip the page if the user wants chat-only. Full launch flow, field rules, and JSON schema live in [SKILL.md Step 4](../SKILL.md) + [`scripts/docs/confirm_ui.md`](../scripts/docs/confirm_ui.md) — don't restate them here. The page is a confirmation surface only. > **Default presentation surface — Confirm UI.** Deliver the bundled package through the interactive page: write your recommendations to `<project>/confirm_ui/recommendations.json`, then launch per [SKILL.md Step 4](../SKILL.md). You still author everything — enumerable fields name a recommended `id`; generative fields (color `palette`, CJK + Latin typography, generated-image style) each carry **≥3 distinct candidates**, and the deck's **visual style** (§d Layer 2) carries a **≥3-style personality spectrum** (`visual_style_spectrum`, safe / shifted / bold) — creative recommendations always offer real choice, never a single silent option, same hard rule and thinking as h.5. Honest-shortfall exception (mirrors h.5): if the constraints genuinely cannot yield 3 non-conflicting options, present the smaller set and say why — never pad with duplicates or known-conflicting fillers. **Always also print the recommendations + URL in chat** as the always-valid fallback. On confirm, read `<project>/confirm_ui/result.json` (`generation_mode: "split"` / `refine_spec: true` are explicit user choices). Skip the page if the user wants chat-only. Full launch flow, field rules, and JSON schema live in [SKILL.md Step 4](../SKILL.md) + [`scripts/docs/confirm_ui.md`](../scripts/docs/confirm_ui.md) — don't restate them here. The page is a confirmation surface only.
### a. Canvas Format Confirmation ### a. Canvas Format Confirmation
@@ -42,6 +42,14 @@ Provide specific page count recommendation based on source document content volu
Confirm target audience, usage occasion, and core message; provide initial assessment based on document nature. Confirm target audience, usage occasion, and core message; provide initial assessment based on document nature.
**Material divergence** — a **free-text** intent the user states beside audience (same content-strategy cluster): in their own words, how closely the deck should follow the source vs how freely it may reshape it. This is the user's own call — a free prose field (`content_divergence`), **not** a fixed set of options and **not** something you recommend from analyzing the source. Surface the question (in the confirm UI it is a text box under audience; in chat, ask it plainly); leave it for the user to fill. Blank = a balanced default.
Read the user's prose as a point on a spectrum and apply judgment — from *stay close* (track the source's structure and wording, tune only for clarity, no substantive add / drop) through the default *balanced* (re-architect and distill into a narrative under the locked `mode`, keeping all substance) to *free* (regroup, reframe, expand terse points, draw out connections latent in the source, invent section structure and transitions).
**Hard rule — facts stay sourced however free the user asks.** Divergence is freedom to *develop* what is in the source (reorganize / reframe / expand / connect), never licence to invent. Even the freest request must not introduce facts, figures, or claims from outside the source material — that is the `topic-research` job, not divergence. `mode` and divergence are orthogonal (e.g. a pyramid that hews to the source's own points vs. a pyramid built from freely synthesized themes).
**Consumption — outline-authoring only.** Apply the user's stated intent when authoring the `§IX` outline. Record the prose (or "balanced default") in `design_spec.md §I` (Content Strategy). Do **NOT** write it to `spec_lock.md` — it is baked into `§IX` at authoring time and the Executor never reads it. It carries no page-count coupling — the §b page count stays the user's separate call. The beautify / template-fill workflows keep content verbatim, so they do not surface this field.
### d. Style Objective Confirmation ### d. Style Objective Confirmation
Two independent layers, each locks one catalog item. Output: `d. Mode: <mode> + Visual style: <visual_style>`. Two independent layers, each locks one catalog item. Output: `d. Mode: <mode> + Visual style: <visual_style>`.
@@ -69,8 +77,8 @@ Write the locked value to `spec_lock.md` `- mode:` and record the rationale in `
The deck's **visual aesthetic** — shape language, decoration density, whitespace rhythm, typographic character, texture. Anchors the downstream confirmations e (Color), f (Icon), g (Typography), h (Image). Lock one preset from the catalog, or `custom`. The deck's **visual aesthetic** — shape language, decoration density, whitespace rhythm, typographic character, texture. Anchors the downstream confirmations e (Color), f (Icon), g (Typography), h (Image). Lock one preset from the catalog, or `custom`.
**Source**: **Source**:
- User named a style map to the closest preset; if none fits, `custom` with a `visual_style_behavior` paragraph. - User named a style (chat / template / beautify) → it is truth: map to the closest preset (or `custom` with a `visual_style_behavior` paragraph) and lock directly. **Skip the spectrum below** — do not re-offer choice they already made.
- No user description → recommend by the index's auto-selection table (content vibe / industry → style). Present as a recommendation; the user may override. - No user description → **present a personality spectrum, not one safe pick** (this is the lever against "every deck looks the same" — the visual style is what most determines a deck's character, so it gets real choice, same hard rule and thinking as h.5). Author **≥3 distinct styles** from the index's auto-selection table spanning *safe* (the industry-norm recommendation) → *shifted* (an alternate one tick more expressive) → *bold* (a characterful style that challenges the default — `brutalist` / `zine` / `memphis` / `ink-wash` / `vintage-poster` etc., whenever the content can carry it). Give each a one-line **temperament tag + real-world analogy** (like h.5's "like an Economist feature"). Write the three to `recommendations.json` `visual_style_spectrum` (each `{id, tag_zh/en, note_zh/en}`) **and present the same three in chat** as the always-valid fallback; set `recommend.visual_style` to the *safe* pick as the pre-selected default. The user may pick any of the three, a style outside them, or Custom. Honest-shortfall exception (mirrors h.5): if the content genuinely supports fewer than 3 non-gimmicky directions, present the smaller set and say why — never pad with a style that fights the content.
**Forbidden — a non-catalog name as `visual_style`**: the value MUST be an `id` from the visual-styles catalog (or genuine `custom` prose). A name that is **not** in that catalog is not a visual style — most often it is an image-rendering name from the `_index` "Paired rendering" column (`flat`, `vector-illustration`, `digital-dashboard`, `3d-isometric`, `corporate-photo`, …), which names the §h *illustration* family, not the deck's layout aesthetic. Do not borrow it. (Names that are intentionally **both** a style and its paired rendering — `glassmorphism`, `blueprint`, `editorial`, `dark-tech` — are valid styles because they *are* in the catalog.) Generic baseline words — `flat` / flat-design / 扁平 / modern / clean / simple / minimal — are **not** custom-worthy either: the whole system is flat by default (shadows discouraged), so map them to the closest preset (flat + grid → `swiss-minimal`; flat + rounded → `soft-rounded`; flat + dense → `brutalist`). Reserve `custom` for an aesthetic no preset covers. **Forbidden — a non-catalog name as `visual_style`**: the value MUST be an `id` from the visual-styles catalog (or genuine `custom` prose). A name that is **not** in that catalog is not a visual style — most often it is an image-rendering name from the `_index` "Paired rendering" column (`flat`, `vector-illustration`, `digital-dashboard`, `3d-isometric`, `corporate-photo`, …), which names the §h *illustration* family, not the deck's layout aesthetic. Do not borrow it. (Names that are intentionally **both** a style and its paired rendering — `glassmorphism`, `blueprint`, `editorial`, `dark-tech` — are valid styles because they *are* in the catalog.) Generic baseline words — `flat` / flat-design / 扁平 / modern / clean / simple / minimal — are **not** custom-worthy either: the whole system is flat by default (shadows discouraged), so map them to the closest preset (flat + grid → `swiss-minimal`; flat + rounded → `soft-rounded`; flat + dense → `brutalist`). Reserve `custom` for an aesthetic no preset covers.
@@ -306,6 +314,14 @@ The script renders PNGs into `images/`, trying `codecogs`, `quicklatex`, `mathpa
| **D** | Web-sourced | Real-world reference imagery, editorial support, stock-style needs (no API key required for default providers) | | **D** | Web-sourced | Real-world reference imagery, editorial support, stock-style needs (no API key required for default providers) |
| **E** | Placeholders | Images to be added later | | **E** | Placeholders | Images to be added later |
> 🚧 **GATE — know your resources before recommending.** `images/` is a live working folder (source-extracted pictures, user drops, later replacements), so its facts are **re-derived on use, never trusted from a stale store**. Before recommending image usage, if `images/` is non-empty, regenerate the inventory from whatever is currently in it, then read it back:
>
> ```bash
> python3 scripts/analyze_images.py <project_path>/images
> ```
>
> Read `<project_path>/analysis/image_analysis.csv` (size / ratio / category of every in-hand picture). The AE choice is still your judgment, but it MUST be made with the current inventory in full view — never in ignorance of what is already on hand. `image_analysis.csv` is a regenerated view of the live folder, not a durable fact: re-run this whenever `images/` changes.
> **Confirmed value wins.** The `image_usage` in `result.json` (or the chat reply) **overrides the recommendation here** — map it to §VIII `Acquire Via` (`ai`→`ai`, `web`→`web`, `provided`→**`user`**, `placeholder`→`placeholder`, `none`→option A, no image rows). When it is not `ai` (and the plan has no AI part), skip h.5 entirely and write no `ai` rows. See SKILL.md Step 4 for the full mapping. > **Confirmed value wins.** The `image_usage` in `result.json` (or the chat reply) **overrides the recommendation here** — map it to §VIII `Acquire Via` (`ai`→`ai`, `web`→`web`, `provided`→**`user`**, `placeholder`→`placeholder`, `none`→option A, no image rows). When it is not `ai` (and the plan has no AI part), skip h.5 entirely and write no `ai` rows. See SKILL.md Step 4 for the full mapping.
**When recommending C** — surface its three implementation modes so the user knows "no API key" is a supported state: **When recommending C** — surface its three implementation modes so the user knows "no API key" is a supported state:
@@ -745,6 +761,9 @@ Content-outline and speaker-notes strategy follow the deck's locked **mode** —
4. **Generate execution lock**: read `templates/spec_lock_reference.md` and produce `projects/<project_name>.../spec_lock.md` — a distilled, machine-readable short form of the color / typography / icon / image / **page_rhythm** / **page_layouts** / **page_charts** decisions above. This file is what the Executor re-reads before every page (see [executor-base.md](executor-base.md) §2.1). The values in `spec_lock.md` MUST exactly match the decisions recorded in `design_spec.md`; if they ever diverge, `spec_lock.md` wins and `design_spec.md` should be treated as historical narrative. 4. **Generate execution lock**: read `templates/spec_lock_reference.md` and produce `projects/<project_name>.../spec_lock.md` — a distilled, machine-readable short form of the color / typography / icon / image / **page_rhythm** / **page_layouts** / **page_charts** decisions above. This file is what the Executor re-reads before every page (see [executor-base.md](executor-base.md) §2.1). The values in `spec_lock.md` MUST exactly match the decisions recorded in `design_spec.md`; if they ever diverge, `spec_lock.md` wins and `design_spec.md` should be treated as historical narrative.
- **page_rhythm is mandatory**: Based on the page list in §IX Content Outline, assign each page one of `anchor` / `dense` / `breathing` (see `spec_lock_reference.md` for the full vocabulary). This is what breaks the uniform "every page is a card grid" feel — without it the Executor defaults all pages to `dense`. - **page_rhythm is mandatory**: Based on the page list in §IX Content Outline, assign each page one of `anchor` / `dense` / `breathing` (see `spec_lock_reference.md` for the full vocabulary). This is what breaks the uniform "every page is a card grid" feel — without it the Executor defaults all pages to `dense`.
- **Rhythm follows narrative, not quota**: `breathing` pages mark natural pauses — chapter transitions, standalone emphasis (hero quote / big number), SCQA bridges. Dense decks may legitimately be all `dense`. **Do NOT invent filler pages** ("Thank you", empty dividers) to pad rhythm — every `breathing` page must say something independent. - **Rhythm follows narrative, not quota**: `breathing` pages mark natural pauses — chapter transitions, standalone emphasis (hero quote / big number), SCQA bridges. Dense decks may legitimately be all `dense`. **Do NOT invent filler pages** ("Thank you", empty dividers) to pad rhythm — every `breathing` page must say something independent.
- **Cover impact is mandatory**: Page `P01` is the deck's first visual contract, not a generic title slide. In `design_spec.md §IX`, add a `Cover impact` line for `P01` that names one concrete hook and one concrete composition strategy. Use the source's strongest available signal: a provocative core claim, object / scene metaphor, hero number, founder / product / audience moment, or a distilled conflict. Pair it with one concrete composition strategy — such as `full-bleed image + floating title`, `typographic poster`, `hero object`, `data hook`, `editorial scene`, `high-contrast abstract geometry`, or a fresh composition the deck's subject suggests (these are starting points, not the allowed set). If no external or AI image is available, still specify a native-SVG visual hook; do not fall back to "title + subtitle + decorative background". (Beautify / template-fill keep the source cover verbatim — this rule does not apply on those preservation paths.)
- **Cover rhythm lock**: `P01` remains `anchor` in `spec_lock.md page_rhythm`, but its §IX `Cover impact` must prevent content-page patterns. Do not plan multi-card grids, agenda-like bullets, or equal-weight columns on the cover unless a template explicitly requires that structure, or a preservation path (beautify / template-fill) is transcribing the source cover verbatim.
- **Closing impact (only when the deck closes)**: the deck's last page is its final visual contract — the strongest impression after the cover. When the deck genuinely lands on a conclusion / call-to-action / final-takeaway page, give it a `Closing impact` line in §IX: name the one thing the audience should leave with (a distilled takeaway, a forward call, a memorable restatement of the core claim) + one composition that delivers it — never a generic "Thank you" / contact-only slide or a centered-title reprise of the cover. **Do NOT invent a closing page to satisfy this** — the filler-page ban above still holds; apply it only to the page where the deck actually resolves. Same exemptions as the cover: skip on template / beautify / template-fill preservation paths.
- **page_layouts (write only when a template is in use)**: For each page that inherits a template SVG, add `P<NN>: <svg_basename>` (e.g., `P04: 03a_content_image_text`). Pages designed freely get **no entry** — Executor reads the absence as "free design, no inheritance". If zero pages use a template, omit the section entirely. - **page_layouts (write only when a template is in use)**: For each page that inherits a template SVG, add `P<NN>: <svg_basename>` (e.g., `P04: 03a_content_image_text`). Pages designed freely get **no entry** — Executor reads the absence as "free design, no inheritance". If zero pages use a template, omit the section entirely.
- **page_charts (write only for chart pages that match a catalog template)**: For each page in `design_spec.md §VII` whose `reference template path` points to `templates/charts/<name>.svg`, add `P<NN>: <chart_name>`. Pages with `no-template-match` in §VII MUST NOT appear here (Executor would look for a non-existent reference). If the deck has no data-visualization pages, omit the section. - **page_charts (write only for chart pages that match a catalog template)**: For each page in `design_spec.md §VII` whose `reference template path` points to `templates/charts/<name>.svg`, add `P<NN>: <chart_name>`. Pages with `no-template-match` in §VII MUST NOT appear here (Executor would look for a non-existent reference). If the deck has no data-visualization pages, omit the section.
- **Hard rule**: Use both `page_layouts` and `page_charts` for the same page only when the layout template is a compatible shell for the chart. Do not pair chart pages with conflicting page layouts (e.g., `waterfall_chart` + timeline layout, KPI cards + circle-diagram layout). If no compatible layout exists, omit the page from `page_layouts`. - **Hard rule**: Use both `page_layouts` and `page_charts` for the same page only when the layout template is a compatible shell for the chart. Do not pair chart pages with conflicting page layouts (e.g., `waterfall_chart` + timeline layout, KPI cards + circle-diagram layout). If no compatible layout exists, omit the page from `page_layouts`.
@@ -6,7 +6,7 @@ Classroom chalkboard — a dark slate field, soft chalk-stroke line work, powder
## 1. Shape & decoration ## 1. Shape & decoration
- Shape language: chalk-stroke line work with slightly diffused, dry-medium edges; sketched boxes, brackets, arrows in chalk. Confident but never mechanical. - Shape language: chalk-stroke line work with slightly diffused, dry-medium edges; sketched boxes, brackets, arrows in chalk. Confident but never mechanical — the sketched boxes and arrows are `<path>` with non-aligned points; a primitive `<rect>` / `<line>` snaps the chalk back to mechanical.
- Decoration: underlines and emphasis marks; a few sprinkled chalk stars / dots. Blackboard pedagogy — organized sections, a clear central focus. - Decoration: underlines and emphasis marks; a few sprinkled chalk stars / dots. Blackboard pedagogy — organized sections, a clear central focus.
- Whitespace: the dark board reads as room; let chalk marks breathe rather than crowd. - Whitespace: the dark board reads as room; let chalk marks breathe rather than crowd.
@@ -22,6 +22,8 @@ Dark canvas, luminous accents, geometric precision. For tech, AI, dev tools, dat
- Dark background; one or two luminous accents carry focus (glowing figures, active nodes); everything else low-key. - Dark background; one or two luminous accents carry focus (glowing figures, active nodes); everything else low-key.
- The accent does the work of attention — few points, high contrast. - The accent does the work of attention — few points, high contrast.
> HEX values come from confirmation `e`; this style only governs the dark-field, luminous-accent discipline — it names no colors.
## 4. Texture / elevation ## 4. Texture / elevation
- Depth via glow and layering on dark, not drop shadows. Outer glow / light strokes mark elevation; gradients stay same-hue and subtle. (Dark-theme legibility — prefer light stroke / outer glow over black shadow: [`shared-standards.md §6`](../shared-standards.md).) - Depth via glow and layering on dark, not drop shadows. Outer glow / light strokes mark elevation; gradients stay same-hue and subtle. (Dark-theme legibility — prefer light stroke / outer glow over black shadow: [`shared-standards.md §6`](../shared-standards.md).)
@@ -23,6 +23,8 @@ Magazine-grade hierarchy. Columns, hairline rules, a serif / sans interplay, str
- Mostly monochrome text on a light field; one accent for emphasis (a rule, a highlighted figure, a kicker). - Mostly monochrome text on a light field; one accent for emphasis (a rule, a highlighted figure, a kicker).
- Restraint — color marks structure and emphasis, not decoration. - Restraint — color marks structure and emphasis, not decoration.
> HEX values come from confirmation `e`; this style only governs the monochrome-with-structural-accent discipline — it names no colors.
## 4. Texture / elevation ## 4. Texture / elevation
- Flat to barely-raised. Rules and whitespace separate content, not shadows. Shadow only on a genuine floating element. - Flat to barely-raised. Rules and whitespace separate content, not shadows. Shadow only on a genuine floating element.
@@ -7,7 +7,7 @@ Frosted-glass SaaS — translucent layered panels, flowing gradient light, float
## 1. Shape & decoration ## 1. Shape & decoration
- Shape language: rounded translucent glass panels (low fill-opacity over the dark field) with bright hairline edges; layered, floating cards that imply blur and frost; rounded corners (`rx` 12-20). - Shape language: rounded translucent glass panels (low fill-opacity over the dark field) with bright hairline edges; layered, floating cards that imply blur and frost; rounded corners (`rx` 12-20).
- Decoration: soft radial light blooms in the background; thin luminous edge highlights along panels; restrained — the glass material is the decoration, not added ornament. - Decoration: soft radial light blooms in the background; thin luminous edge highlights along panels; restrained — the glass material is the decoration, not added ornament. Realize the radial bloom / glow halo as a `<circle>` / `<ellipse>` with a `<radialGradient>` fill, never a `rect rx=w/2` standing in for it.
- Whitespace: dark negative space reads as depth; let panels float on it with room to breathe. - Whitespace: dark negative space reads as depth; let panels float on it with room to breathe.
## 2. Typography character ## 2. Typography character
@@ -6,7 +6,7 @@ Whiteboard-ink minimalism — a pale field, confident black hand-ink line work,
## 1. Shape & decoration ## 1. Shape & decoration
- Shape language: hand-drawn line work with slight, intentional wobble — boxes, arrows, dividers and brackets sketched as if on a thoughtful whiteboard; never mechanically straight. Line defines structure; no filled cards. - Shape language: hand-drawn line work with slight, intentional wobble — boxes, arrows, dividers and brackets sketched as if on a thoughtful whiteboard; never mechanically straight — realize it as `<path>` / `<polyline>` with off-grid points, not `<rect>` / `<line>` primitives. Line defines structure; no filled cards.
- Decoration: minimal — a few doodle marks (stars, dashes, dots, underlines) for emphasis. Restraint is the look; clutter breaks the "considered" feel. - Decoration: minimal — a few doodle marks (stars, dashes, dots, underlines) for emphasis. Restraint is the look; clutter breaks the "considered" feel.
- Whitespace: generous and empty; the pale field carries most of the canvas, elements float with room around them. - Whitespace: generous and empty; the pale field carries most of the canvas, elements float with room around them.
@@ -6,7 +6,7 @@ New-Chinese ink-wash — a rice-paper field, vast literati whitespace, restraine
## 1. Shape & decoration ## 1. Shape & decoration
- Shape language: minimal brush-stroke marks and hairline dividers; the occasional ink-dark block; a single seal-stamp (印章) square as a focal accent. No cards, no boxes — emptiness is the structure. - Shape language: minimal brush-stroke marks and hairline dividers; the occasional ink-dark block; a single seal-stamp (印章) square as a focal accent. No cards, no boxes — emptiness is the structure. The brush-stroke and ink-bleed marks are irregular `<path>` shapes with uneven control points — never an `<ellipse>` / `<circle>` standing in for a wash, which reads as fake ink. (The seal-stamp square is the one deliberate hard edge.)
- Decoration: almost none; what little appears reads as brush and seal. Asymmetric, scroll-like composition with deliberate off-balance. - Decoration: almost none; what little appears reads as brush and seal. Asymmetric, scroll-like composition with deliberate off-balance.
- Whitespace: vast and intentional — the rice-paper field carries most of the page; a few elements float in great calm. - Whitespace: vast and intentional — the rice-paper field carries most of the page; a few elements float in great calm.
@@ -6,7 +6,7 @@ Layered paper-craft — scissor-cut shapes stacked in tactile layers, soft shado
## 1. Shape & decoration ## 1. Shape & decoration
- Shape language: forms defined by crisp, slightly-irregular cut edges (no outlines); simplified, stylized shapes that read as cut paper rather than illustration. - Shape language: forms defined by crisp, slightly-irregular cut edges (no outlines); simplified, stylized shapes that read as cut paper rather than illustration. Those cut edges are irregular `<polygon>` / `<path>` outlines, not a clean `<rect>` / `<circle>`, which reads as a digital box rather than torn paper.
- Decoration: layering itself is the device — each element is a "sheet" stacked over the one beneath; small cut-out accents on the top layer. - Decoration: layering itself is the device — each element is a "sheet" stacked over the one beneath; small cut-out accents on the top layer.
- Whitespace: cozy, composed — the backing sheet shows through as breathing room. - Whitespace: cozy, composed — the backing sheet shows through as breathing room.
@@ -6,7 +6,7 @@ Warm hand-drawn sketchnote — soft paper field, black ink doodle line work, gen
## 1. Shape & decoration ## 1. Shape & decoration
- Shape language: rounded shapes drawn with a slight wobble; pastel block fills that slightly overshoot their outlines (hand-painted feel); simple cartoon icons. - Shape language: rounded shapes drawn with a slight wobble; pastel block fills that slightly overshoot their outlines (hand-painted feel); simple cartoon icons. Draw that wobble as a `<path>` with non-aligned points — a `<rect rx>` is not wobble.
- Decoration: small doodles — stars, sparkles, dots, underlines — sprinkled sparingly for warmth; wavy hand-drawn arrows connecting ideas with short inline labels. - Decoration: small doodles — stars, sparkles, dots, underlines — sprinkled sparingly for warmth; wavy hand-drawn arrows connecting ideas with short inline labels.
- Whitespace: airy and well-organized; generous gaps between elements keep it friendly, never dense. - Whitespace: airy and well-organized; generous gaps between elements keep it friendly, never dense.
@@ -22,6 +22,8 @@ Approachable and modern. Rounded cards, gentle elevation, friendly rhythm. For p
- Theme color used confidently on covers / chapter backgrounds; same-hue tints for card backings; accent for key figures. - Theme color used confidently on covers / chapter backgrounds; same-hue tints for card backings; accent for key figures.
- Warmer, more generous color use than swiss / editorial — still disciplined (60-30-10), never rainbow. - Warmer, more generous color use than swiss / editorial — still disciplined (60-30-10), never rainbow.
> HEX values come from confirmation `e`; this style only governs the confident-but-disciplined (60-30-10) color use — it names no colors.
## 4. Texture / elevation ## 4. Texture / elevation
- Gentle elevation: soft shadows on floating cards (resting tier), subtle tints, optional same-hue gradients. Two-tier elevation max; keep peer-grid cards flat. (Full shadow rules: [`shared-standards.md §6`](../shared-standards.md).) - Gentle elevation: soft shadows on floating cards (resting tier), subtle tints, optional same-hue gradients. Two-tier elevation max; keep peer-grid cards flat. (Full shadow rules: [`shared-standards.md §6`](../shared-standards.md).)
@@ -40,7 +40,7 @@ python3 scripts/update_repo.py
| Area | Primary scripts | Documentation | | Area | Primary scripts | Documentation |
|------|-----------------|---------------| |------|-----------------|---------------|
| Conversion | `source_to_md/pdf_to_md.py`, `source_to_md/doc_to_md.py`, `source_to_md/excel_to_md.py`, `source_to_md/ppt_to_md.py`, `source_to_md/web_to_md.py` | [docs/conversion.md](./docs/conversion.md) | | Conversion | `source_to_md/pdf_to_md.py`, `source_to_md/doc_to_md.py`, `source_to_md/excel_to_md.py`, `source_to_md/ppt_to_md.py`, `source_to_md/web_to_md.py`, `pptx_intake.py` | [docs/conversion.md](./docs/conversion.md) |
| Project management | `project_manager.py`, `batch_validate.py`, `generate_examples_index.py`, `error_helper.py`, `pptx_template_import.py`, `template_fill_pptx.py` | [docs/project.md](./docs/project.md) | | Project management | `project_manager.py`, `batch_validate.py`, `generate_examples_index.py`, `error_helper.py`, `pptx_template_import.py`, `template_fill_pptx.py` | [docs/project.md](./docs/project.md) |
| SVG pipeline | `finalize_svg.py`, `svg_to_pptx.py`, `total_md_split.py`, `svg_quality_checker.py`, `extract_svg_assets.py`, `animation_config.py`, `notes_to_audio.py` | [docs/svg-pipeline.md](./docs/svg-pipeline.md) | | SVG pipeline | `finalize_svg.py`, `svg_to_pptx.py`, `total_md_split.py`, `svg_quality_checker.py`, `extract_svg_assets.py`, `animation_config.py`, `notes_to_audio.py` | [docs/svg-pipeline.md](./docs/svg-pipeline.md) |
| Spec maintenance | `update_spec.py` | [docs/update_spec.md](./docs/update_spec.md) | | Spec maintenance | `update_spec.py` | [docs/update_spec.md](./docs/update_spec.md) |
@@ -80,9 +80,9 @@ Template fill (direct PPTX, no SVG conversion):
```bash ```bash
mkdir -p <project_path>/sources <project_path>/analysis <project_path>/exports <project_path>/validation mkdir -p <project_path>/sources <project_path>/analysis <project_path>/exports <project_path>/validation
python3 scripts/template_fill_pptx.py analyze <project_path>/sources/<source.pptx> -o <project_path>/analysis/slide_library.json python3 scripts/template_fill_pptx.py analyze <project_path>/sources/<source.pptx> -o <project_path>/analysis/<stem>.slide_library.json
python3 scripts/template_fill_pptx.py scaffold <project_path>/analysis/slide_library.json -o <project_path>/analysis/fill_plan.json --slides "1,3,4" python3 scripts/template_fill_pptx.py scaffold <project_path>/analysis/<stem>.slide_library.json -o <project_path>/analysis/fill_plan.json --slides "1,3,4"
python3 scripts/template_fill_pptx.py check-plan <project_path>/analysis/slide_library.json <project_path>/analysis/fill_plan.json -o <project_path>/analysis/check_report.json python3 scripts/template_fill_pptx.py check-plan <project_path>/analysis/<stem>.slide_library.json <project_path>/analysis/fill_plan.json -o <project_path>/analysis/check_report.json
python3 scripts/template_fill_pptx.py apply <project_path>/sources/<source.pptx> <project_path>/analysis/fill_plan.json -o <project_path>/exports/filled.pptx python3 scripts/template_fill_pptx.py apply <project_path>/sources/<source.pptx> <project_path>/analysis/fill_plan.json -o <project_path>/exports/filled.pptx
``` ```
@@ -18,7 +18,8 @@ Usage:
Output: Output:
- Analysis report displayed in console - Analysis report displayed in console
- Generates image_analysis.csv in the parent directory of the images folder - Generates image_analysis.csv under the project's analysis/ directory
(sibling of the images folder), alongside the PPTX intake bundle
""" """
import argparse import argparse
@@ -572,9 +573,12 @@ def main() -> None:
print_results(results) print_results(results)
generate_markdown(results, canvas_key) generate_markdown(results, canvas_key)
# Save to CSV file (saved in the parent directory of the images folder) # Save to CSV file (saved under the project's analysis/ directory,
# alongside the PPTX intake bundle)
parent_dir = os.path.dirname(images_dir) parent_dir = os.path.dirname(images_dir)
csv_path = os.path.join(parent_dir, "image_analysis.csv") analysis_dir = os.path.join(parent_dir, "analysis")
os.makedirs(analysis_dir, exist_ok=True)
csv_path = os.path.join(analysis_dir, "image_analysis.csv")
save_csv(results, csv_path) save_csv(results, csv_path)
else: else:
print("No image files found in the directory.") print("No image files found in the directory.")
@@ -14,7 +14,7 @@ Usage:
Examples: Examples:
python3 scripts/beautify_identity.py projects/x/sources/deck.pptx python3 scripts/beautify_identity.py projects/x/sources/deck.pptx
python3 scripts/beautify_identity.py deck.pptx -o projects/x/analysis/identity.json python3 scripts/beautify_identity.py deck.pptx -o projects/x/analysis/deck.identity.json
Dependencies: Dependencies:
None beyond the standard library (reuses scripts/pptx_to_svg/). None beyond the standard library (reuses scripts/pptx_to_svg/).
@@ -13,7 +13,7 @@ Usage:
python3 scripts/beautify_inventory.py <slide_library.json> [--images <image_manifest.json>] [-o inventory.json] python3 scripts/beautify_inventory.py <slide_library.json> [--images <image_manifest.json>] [-o inventory.json]
Examples: Examples:
python3 scripts/beautify_inventory.py projects/x/analysis/slide_library.json \ python3 scripts/beautify_inventory.py projects/x/analysis/<stem>.slide_library.json \
--images projects/x/images/image_manifest.json -o projects/x/analysis/beautify_inventory.json --images projects/x/images/image_manifest.json -o projects/x/analysis/beautify_inventory.json
Dependencies: Dependencies:
@@ -32,6 +32,8 @@
sec_refine: "Refine spec first", sec_refine: "Refine spec first",
sub_mode: "Narrative mode", sub_mode: "Narrative mode",
sub_visual: "Visual style", sub_visual: "Visual style",
sub_divergence: "Material divergence (how freely to reshape vs. stay close to the source)",
placeholder_divergence: "In your words — e.g. \"stick closely to the document\" / \"freely restructure and expand within the source\". Leave blank for a balanced default.",
custom: "Custom", custom: "Custom",
custom_placeholder: "Type your own…", custom_placeholder: "Type your own…",
recommended: "Recommended", recommended: "Recommended",
@@ -100,6 +102,8 @@
sec_refine: "先精修设计规范", sec_refine: "先精修设计规范",
sub_mode: "叙事模式", sub_mode: "叙事模式",
sub_visual: "视觉风格", sub_visual: "视觉风格",
sub_divergence: "材料发散度(多大程度重塑,还是贴近源材料)",
placeholder_divergence: "用你自己的话写,例如「严格贴着文档来」/「在源材料范围内自由重组并展开」。留空则按平衡处理。",
custom: "自定义", custom: "自定义",
custom_placeholder: "输入自定义内容…", custom_placeholder: "输入自定义内容…",
recommended: "推荐", recommended: "推荐",
@@ -287,6 +291,14 @@
var grouped = list.length && list[0] && list[0].items; var grouped = list.length && list[0] && list[0].items;
var flat = grouped ? list.reduce(function (a, g) { return a.concat(g.items || []); }, []) : list; var flat = grouped ? list.reduce(function (a, g) { return a.concat(g.items || []); }, []) : list;
var ids = flat.map(function (o) { return o.id; }); var ids = flat.map(function (o) { return o.id; });
// Optional personality spectrum: instead of a single ★ recommendation,
// the AI marks a few catalog ids (safe / shifted / bold) each with a
// temperament tag + a real-world analogy note. Replaces the single badge.
var spectrum = (opts2.spectrum && opts2.spectrum.length) ? opts2.spectrum : null;
var specById = {};
if (spectrum) spectrum.forEach(function (s) {
if (s && s.id) specById[s.id] = { tag: localized(s, "tag"), note: localized(s, "note") };
});
var allowCustom = opts2.allowCustom === true; // only for fields not fully enumerable var allowCustom = opts2.allowCustom === true; // only for fields not fully enumerable
var customSentinel = opts2.customSentinel || ""; var customSentinel = opts2.customSentinel || "";
var customInvalidValues = opts2.customInvalidValues || []; var customInvalidValues = opts2.customInvalidValues || [];
@@ -312,9 +324,15 @@
if (o.dim) label += " · " + o.dim; if (o.dim) label += " · " + o.dim;
var desc = optionDesc(o); var desc = optionDesc(o);
if (desc) label += (LANG === "zh" ? "" : " — ") + desc; if (desc) label += (LANG === "zh" ? "" : " — ") + desc;
var spec = specById[o.id];
if (spec && spec.note) label += " · " + spec.note;
var chip = el("div", "chip"); var chip = el("div", "chip");
chip.appendChild(el("span", "chip-text", label)); chip.appendChild(el("span", "chip-text", label));
if (o.id === recommendedId) { if (spec) {
// spectrum pick: badge shows its temperament tag, not the generic ★
chip.classList.add("recommended");
chip.appendChild(el("span", "rec-badge", "★ " + (spec.tag || t("recommended"))));
} else if (!spectrum && o.id === recommendedId) {
chip.classList.add("recommended"); chip.classList.add("recommended");
chip.appendChild(el("span", "rec-badge", "★ " + t("recommended"))); chip.appendChild(el("span", "rec-badge", "★ " + t("recommended")));
} }
@@ -494,6 +512,15 @@
var sec = section(3, "sec_audience"); var sec = section(3, "sec_audience");
textField(sec, function () { return STATE.audience; }, textField(sec, function () { return STATE.audience; },
function (v) { STATE.audience = v; }, "placeholder_audience", false); function (v) { STATE.audience = v; }, "placeholder_audience", false);
// Material divergence — a distinct, free-text sub-question inside §c, shown
// right under the audience box: the user states in their own words how
// closely to follow the source vs. how freely to reshape it. Free prose, not
// fixed options; no page-count coupling, no source-signal recommendation.
var subDiv = el("div", "subfield");
subDiv.appendChild(el("div", "subfield-label", t("sub_divergence")));
textField(subDiv, function () { return STATE.content_divergence; },
function (v) { STATE.content_divergence = v; }, "placeholder_divergence", false);
sec.appendChild(subDiv);
host.appendChild(sec); host.appendChild(sec);
} }
@@ -505,7 +532,8 @@
var sub2 = el("div", "subfield"); var sub2 = el("div", "subfield");
sub2.appendChild(el("div", "subfield-label", t("sub_visual"))); sub2.appendChild(el("div", "subfield-label", t("sub_visual")));
enumField(sub2, CAT.visual_styles, recOrFirst("visual_style", CAT.visual_styles), enumField(sub2, CAT.visual_styles, recOrFirst("visual_style", CAT.visual_styles),
function () { return STATE.visual_style; }, function (v) { STATE.visual_style = v; }, { allowCustom: true }); function () { return STATE.visual_style; }, function (v) { STATE.visual_style = v; },
{ allowCustom: true, spectrum: REC && REC.visual_style_spectrum });
sec.appendChild(sub2); sec.appendChild(sub2);
host.appendChild(sec); host.appendChild(sec);
} }
@@ -1031,6 +1059,7 @@
STATE.canvas = pick("canvas", CAT.canvas); STATE.canvas = pick("canvas", CAT.canvas);
STATE.page_count = (REC.page_count && REC.page_count.value != null) ? String(REC.page_count.value) : ""; STATE.page_count = (REC.page_count && REC.page_count.value != null) ? String(REC.page_count.value) : "";
STATE.audience = (REC.audience && REC.audience.value) || ""; STATE.audience = (REC.audience && REC.audience.value) || "";
STATE.content_divergence = (REC.content_divergence && REC.content_divergence.value) || ""; // free text; blank = balanced default
STATE.mode = pick("mode", CAT.modes); STATE.mode = pick("mode", CAT.modes);
STATE.visual_style = pick("visual_style", CAT.visual_styles); STATE.visual_style = pick("visual_style", CAT.visual_styles);
@@ -29,9 +29,9 @@ pip install flask
## Two kinds of field ## Two kinds of field
- **Enumerable + custom** — canvas / mode / visual_style / icons / image usage. The page lists common options from `static/catalogs.json`, badges the AI's recommendation, and still offers a Custom box for edge cases (custom canvas size, bespoke narrative mode, mixed image plan, self-provided icon system, etc.). - **Enumerable + custom** — canvas / mode / visual_style / icons / image usage. The page lists common options from `static/catalogs.json`, badges the AI's recommendation, and still offers a Custom box for edge cases (custom canvas size, bespoke narrative mode, mixed image plan, self-provided icon system, etc.). `visual_style` additionally honors an optional `visual_style_spectrum` that badges a 3-pick personality spectrum (safe / shifted / bold, each with a temperament tag + analogy) in place of the single recommendation — see the schema below.
- **Closed enumerable** — formula policy / generation mode / refine spec, plus AI source only when image usage may include `ai`. These have no Custom box; out-of-catalog values snap back to the recommended option. Use pipeline vocabulary: icon ids are actual library ids such as `tabler-outline`, or `emoji` for system emoji; image usage labels mirror Strategist terminology: `ai` = AI-generated, `web` = Web-sourced, `provided` = User-provided, `placeholder` = Placeholder, `none` = No images. Use custom prose only when several sources are mixed. - **Closed enumerable** — formula policy / generation mode / refine spec, plus AI source only when image usage may include `ai`. These have no Custom box; out-of-catalog values snap back to the recommended option. Use pipeline vocabulary: icon ids are actual library ids such as `tabler-outline`, or `emoji` for system emoji; image usage labels mirror Strategist terminology: `ai` = AI-generated, `web` = Web-sourced, `provided` = User-provided, `placeholder` = Placeholder, `none` = No images. Use custom prose only when several sources are mixed.
- **Generative (open)** — color, typography, generated-image style. No finite catalog; the AI authors **≥3 candidates** the page renders as cards (never a single option — creative fields must offer real choice; fewer than 3 only on the honest-shortfall exception). `page_count` and `audience` are free inputs. - **Generative (open)** — color, typography, generated-image style. No finite catalog; the AI authors **≥3 candidates** the page renders as cards (never a single option — creative fields must offer real choice; fewer than 3 only on the honest-shortfall exception). `page_count`, `audience`, and `content_divergence` are free inputs (`content_divergence` is a free-text intent shown under audience in §c, not a fixed-option field).
**Custom box** appears only on fields whose universe is genuinely open — `canvas`, `mode`, `visual_style`, `icons`, and `image_usage`. Fully closed sets — `image_ai_path`, `formula_policy`, `generation_mode`, `refine_spec` — have **no** Custom box; an out-of-catalog value there is snapped back to the recommended option. **Custom box** appears only on fields whose universe is genuinely open — `canvas`, `mode`, `visual_style`, `icons`, and `image_usage`. Fully closed sets — `image_ai_path`, `formula_policy`, `generation_mode`, `refine_spec` — have **no** Custom box; an out-of-catalog value there is snapped back to the recommended option.
@@ -59,8 +59,9 @@ Both files live under `<project_path>/confirm_ui/`.
"formula_policy": "mixed", "formula_policy": "mixed",
"generation_mode": "continuous" "generation_mode": "continuous"
}, },
"page_count": { "value": "12-15" }, "page_count": { "value": "12-15" },
"audience": { "value": "..." }, "audience": { "value": "..." },
"content_divergence": { "value": "" },
"color": { "color": {
"selected": 0, "selected": 0,
"candidates": [ "candidates": [
@@ -97,6 +98,11 @@ Both files live under `<project_path>/confirm_ui/`.
} }
] ]
}, },
"visual_style_spectrum": [
{ "id": "soft-rounded", "tag_zh": "稳妥专业", "tag_en": "Safe & professional", "note_zh": "像 Notion 官网", "note_en": "like the Notion site" },
{ "id": "editorial", "tag_zh": "编辑质感", "tag_en": "Editorial depth", "note_zh": "像经济学人专题", "note_en": "like an Economist feature" },
{ "id": "brutalist", "tag_zh": "硬核宣言", "tag_en": "Bold manifesto", "note_zh": "像研究机构年度宣言", "note_en": "like a research-house manifesto" }
],
"refine_spec": { "value": false } "refine_spec": { "value": false }
} }
``` ```
@@ -111,7 +117,9 @@ Both files live under `<project_path>/confirm_ui/`.
- **Typography candidates split CJK and Latin** for both `heading` and `body`; `css` is the fallback preview `font-family` stack. The page previews CJK sample text with `cjk + css` and Latin sample text with `latin + css`, so the two script choices are visible independently. Each candidate should also include `body_size` (the body baseline in px; common values are 18 and 24, but any reasonable integer is valid). The page exposes `body_size` as an editable numeric field whose hint shows a canvas-appropriate range (≈2.53.3% of the confirmed canvas height, parsed from the canvas `dim`), updated when the canvas changes — a hint only, never an override, so a 16:9 default is not silently kept on a tall canvas (Xiaohongshu / Story / A4). It also offers a custom typography text box so the user is not limited to the proposed candidates. - **Typography candidates split CJK and Latin** for both `heading` and `body`; `css` is the fallback preview `font-family` stack. The page previews CJK sample text with `cjk + css` and Latin sample text with `latin + css`, so the two script choices are visible independently. Each candidate should also include `body_size` (the body baseline in px; common values are 18 and 24, but any reasonable integer is valid). The page exposes `body_size` as an editable numeric field whose hint shows a canvas-appropriate range (≈2.53.3% of the confirmed canvas height, parsed from the canvas `dim`), updated when the canvas changes — a hint only, never an override, so a 16:9 default is not silently kept on a tall canvas (Xiaohongshu / Story / A4). It also offers a custom typography text box so the user is not limited to the proposed candidates.
- **Combined style preview** — a compact live "overall impression" strip sits just above the color section and is **sticky**: it pins under the topbar so it stays visible while the user scrolls through the color / icon / typography sections, keeping the picking controls and their combined effect on screen together. It applies the currently selected color palette **and** typography (heading sample in `primary` over `background`, body sample in `body_text`, an `accent` bar, a `secondary_bg` chip) and repaints on every color / HEX-override / font / `body_size` change. It does not replace the per-candidate swatches or font samples (those stay for picking); it is deliberately an abstract style chip, **not** a slide-layout preview — page layout preview remains the live-preview server's job (Step 6). No schema field; it derives entirely from the existing color + typography selections. - **Combined style preview** — a compact live "overall impression" strip sits just above the color section and is **sticky**: it pins under the topbar so it stays visible while the user scrolls through the color / icon / typography sections, keeping the picking controls and their combined effect on screen together. It applies the currently selected color palette **and** typography (heading sample in `primary` over `background`, body sample in `body_text`, an `accent` bar, a `secondary_bg` chip) and repaints on every color / HEX-override / font / `body_size` change. It does not replace the per-candidate swatches or font samples (those stay for picking); it is deliberately an abstract style chip, **not** a slide-layout preview — page layout preview remains the live-preview server's job (Step 6). No schema field; it derives entirely from the existing color + typography selections.
- **Generated image style candidates** live in `image_strategy.candidates` and are shown only when `image_usage` is `ai` or a custom image plan may include generated images. Each candidate records `rendering`, `palette`, and short `visual` / `color` / `mood` lines from Strategist h.5. The chosen value is written to `result.json.image_strategy`; it is omitted when generated images are not part of the plan. - **Generated image style candidates** live in `image_strategy.candidates` and are shown only when `image_usage` is `ai` or a custom image plan may include generated images. Each candidate records `rendering`, `palette`, and short `visual` / `color` / `mood` lines from Strategist h.5. The chosen value is written to `result.json.image_strategy`; it is omitted when generated images are not part of the plan.
- **`visual_style_spectrum`** (optional) lets the AI surface the deck's aesthetic as a **personality spectrum** instead of one badged style. Each entry is `{ "id", "tag_zh"/"tag_en", "note_zh"/"note_en" }` where `id` is a real `visual_styles` catalog id; the page badges those chips with their temperament `tag` (replacing the single ★) and appends the `note` (a real-world analogy) inline. The full grouped style list and Custom box stay visible below, and `recommend.visual_style` is still the pre-selected default (it should equal the spectrum's safe pick). Author **≥3** spanning safe / shifted / bold (mirrors h.5; honest-shortfall exception applies — fewer only when the constraints genuinely cannot yield 3). The user's pick still writes back to `result.json.visual_style` as a plain id; the spectrum is presentation-only. Omit the field to fall back to the single-recommendation badge.
- `recommend.generation_mode` and `refine_spec` mirror the two mandatory notes in SKILL.md Step 4. Confirmed `generation_mode: "split"` / `refine_spec: true` are explicit user choices, equivalent to opting in through chat. - `recommend.generation_mode` and `refine_spec` mirror the two mandatory notes in SKILL.md Step 4. Confirmed `generation_mode: "split"` / `refine_spec: true` are explicit user choices, equivalent to opting in through chat.
- `content_divergence` is a **free-text** field shown right under the audience box in §c — the user states in their own words how closely to follow the source vs how freely to reshape it (e.g. "stick closely to the document" / "freely restructure and expand within the source"). It is **not** a fixed-option field; blank means a balanced default. Whatever the level, facts stay sourced — reshaping develops what is in the source, never imports facts from outside it. The Strategist consumes the prose when authoring the §IX outline and records it in `design_spec.md §I`; it is **not** written to `spec_lock.md` (the Executor never reads it). It carries no page-count coupling and no source-signal recommendation — it is purely the user's stated intent. Beautify / template-fill keep content verbatim and do not surface this field.
- `lang` is a soft default; an explicit user language choice in the page (persisted to `localStorage`) wins. - `lang` is a soft default; an explicit user language choice in the page (persisted to `localStorage`) wins.
### Output — `result.json` (written on submit, read by the AI) ### Output — `result.json` (written on submit, read by the AI)
@@ -121,6 +129,7 @@ Both files live under `<project_path>/confirm_ui/`.
"canvas": "ppt169", "canvas": "ppt169",
"page_count": "12-15", "page_count": "12-15",
"audience": "...", "audience": "...",
"content_divergence": "freely restructure and expand within the source",
"mode": "pyramid", "mode": "pyramid",
"visual_style": "swiss-minimal", "visual_style": "swiss-minimal",
"color": { "name": "...", "palette": { "background": "#...", "secondary_bg": "#...", "primary": "#...", "accent": "#...", "secondary_accent": "#...", "body_text": "#..." } }, "color": { "name": "...", "palette": { "background": "#...", "secondary_bg": "#...", "primary": "#...", "accent": "#...", "secondary_accent": "#...", "body_text": "#..." } },
@@ -122,6 +122,7 @@ python3 scripts/source_to_md/ppt_to_md.py template.ppsx -o notes/template.md
Behavior: Behavior:
- extracts slide text in reading order - extracts slide text in reading order
- converts PowerPoint tables to Markdown tables - converts PowerPoint tables to Markdown tables
- transcribes native chart data (type + categories × series values) into a Markdown table, so chart numbers are not lost in conversion
- exports embedded pictures to a sibling `_files/` directory - exports embedded pictures to a sibling `_files/` directory
- appends speaker notes when present - appends speaker notes when present
@@ -133,6 +134,28 @@ pip install python-pptx
Legacy `.ppt` is not parsed directly. Resave it as `.pptx` or export it to PDF first. 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
- `source_profile.json` — the single multi-deck index: a compact Strategist-facing digest per deck (over identity, tables, charts, 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 / template-fill workflows 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.
- Template-fill uses the slide library as the native PPTX fill contract.
## `source_to_md/web_to_md.py` ## `source_to_md/web_to_md.py`
Convert web pages to Markdown and download images locally. Convert web pages to Markdown and download images locally.
@@ -167,6 +167,8 @@ python3 scripts/image_search.py "offshore wind farm" \
--orientation landscape -o projects/demo/images --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 (Openverse and Wikimedia work with no key; configure Pexels / Pixabay for better stock-photo quality): Providers (Openverse and Wikimedia work with no key; configure Pexels / Pixabay for better stock-photo quality):
| Provider | Config | Strength | | Provider | Config | Strength |
@@ -22,6 +22,11 @@ Notes:
note), to avoid leaving unintended artifacts that could be committed by mistake. note), to avoid leaving unintended artifacts that could be committed by mistake.
Pass `--copy` to force a copy for in-repo sources instead. Pass `--copy` to force a copy for in-repo sources instead.
- `--move` and `--copy` are mutually exclusive. - `--move` and `--copy` are mutually exclusive.
- 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.
Common formats: Common formats:
- `ppt169` - `ppt169`
@@ -39,10 +39,13 @@ Examples:
from __future__ import annotations from __future__ import annotations
import argparse import argparse
import concurrent.futures
import importlib import importlib
import json import json
import os import os
import sys import sys
import tempfile
import threading
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Optional
@@ -85,6 +88,29 @@ ALL_PROVIDERS: tuple[str, ...] = ZERO_CONFIG_PROVIDERS + KEYED_PROVIDERS
ORIENTATION_CHOICES = ("any", "landscape", "portrait", "square") ORIENTATION_CHOICES = ("any", "landscape", "portrait", "square")
# --- Batch mode (`--batch image_queries.json`) -----------------------------
# Web providers are politeness-sensitive (Wikimedia/Openverse expect a modest
# rate), so the default concurrency is deliberately low. Sister-tool
# `image_gen.py` hits a paid API and defaults higher; here 3 keeps several
# rows in flight without hammering any single free provider. Set to 1 to
# restore strict one-at-a-time pacing.
DEFAULT_SEARCH_CONCURRENCY = 3
SEARCH_STATUS_PENDING = "Pending"
SEARCH_STATUS_SOURCED = "Sourced"
SEARCH_STATUS_FAILED = "Failed"
SEARCH_STATUS_NEEDS_MANUAL = "Needs-Manual"
SEARCH_VALID_STATUSES = {
SEARCH_STATUS_PENDING,
SEARCH_STATUS_SOURCED,
SEARCH_STATUS_FAILED,
SEARCH_STATUS_NEEDS_MANUAL,
}
# A row reaching `Needs-Manual` after the full provider/stage chain is terminal
# (see image-searcher.md §8); only Pending/Failed rows are retried on re-run.
SEARCH_RETRYABLE_STATUSES = {SEARCH_STATUS_PENDING, SEARCH_STATUS_FAILED}
SEARCH_REQUIRED_ITEM_FIELDS = ("filename", "query", "status")
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# .env loading # .env loading
@@ -531,6 +557,241 @@ def promote_candidate(
return 0 return 0
# ---------------------------------------------------------------------------
# Batch mode (`--batch image_queries.json`)
# ---------------------------------------------------------------------------
def load_search_manifest(path: str) -> dict:
"""Load and validate an ``image_queries.json`` batch manifest.
Schema (top level): ``{"items": [ ... ]}``. Each item requires
``filename``, ``query``, ``status``. Optional per-item overrides:
``slide``, ``purpose``, ``orientation``, ``provider``,
``strict_no_attribution``, ``min_width``, ``min_height``, ``last_error``.
"""
try:
data = json.loads(Path(path).read_text(encoding="utf-8"))
except json.JSONDecodeError as exc:
raise ValueError(
f"Invalid JSON in {path}: {exc.msg} "
f"(line {exc.lineno}, col {exc.colno})"
) from exc
if not isinstance(data, dict):
raise ValueError(
f"{path}: top level must be a JSON object, got {type(data).__name__}"
)
items = data.get("items")
if not isinstance(items, list) or not items:
raise ValueError(f"{path}: 'items' must be a non-empty array")
seen_filenames: set[str] = set()
for i, item in enumerate(items):
prefix = f"{path}: items[{i}]"
if not isinstance(item, dict):
raise ValueError(f"{prefix} must be an object")
for field in SEARCH_REQUIRED_ITEM_FIELDS:
if field not in item:
raise ValueError(f"{prefix} missing required field '{field}'")
if not isinstance(item[field], str) or not item[field].strip():
raise ValueError(
f"{prefix} field '{field}' must be a non-empty string"
)
if item["status"] not in SEARCH_VALID_STATUSES:
raise ValueError(
f"{prefix} status '{item['status']}' is invalid. "
f"Valid: {sorted(SEARCH_VALID_STATUSES)}"
)
fname = item["filename"]
if fname in seen_filenames:
raise ValueError(f"{prefix} duplicate filename '{fname}'")
seen_filenames.add(fname)
return data
def save_search_manifest(path: str, data: dict) -> None:
"""Atomically write the batch manifest back (tmp file + rename)."""
target = Path(path)
fd, tmp_path = tempfile.mkstemp(
prefix=target.stem + ".", suffix=".tmp", dir=str(target.parent)
)
try:
with os.fdopen(fd, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
f.write("\n")
os.replace(tmp_path, target)
except Exception:
try:
os.unlink(tmp_path)
except OSError:
pass
raise
def _resolve_search_concurrency(cli_value: Optional[int]) -> int:
"""CLI value wins over IMAGE_SEARCH_CONCURRENCY env; default 3."""
if cli_value is not None:
return max(1, cli_value)
env_val = os.environ.get("IMAGE_SEARCH_CONCURRENCY", "").strip()
if env_val.isdigit():
return max(1, int(env_val))
return DEFAULT_SEARCH_CONCURRENCY
def _search_one_item(
item: dict,
*,
output_dir: Path,
save_candidates: bool,
max_candidates: int,
default_provider: Optional[str],
default_strict: bool,
default_min_width: int,
default_min_height: int,
) -> tuple[Optional[dict], Optional[str]]:
"""Run the full search + download for one batch item (thread worker).
Returns ``(manifest_item, error)``. Only the network/disk work happens
here; all manifest writes are serialized by the caller.
"""
orientation = item.get("orientation", "any") or "any"
strict = bool(item.get("strict_no_attribution", default_strict))
request = ImageSearchRequest(
query=item["query"],
purpose=item.get("purpose", ""),
orientation="" if orientation == "any" else orientation,
filename=item["filename"],
slide=item.get("slide", ""),
min_width=int(item.get("min_width", default_min_width)),
min_height=int(item.get("min_height", default_min_height)),
)
pinned = item.get("provider") or default_provider
providers = [pinned] if pinned else _default_provider_chain()
output_path = output_dir / item["filename"]
candidate, provider_name, stage = search_and_download(
providers,
request,
output_path=output_path,
strict_no_attribution=strict,
save_candidates=save_candidates,
max_candidates=max_candidates,
)
if candidate is None:
return None, "no acceptable candidate across all providers/stages"
actual_dimensions = _measure_actual_image(output_path)
item_args = argparse.Namespace(
filename=item["filename"],
slide=item.get("slide", ""),
purpose=item.get("purpose", ""),
query=item["query"],
orientation=orientation,
)
manifest_item = _candidate_to_manifest_item(
candidate,
item_args,
provider_name=provider_name,
stage=stage,
actual_dimensions=actual_dimensions,
)
return manifest_item, None
def run_search_manifest(
manifest: dict,
manifest_path: str,
*,
output_dir: Path,
sources_manifest_path: Path,
concurrency: int,
save_candidates: bool,
max_candidates: int,
default_provider: Optional[str],
default_strict: bool,
default_min_width: int,
default_min_height: int,
) -> tuple[int, int, int]:
"""Process all Pending/Failed rows concurrently with a bounded pool.
On success the rich provenance entry is appended to ``image_sources.json``
(the credit source of truth) and the row's status flips to ``Sourced``.
A row that exhausts the provider/stage chain becomes ``Needs-Manual``
(terminal). Status is written back after each completion, so an interrupt
preserves finished rows. Returns ``(sourced, needs_manual, skipped)``.
"""
items = manifest["items"]
pending_idx = [
i for i, it in enumerate(items)
if it["status"] in SEARCH_RETRYABLE_STATUSES
]
total = len(pending_idx)
skipped = len(items) - total
if total == 0:
print(
f"[Batch] Nothing to do — all {len(items)} row(s) already in a "
"terminal state (Sourced / Needs-Manual)."
)
return 0, 0, skipped
print(
f"\n[Batch] {total} row(s) to search, {skipped} already done. "
f"concurrency={concurrency}\n"
)
sourced_count = 0
needs_manual_count = 0
write_lock = threading.Lock()
def _one(idx: int):
try:
manifest_item, error = _search_one_item(
items[idx],
output_dir=output_dir,
save_candidates=save_candidates,
max_candidates=max_candidates,
default_provider=default_provider,
default_strict=default_strict,
default_min_width=default_min_width,
default_min_height=default_min_height,
)
return idx, manifest_item, error
except Exception as exc: # noqa: BLE001 — provider code raises freely
return idx, None, str(exc)[:500]
with concurrent.futures.ThreadPoolExecutor(max_workers=concurrency) as ex:
futures = [ex.submit(_one, i) for i in pending_idx]
for fut in concurrent.futures.as_completed(futures):
idx, manifest_item, error = fut.result()
item = items[idx]
with write_lock:
if manifest_item is not None:
write_sources_manifest(sources_manifest_path, manifest_item)
item["status"] = SEARCH_STATUS_SOURCED
item["provider"] = manifest_item.get("provider", "")
item["license_tier"] = manifest_item.get("license_tier", "")
item.pop("last_error", None)
sourced_count += 1
print(f" [OK] {item['filename']} ({item['provider']})")
else:
item["status"] = SEARCH_STATUS_NEEDS_MANUAL
item["last_error"] = error or "search failed"
needs_manual_count += 1
print(f" [MANUAL] {item['filename']}{item['last_error']}")
save_search_manifest(manifest_path, manifest)
print(
f"\n[Batch] Done: {sourced_count} sourced / {needs_manual_count} "
f"needs-manual ({skipped} pre-skipped). Manifest: {manifest_path}"
)
return sourced_count, needs_manual_count, skipped
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# CLI # CLI
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -544,11 +805,19 @@ def build_parser() -> argparse.ArgumentParser:
), ),
formatter_class=argparse.RawDescriptionHelpFormatter, formatter_class=argparse.RawDescriptionHelpFormatter,
) )
parser.add_argument("query", help="Search query (2-5 keywords work best).") parser.add_argument(
"query",
nargs="?",
default=None,
help="Search query (2-5 keywords work best). Omit in --batch mode.",
)
parser.add_argument( parser.add_argument(
"--filename", "--filename",
required=True, default=None,
help="Local filename for the chosen image (e.g. cover_bg.jpg).", help=(
"Local filename for the chosen image (e.g. cover_bg.jpg). "
"Required for single-query and --promote modes; ignored in --batch."
),
) )
parser.add_argument( parser.add_argument(
"-o", "-o",
@@ -606,6 +875,27 @@ def build_parser() -> argparse.ArgumentParser:
default=None, default=None,
help="Override manifest path. Defaults to <output>/image_sources.json.", help="Override manifest path. Defaults to <output>/image_sources.json.",
) )
parser.add_argument(
"--batch",
default=None,
metavar="QUERIES_JSON",
help=(
"Process a batch of search requests from an image_queries.json "
"manifest concurrently, writing provenance into image_sources.json "
"and status back into the queries manifest."
),
)
parser.add_argument(
"--concurrency",
type=int,
default=None,
help=(
"Max concurrent searches in --batch mode. Defaults to "
f"IMAGE_SEARCH_CONCURRENCY env or {DEFAULT_SEARCH_CONCURRENCY}. "
"Keep modest — free providers are rate-sensitive; use 1 for "
"strict one-at-a-time pacing."
),
)
parser.add_argument( parser.add_argument(
"--no-candidates", "--no-candidates",
action="store_true", action="store_true",
@@ -651,6 +941,8 @@ def main(argv: Optional[list[str]] = None) -> int:
# --- Promote mode --- # --- Promote mode ---
if args.promote: if args.promote:
if not args.filename:
parser.error("--filename is required in --promote mode")
return promote_candidate( return promote_candidate(
output_dir, output_dir,
args.filename, args.filename,
@@ -658,7 +950,52 @@ def main(argv: Optional[list[str]] = None) -> int:
manifest_path=Path(args.manifest) if args.manifest else None, manifest_path=Path(args.manifest) if args.manifest else None,
) )
# --- Search mode --- # --- Batch mode ---
if args.batch:
if not os.path.isfile(args.batch):
print(f"Error: queries manifest not found: {args.batch}", file=sys.stderr)
return 1
try:
manifest = load_search_manifest(args.batch)
except ValueError as exc:
print(f"Error: {exc}", file=sys.stderr)
return 1
batch_output_dir = (
output_dir if args.output != "." else Path(args.batch).parent
)
batch_output_dir.mkdir(parents=True, exist_ok=True)
sources_manifest_path = (
Path(args.manifest) if args.manifest
else default_manifest_path(str(batch_output_dir))
)
try:
_, needs_manual, _ = run_search_manifest(
manifest,
args.batch,
output_dir=batch_output_dir,
sources_manifest_path=sources_manifest_path,
concurrency=_resolve_search_concurrency(args.concurrency),
save_candidates=not args.no_candidates,
max_candidates=args.max_candidates,
default_provider=args.provider,
default_strict=args.strict_no_attribution,
default_min_width=args.min_width,
default_min_height=args.min_height,
)
except KeyboardInterrupt:
print("\n\nInterrupted by user. Partial progress preserved in manifest.")
return 130
# Mirror image_gen.py: a non-zero code flags rows that need manual
# attention. It is a signal, not a halt — the workflow (image-base.md
# §6) surfaces Needs-Manual rows and continues regardless.
return 1 if needs_manual else 0
# --- Single-query search mode ---
if not args.query:
parser.error("query is required unless --batch or --promote is used")
if not args.filename:
parser.error("--filename is required in single-query mode")
request = ImageSearchRequest( request = ImageSearchRequest(
query=args.query, query=args.query,
purpose=args.purpose, purpose=args.purpose,
@@ -0,0 +1,231 @@
#!/usr/bin/env python3
"""
PPT Master - PPTX Intake Enrichment
Extract reusable PPTX intake facts into a standard analysis bundle. This is a
read-only companion to `ppt_to_md.py`: Markdown remains the content source,
while this bundle provides canvas, visual identity, slide geometry, tables, and
native chart data for downstream workflows.
Usage:
python3 scripts/pptx_intake.py <source.pptx> -o <output_dir>
Examples:
python3 scripts/pptx_intake.py deck.pptx -o projects/demo/analysis
Dependencies:
None beyond the repository scripts used for PPTX parsing.
"""
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
from typing import Any
_SCRIPTS_DIR = Path(__file__).resolve().parent
if str(_SCRIPTS_DIR) not in sys.path:
sys.path.insert(0, str(_SCRIPTS_DIR))
from beautify_identity import extract_identity # noqa: E402
from template_fill_pptx.analyzer import analyze_pptx # noqa: E402
def _write_json(path: Path, payload: dict[str, Any]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
def _chart_summary(slide_library: dict[str, Any]) -> dict[str, Any]:
charts: list[dict[str, Any]] = []
total_series = 0
multi_plot_count = 0
for slide in slide_library.get("slides", []):
for chart in slide.get("charts", []):
series_count = int(chart.get("series_count") or 0)
plot_types = chart.get("plot_types") or []
if len(plot_types) > 1:
multi_plot_count += 1
total_series += series_count
charts.append(
{
"slide_index": slide.get("slide_index"),
"chart_id": chart.get("chart_id"),
"chart_type": chart.get("chart_type"),
"plot_types": plot_types,
"category_count": chart.get("category_count", 0),
"series_count": series_count,
"series_names": [
series.get("name")
for series in chart.get("series", [])
if series.get("name")
],
}
)
return {
"chart_count": len(charts),
"series_count": total_series,
"multi_plot_chart_count": multi_plot_count,
"charts": charts,
}
def _table_summary(slide_library: dict[str, Any]) -> dict[str, Any]:
tables: list[dict[str, Any]] = []
for slide in slide_library.get("slides", []):
for table in slide.get("tables", []):
tables.append(
{
"slide_index": slide.get("slide_index"),
"table_id": table.get("table_id"),
"row_count": table.get("row_count", 0),
"column_count": table.get("column_count", 0),
}
)
return {"table_count": len(tables), "tables": tables}
def build_source_profile(
pptx_path: Path,
identity: dict[str, Any],
slide_library: dict[str, Any],
stem: str | None = None,
) -> dict[str, Any]:
"""Build the Strategist-facing per-deck digest over the raw intake artifacts.
`stem` is the source-file stem used to prefix the per-deck artifact files so
several decks can coexist in one `analysis/` folder. Defaults to the pptx stem.
"""
stem = stem or pptx_path.stem
return {
"schema": "pptx_intake_profile.v1",
"stem": stem,
"source_pptx": str(pptx_path),
"slide_count": slide_library.get("slide_count", identity.get("slide_count", 0)),
"usage_contract": {
"standard_generation": (
"Use identity and slide-library fields as source facts and recommendation "
"candidates only; do not preserve original page count, order, or coordinates "
"unless the user selected a beautify/template-fill workflow."
),
"beautify": (
"Promote source text, page order, page count, colors, and fonts into locked "
"constraints after user confirmation."
),
"template_fill": (
"Use slide slots, tables, charts, and geometry as the native PPTX fill contract."
),
},
"artifacts": {
"identity": f"{stem}.identity.json",
"slide_library": f"{stem}.slide_library.json",
},
"canvas": identity.get("canvas", {}),
"identity": {
"theme_palette": (identity.get("theme") or {}).get("palette", {}),
"theme_fonts": (identity.get("theme") or {}).get("fonts", {}),
"observed_colors": (identity.get("observed") or {}).get("colors", []),
"observed_fonts": (identity.get("observed") or {}).get("fonts", {}),
},
"structure": {
"canvas_px": slide_library.get("canvas_px", {}),
"page_types": [
{
"slide_index": slide.get("slide_index"),
"page_type": slide.get("page_type"),
"slot_count": len(slide.get("slots", [])),
}
for slide in slide_library.get("slides", [])
],
},
"tables": _table_summary(slide_library),
"charts": _chart_summary(slide_library),
}
SOURCE_INDEX_NAME = "source_profile.json"
def upsert_source_index(output_dir: Path, digest: dict[str, Any]) -> Path:
"""Merge one deck's digest into the single multi-deck index `source_profile.json`.
The index stays the single must-read entry for the Strategist: it inlines every
deck's digest under `decks[]`, so a one-deck project is a one-entry index and a
multi-deck project lists each source deck self-containedly. Re-importing a deck
with the same stem replaces its entry in place.
"""
index_path = output_dir / SOURCE_INDEX_NAME
index: dict[str, Any] = {}
if index_path.is_file():
try:
loaded = json.loads(index_path.read_text(encoding="utf-8"))
if isinstance(loaded, dict) and isinstance(loaded.get("decks"), list):
index = loaded
except (json.JSONDecodeError, OSError):
index = {}
stem = digest.get("stem")
decks = [d for d in index.get("decks", []) if d.get("stem") != stem]
decks.append(digest)
decks.sort(key=lambda d: str(d.get("stem", "")))
index = {
"schema": "pptx_intake_index.v1",
"deck_count": len(decks),
"decks": decks,
}
_write_json(index_path, index)
return index_path
def run_intake(pptx_path: Path, output_dir: Path) -> dict[str, Path]:
"""Write `<stem>.identity.json`, `<stem>.slide_library.json`, and merge the
deck's digest into the single multi-deck index `source_profile.json`."""
output_dir.mkdir(parents=True, exist_ok=True)
stem = pptx_path.stem
identity = extract_identity(pptx_path)
slide_library = analyze_pptx(pptx_path)
digest = build_source_profile(pptx_path, identity, slide_library, stem)
identity_path = output_dir / f"{stem}.identity.json"
slide_library_path = output_dir / f"{stem}.slide_library.json"
_write_json(identity_path, identity)
_write_json(slide_library_path, slide_library)
profile_path = upsert_source_index(output_dir, digest)
return {
"identity": identity_path,
"slide_library": slide_library_path,
"source_profile": profile_path,
}
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description="Extract standard PPTX intake analysis artifacts.",
formatter_class=argparse.RawDescriptionHelpFormatter,
)
parser.add_argument("source", help="Source PPTX / PPTM / PPSX / PPSM / POTX / POTM file")
parser.add_argument("-o", "--output-dir", required=True, help="Output project analysis directory")
return parser
def main(argv: list[str] | None = None) -> int:
parser = build_parser()
args = parser.parse_args(argv)
source = Path(args.source).expanduser().resolve()
if not source.is_file():
print(f"Error: source not found: {source}", file=sys.stderr)
return 1
try:
outputs = run_intake(source, Path(args.output_dir).expanduser().resolve())
except (RuntimeError, KeyError, ValueError) as exc:
print(f"Error: PPTX intake failed: {exc}", file=sys.stderr)
return 1
print(f"PPTX intake -> {Path(args.output_dir).expanduser().resolve()}", file=sys.stderr)
for name, path in outputs.items():
print(f" {name}: {path}", file=sys.stderr)
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -11,6 +11,7 @@ Usage:
from __future__ import annotations from __future__ import annotations
import argparse import argparse
import filecmp
import json import json
import re import re
import shutil import shutil
@@ -147,6 +148,7 @@ class ProjectManager:
"notes", "notes",
"templates", "templates",
SOURCE_DIRNAME, SOURCE_DIRNAME,
"analysis",
"exports", "exports",
): ):
(project_path / rel_path).mkdir(parents=True, exist_ok=True) (project_path / rel_path).mkdir(parents=True, exist_ok=True)
@@ -161,11 +163,12 @@ class ProjectManager:
"## Directories\n\n" "## Directories\n\n"
"- `svg_output/`: raw SVG output\n" "- `svg_output/`: raw SVG output\n"
"- `svg_final/`: finalized SVG output\n" "- `svg_final/`: finalized SVG output\n"
"- `images/`: presentation assets\n" "- `images/`: runtime image pool; converter assets keep their original short filenames when possible\n"
"- `icons/`: project icon set — selected library icons copied in (via icon_sync.py) plus any custom icons you add; embedded from here at export\n" "- `icons/`: project icon set — selected library icons copied in (via icon_sync.py) plus any custom icons you add; embedded from here at export\n"
"- `notes/`: speaker notes\n" "- `notes/`: speaker notes\n"
"- `templates/`: project templates\n" "- `templates/`: project templates\n"
"- `sources/`: source materials and normalized markdown\n" "- `sources/`: source materials and normalized markdown\n"
"- `analysis/`: machine-extracted intermediate analysis (PPTX intake, image_analysis.csv) — the pipeline's canonical must-read source/asset facts\n"
"- `exports/`: main native pptx (timestamped); `_svg.pptx` sibling added when exported with `--svg-snapshot`\n" "- `exports/`: main native pptx (timestamped); `_svg.pptx` sibling added when exported with `--svg-snapshot`\n"
"- `backup/<timestamp>/`: svg_output/ archive (always written in default-flow mode; safe to delete old timestamps)\n" "- `backup/<timestamp>/`: svg_output/ archive (always written in default-flow mode; safe to delete old timestamps)\n"
), ),
@@ -181,6 +184,11 @@ class ProjectManager:
sources_dir.mkdir(parents=True, exist_ok=True) sources_dir.mkdir(parents=True, exist_ok=True)
return sources_dir return sources_dir
def _analysis_dir(self, project_path: Path) -> Path:
analysis_dir = project_path / "analysis"
analysis_dir.mkdir(parents=True, exist_ok=True)
return analysis_dir
def _ensure_unique_path(self, path: Path) -> Path: def _ensure_unique_path(self, path: Path) -> Path:
if not path.exists(): if not path.exists():
return path return path
@@ -275,6 +283,22 @@ class ProjectManager:
] ]
) )
def _import_pptx_intake(self, presentation_path: Path, project_dir: Path) -> Path:
# Multi-deck intake: each PPTX writes its own `<stem>.identity.json` /
# `<stem>.slide_library.json` and is merged into the single multi-deck
# index `analysis/source_profile.json` (one entry per source deck).
analysis_dir = self._analysis_dir(project_dir)
self._run_tool(
[
sys.executable,
str(TOOLS_DIR / "pptx_intake.py"),
str(presentation_path),
"-o",
str(analysis_dir),
]
)
return analysis_dir
def _import_excel(self, excel_path: Path, markdown_path: Path) -> None: def _import_excel(self, excel_path: Path, markdown_path: Path) -> None:
self._run_tool( self._run_tool(
[ [
@@ -307,6 +331,15 @@ class ProjectManager:
] ]
self._run_tool(command) self._run_tool(command)
def _is_valid_imported_url_markdown(self, markdown_path: Path) -> bool:
"""Return whether web_to_md produced a usable Markdown source."""
if not markdown_path.is_file():
return False
content = markdown_path.read_text(encoding="utf-8", errors="replace")
if "[Failed URLs]:" in content:
return False
return bool(content.strip())
def _archive_url_record(self, sources_dir: Path, url: str) -> Path: def _archive_url_record(self, sources_dir: Path, url: str) -> Path:
file_path = self._ensure_unique_path(sources_dir / f"{derive_url_basename(url)}.url.txt") file_path = self._ensure_unique_path(sources_dir / f"{derive_url_basename(url)}.url.txt")
file_path.write_text( file_path.write_text(
@@ -420,11 +453,53 @@ class ProjectManager:
suffix = "_files" suffix = "_files"
return name[:-len(suffix)] if name.endswith(suffix) else name return name[:-len(suffix)] if name.endswith(suffix) else name
def _image_destination_name(
self,
images_dir: Path,
source_file: Path,
namespace: str,
existing_manifest: dict[str, dict],
) -> str:
"""Return a short unique image filename for the runtime image pool."""
candidate = images_dir / source_file.name
if not candidate.exists():
return source_file.name
try:
meta = existing_manifest.get(candidate.name, {})
if (
meta.get("source_namespace") == namespace
and candidate.is_file()
and filecmp.cmp(source_file, candidate, shallow=False)
):
return candidate.name
except OSError:
pass
stem = source_file.stem
suffix = source_file.suffix
counter = 2
while True:
candidate = images_dir / f"{stem}_{counter}{suffix}"
if not candidate.exists():
return candidate.name
try:
meta = existing_manifest.get(candidate.name, {})
if (
meta.get("source_namespace") == namespace
and candidate.is_file()
and filecmp.cmp(source_file, candidate, shallow=False)
):
return candidate.name
except OSError:
pass
counter += 1
def _propagate_image_assets(self, asset_dir: Path, project_dir: Path) -> None: def _propagate_image_assets(self, asset_dir: Path, project_dir: Path) -> None:
"""Copy converter-generated image assets and manifest into project images/. """Copy converter-generated image assets and manifest into project images/.
Files are namespaced by source stem to avoid collisions when multiple Filenames are preserved when possible because source Markdown commonly
DOCX/PPTX sources contain identically-named internal media (image1.png, ...). uses short names that are meaningful in context. Only real collisions
receive a compact numeric suffix.
""" """
manifest_path = asset_dir / "image_manifest.json" manifest_path = asset_dir / "image_manifest.json"
if not manifest_path.is_file(): if not manifest_path.is_file():
@@ -443,6 +518,19 @@ class ProjectManager:
images_dir.mkdir(parents=True, exist_ok=True) images_dir.mkdir(parents=True, exist_ok=True)
namespace = self._namespace_from_asset_dir(asset_dir) namespace = self._namespace_from_asset_dir(asset_dir)
existing_manifest: dict[str, dict] = {}
destination_manifest = images_dir / "image_manifest.json"
if destination_manifest.is_file():
try:
data = json.loads(destination_manifest.read_text(encoding="utf-8"))
if isinstance(data, list):
existing_manifest = {
item["filename"]: item
for item in data
if isinstance(item, dict) and isinstance(item.get("filename"), str)
}
except (OSError, json.JSONDecodeError):
existing_manifest = {}
rename_map: dict[str, str] = {} rename_map: dict[str, str] = {}
copied_count = 0 copied_count = 0
@@ -451,8 +539,15 @@ class ProjectManager:
continue continue
if source_file.suffix.lower() not in IMAGE_ASSET_SUFFIXES: if source_file.suffix.lower() not in IMAGE_ASSET_SUFFIXES:
continue continue
new_name = f"{namespace}__{source_file.name}" new_name = self._image_destination_name(
shutil.copy2(source_file, images_dir / new_name) images_dir,
source_file,
namespace,
existing_manifest,
)
destination = images_dir / new_name
if source_file.resolve() != destination.resolve():
shutil.copy2(source_file, destination)
rename_map[source_file.name] = new_name rename_map[source_file.name] = new_name
copied_count += 1 copied_count += 1
@@ -464,7 +559,7 @@ class ProjectManager:
if not isinstance(original, str): if not isinstance(original, str):
continue continue
new_item = dict(item) new_item = dict(item)
new_item["filename"] = rename_map.get(original, f"{namespace}__{original}") new_item["filename"] = rename_map.get(original, original)
new_item["source_namespace"] = namespace new_item["source_namespace"] = namespace
rebased_items.append(new_item) rebased_items.append(new_item)
@@ -534,6 +629,7 @@ class ProjectManager:
"archived": [], "archived": [],
"markdown": [], "markdown": [],
"assets": [], "assets": [],
"analysis": [],
"notes": [], "notes": [],
"skipped": [], "skipped": [],
} }
@@ -548,17 +644,24 @@ class ProjectManager:
for item in source_items: for item in source_items:
if is_url(item): if is_url(item):
archived = self._archive_url_record(sources_dir, item)
markdown_path = self._ensure_unique_path( markdown_path = self._ensure_unique_path(
sources_dir / f"{derive_url_basename(item)}.md" sources_dir / f"{derive_url_basename(item)}.md"
) )
try: try:
self._import_url(item, markdown_path) self._import_url(item, markdown_path)
except Exception as exc: # pragma: no cover - summary path except Exception as exc: # pragma: no cover - summary path
archived = self._archive_url_record(sources_dir, item)
summary["archived"].append(str(archived))
summary["skipped"].append(f"{item}: {exc}") summary["skipped"].append(f"{item}: {exc}")
continue continue
summary["archived"].append(str(archived)) if not self._is_valid_imported_url_markdown(markdown_path):
markdown_path.unlink(missing_ok=True)
archived = self._archive_url_record(sources_dir, item)
summary["archived"].append(str(archived))
summary["skipped"].append(f"{item}: URL conversion produced no usable Markdown")
continue
summary["markdown"].append(str(markdown_path)) summary["markdown"].append(str(markdown_path))
self._propagate_companion_image_assets(markdown_path, project_dir) self._propagate_companion_image_assets(markdown_path, project_dir)
continue continue
@@ -640,6 +743,13 @@ class ProjectManager:
summary["skipped"].append(f"{item}: PDF conversion failed ({exc})") summary["skipped"].append(f"{item}: PDF conversion failed ({exc})")
elif suffix in PRESENTATION_SUFFIXES: elif suffix in PRESENTATION_SUFFIXES:
canonical_markdown_path = sources_dir / f"{archived_path.stem}.md" canonical_markdown_path = sources_dir / f"{archived_path.stem}.md"
try:
intake_dir = self._import_pptx_intake(archived_path, project_dir)
intake_str = str(intake_dir)
if intake_str not in summary["analysis"]:
summary["analysis"].append(intake_str)
except Exception as exc: # pragma: no cover - summary path
summary["notes"].append(f"{item}: PPTX intake analysis failed ({exc})")
if archived_path.stem in explicit_markdown_stems: if archived_path.stem in explicit_markdown_stems:
summary["notes"].append( summary["notes"].append(
f"{item}: skipped presentation auto-conversion because a same-stem Markdown source was provided" f"{item}: skipped presentation auto-conversion because a same-stem Markdown source was provided"
@@ -825,6 +935,10 @@ def main(argv: list[str] | None = None) -> int:
print("\nImported asset directories:") print("\nImported asset directories:")
for item in summary["assets"]: for item in summary["assets"]:
print(f" - {item}") print(f" - {item}")
if summary["analysis"]:
print("\nAnalysis artifacts:")
for item in summary["analysis"]:
print(f" - {item}")
if summary["notes"]: if summary["notes"]:
print("\nNotes:") print("\nNotes:")
for item in summary["notes"]: for item in summary["notes"]:
@@ -55,6 +55,10 @@ PANDOC_FORMATS = {
# Formats pandoc should extract embedded media from # Formats pandoc should extract embedded media from
PANDOC_MEDIA_FORMATS = {".odt"} PANDOC_MEDIA_FORMATS = {".odt"}
OFFICE_VECTOR_EXTENSIONS = {".emf", ".wmf"} OFFICE_VECTOR_EXTENSIONS = {".emf", ".wmf"}
IMAGE_ASSET_SUFFIXES = {
".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".tiff", ".tif",
".emf", ".wmf", ".svg",
}
DOCX_NS = { DOCX_NS = {
"w": "http://schemas.openxmlformats.org/wordprocessingml/2006/main", "w": "http://schemas.openxmlformats.org/wordprocessingml/2006/main",
@@ -166,6 +170,59 @@ def _is_office_vector(ext: str) -> bool:
return ext.lower() in OFFICE_VECTOR_EXTENSIONS return ext.lower() in OFFICE_VECTOR_EXTENSIONS
def _write_generic_image_manifest(
media_dir: Path,
rel_media_dir: str,
markdown: str,
source_kind: str,
) -> None:
"""Write lightweight image metadata for non-DOCX converter paths."""
if not media_dir.exists():
return
ref_pattern = re.compile(rf"{re.escape(rel_media_dir)}/([^)\s]+)")
refs = [Path(match.group(1)).name for match in ref_pattern.finditer(markdown)]
occurrence_map: dict[str, list[dict[str, object]]] = {}
for index, filename in enumerate(refs, 1):
occurrence_map.setdefault(filename, []).append({
"occurrence_index": index,
"source_ref": f"{rel_media_dir}/{filename}",
})
manifest: list[dict[str, object]] = []
for file_path in sorted(path for path in media_dir.iterdir() if path.is_file()):
ext = _normalize_ext(file_path.suffix)
if ext not in IMAGE_ASSET_SUFFIXES:
continue
width, height = _image_size(file_path)
ratio = width / height if width and height else None
asset_kind = "office_vector" if _is_office_vector(ext) else "bitmap"
occurrences = occurrence_map.get(file_path.name, [])
entry: dict[str, object] = {
"index": len(manifest) + 1,
"filename": file_path.name,
"original_filename": file_path.name,
"asset_kind": asset_kind,
"svg_renderable": asset_kind != "office_vector",
"pptx_native_supported": True,
"source_kind": source_kind,
"source_ext": ext,
"pixel_width": width,
"pixel_height": height,
"pixel_ratio": round(ratio, 6) if ratio else None,
"display_ratio": round(ratio, 6) if ratio else None,
"occurrences": occurrences,
"usage_count": len(occurrences) if occurrences else 1,
}
manifest.append(entry)
if manifest:
(media_dir / "image_manifest.json").write_text(
json.dumps(manifest, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
def _local_name(elem: ET.Element) -> str: def _local_name(elem: ET.Element) -> str:
"""Return an XML element local name without its namespace.""" """Return an XML element local name without its namespace."""
return elem.tag.rsplit("}", 1)[-1] return elem.tag.rsplit("}", 1)[-1]
@@ -829,6 +886,7 @@ def _convert_html(input_file: Path, out_file: Path) -> str:
# Collapse 3+ blank lines to 2 for tidier output # Collapse 3+ blank lines to 2 for tidier output
markdown = re.sub(r"\n{3,}", "\n\n", markdown).strip() + "\n" markdown = re.sub(r"\n{3,}", "\n\n", markdown).strip() + "\n"
out_file.write_text(markdown, encoding="utf-8") out_file.write_text(markdown, encoding="utf-8")
_write_generic_image_manifest(media_dir, rel_media_dir, markdown, "html_image")
if not any(media_dir.iterdir()): if not any(media_dir.iterdir()):
media_dir.rmdir() media_dir.rmdir()
@@ -1024,6 +1082,7 @@ def _convert_epub(input_file: Path, out_file: Path) -> str:
markdown = markdownify(combined_html, heading_style="ATX", bullets="-") markdown = markdownify(combined_html, heading_style="ATX", bullets="-")
markdown = re.sub(r"\n{3,}", "\n\n", markdown).strip() + "\n" markdown = re.sub(r"\n{3,}", "\n\n", markdown).strip() + "\n"
out_file.write_text(markdown, encoding="utf-8") out_file.write_text(markdown, encoding="utf-8")
_write_generic_image_manifest(media_dir, rel_media_dir, markdown, "epub_image")
if not any(media_dir.iterdir()): if not any(media_dir.iterdir()):
media_dir.rmdir() media_dir.rmdir()
@@ -1089,6 +1148,7 @@ def _convert_ipynb(input_file: Path, out_file: Path) -> str:
markdown = out_file.read_text(encoding="utf-8") if out_file.exists() else body markdown = out_file.read_text(encoding="utf-8") if out_file.exists() else body
media_dir = out_file.parent / rel_media_dir media_dir = out_file.parent / rel_media_dir
_write_generic_image_manifest(media_dir, rel_media_dir, markdown, "ipynb_image")
_report_result(out_file, media_dir if media_dir.exists() else None) _report_result(out_file, media_dir if media_dir.exists() else None)
return markdown return markdown
@@ -1156,6 +1216,7 @@ def _convert_with_pandoc(input_file: Path, out_file: Path, suffix: str) -> str:
markdown = _html_img_to_md(markdown) markdown = _html_img_to_md(markdown)
out_file.write_text(markdown, encoding="utf-8") out_file.write_text(markdown, encoding="utf-8")
_write_generic_image_manifest(media_dir, rel_media_dir, markdown, "pandoc_image")
_report_result(out_file, media_dir if media_dir.exists() else None) _report_result(out_file, media_dir if media_dir.exists() else None)
return markdown return markdown
@@ -7,6 +7,7 @@ Supports heading levels, bold, italic, and list detection.
import argparse import argparse
import hashlib import hashlib
import json
import os import os
import re import re
import sys import sys
@@ -712,6 +713,7 @@ def extract_pdf_to_markdown(
img_dir = output_path.parent / rel_img_dir img_dir = output_path.parent / rel_img_dir
img_count = 0 img_count = 0
image_manifest: list[dict[str, object]] = []
for page_num, page in enumerate(doc, 1): for page_num, page in enumerate(doc, 1):
if page_num > 1: if page_num > 1:
@@ -951,6 +953,27 @@ def extract_pdf_to_markdown(
if prev_was_list: if prev_was_list:
markdown_content += "\n" markdown_content += "\n"
markdown_content += f"![{image_name}]({rel_img_dir}/{image_name})\n\n" markdown_content += f"![{image_name}]({rel_img_dir}/{image_name})\n\n"
width = int(block.get("width", 0) or 0)
height = int(block.get("height", 0) or 0)
ratio = width / height if width > 0 and height > 0 else None
image_manifest.append({
"index": len(image_manifest) + 1,
"filename": image_name,
"original_filename": image_name,
"asset_kind": "bitmap",
"svg_renderable": True,
"pptx_native_supported": True,
"source_kind": "pdf_image",
"source_ext": f".{ext}",
"page_index": page_num,
"occurrence_index": img_count + 1,
"pixel_width": width or None,
"pixel_height": height or None,
"pixel_ratio": round(ratio, 6) if ratio else None,
"display_ratio": round(ratio, 6) if ratio else None,
"source_sha256": hashlib.sha256(image_data).hexdigest(),
"bbox": list(block.get("bbox", [])),
})
img_count += 1 img_count += 1
prev_was_list = False prev_was_list = False
print(f" [OK] Extracted image: {image_name}") print(f" [OK] Extracted image: {image_name}")
@@ -980,6 +1003,29 @@ def extract_pdf_to_markdown(
if prev_was_list: if prev_was_list:
markdown_content += "\n" markdown_content += "\n"
markdown_content += f"![{image_name}]({rel_img_dir}/{image_name})\n\n" markdown_content += f"![{image_name}]({rel_img_dir}/{image_name})\n\n"
ratio = pix.width / pix.height if pix.width > 0 and pix.height > 0 else None
image_manifest.append({
"index": len(image_manifest) + 1,
"filename": image_name,
"original_filename": image_name,
"asset_kind": "bitmap",
"svg_renderable": True,
"pptx_native_supported": True,
"source_kind": "pdf_vector_figure",
"source_ext": ".png",
"page_index": page_num,
"occurrence_index": img_count + 1,
"pixel_width": pix.width,
"pixel_height": pix.height,
"pixel_ratio": round(ratio, 6) if ratio else None,
"display_ratio": round(ratio, 6) if ratio else None,
"bbox": [
figure_rect.x0,
figure_rect.y0,
figure_rect.x1,
figure_rect.y1,
],
})
img_count += 1 img_count += 1
prev_was_list = False prev_was_list = False
print(f" [OK] Rendered vector figure: {image_name}") print(f" [OK] Rendered vector figure: {image_name}")
@@ -1000,6 +1046,11 @@ def extract_pdf_to_markdown(
os.makedirs(os.path.dirname(output_path) or '.', exist_ok=True) os.makedirs(os.path.dirname(output_path) or '.', exist_ok=True)
with open(output_path, 'w', encoding='utf-8') as f: with open(output_path, 'w', encoding='utf-8') as f:
f.write(markdown_content) f.write(markdown_content)
if img_dir and image_manifest:
(img_dir / "image_manifest.json").write_text(
json.dumps(image_manifest, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
print(f"[OK] Saved Markdown to: {output_path}") print(f"[OK] Saved Markdown to: {output_path}")
return markdown_content return markdown_content
@@ -359,6 +359,76 @@ def table_to_markdown(table: object) -> str:
return "\n".join(lines) return "\n".join(lines)
def _format_chart_value(value: object) -> str:
"""Render a chart data point, trimming whole-number floats."""
if value is None:
return ""
if isinstance(value, float) and value.is_integer():
return str(int(value))
return str(value)
def chart_to_markdown(chart: object, name: str) -> str:
"""Render a chart's data as a Markdown table so the numbers survive conversion.
A native PowerPoint chart stores its data in embedded XML, not in any text
frame emitting only a `[Chart]` placeholder drops every value. The markdown
is the content contract for downstream generation, so transcribe categories ×
series here. `scripts/pptx_intake.py` writes the same data in structured JSON
form for tooling; this is the human- and content-readable mirror.
"""
try:
chart_type = str(chart.chart_type)
except (ValueError, AttributeError):
chart_type = ""
categories: list[str] = []
try:
plots = list(chart.plots)
if plots:
categories = [
escape_table_cell(str(cat)) if cat is not None else ""
for cat in plots[0].categories
]
except (ValueError, IndexError, AttributeError):
categories = []
series_data: list[tuple[str, list[object]]] = []
try:
for index, series in enumerate(chart.series, start=1):
try:
values = list(series.values)
except (ValueError, TypeError, AttributeError):
values = []
label = str(series.name) if getattr(series, "name", None) else f"Series {index}"
series_data.append((escape_table_cell(label), values))
except (ValueError, AttributeError):
series_data = []
header = f"> [Chart] {name}" + (f"{chart_type}" if chart_type else "")
row_count = len(categories) if categories else max((len(v) for _, v in series_data), default=0)
if not series_data or row_count == 0:
return header
table_header = (["Category"] if categories else ["#"]) + [sname for sname, _ in series_data]
lines = [
header,
"",
"| " + " | ".join(table_header) + " |",
"| " + " | ".join(["---"] * len(table_header)) + " |",
]
for row_index in range(row_count):
if categories:
label = categories[row_index] if row_index < len(categories) else ""
else:
label = str(row_index + 1)
cells = [label]
for _, values in series_data:
cells.append(_format_chart_value(values[row_index]) if row_index < len(values) else "")
lines.append("| " + " | ".join(cells) + " |")
return "\n".join(lines)
def _image_part_for_shape(shape: object) -> object | None: def _image_part_for_shape(shape: object) -> object | None:
"""Return the first embedded image part referenced by a shape.""" """Return the first embedded image part referenced by a shape."""
element = getattr(shape, "element", None) element = getattr(shape, "element", None)
@@ -710,7 +780,10 @@ def convert_presentation_to_markdown(
continue continue
if getattr(shape, "has_chart", False): if getattr(shape, "has_chart", False):
blocks.append(f"> [Chart] {getattr(shape, 'name', 'Chart')}") try:
blocks.append(chart_to_markdown(shape.chart, getattr(shape, "name", "Chart")))
except (ValueError, AttributeError, KeyError):
blocks.append(f"> [Chart] {getattr(shape, 'name', 'Chart')}")
if blocks: if blocks:
lines.append("\n\n".join(blocks)) lines.append("\n\n".join(blocks))
@@ -29,6 +29,7 @@ TLS fingerprint handling:
import argparse import argparse
import datetime import datetime
import io import io
import json
import os import os
import re import re
import sys import sys
@@ -219,6 +220,7 @@ def download_and_rewrite_images(
os.makedirs(image_dir, exist_ok=True) os.makedirs(image_dir, exist_ok=True)
downloaded = {} downloaded = {}
manifest_by_filename: dict[str, dict[str, object]] = {}
saved = 0 saved = 0
for idx, img in enumerate(images): for idx, img in enumerate(images):
@@ -243,6 +245,8 @@ def download_and_rewrite_images(
img["src"] = src img["src"] = src
abs_url = urljoin(page_url, src) abs_url = urljoin(page_url, src)
content_type = ""
converted_from = ""
if abs_url in downloaded: if abs_url in downloaded:
saved_name = downloaded[abs_url] saved_name = downloaded[abs_url]
else: else:
@@ -269,6 +273,7 @@ def download_and_rewrite_images(
pil_image = Image.open(img_data) pil_image = Image.open(img_data)
# Update filename to .png # Update filename to .png
converted_from = filename
filename = f"{stem}.png" filename = f"{stem}.png"
local_path = os.path.join(image_dir, filename) local_path = os.path.join(image_dir, filename)
@@ -313,6 +318,19 @@ def download_and_rewrite_images(
f.write(resp.content) f.write(resp.content)
downloaded[abs_url] = filename downloaded[abs_url] = filename
saved_name = filename saved_name = filename
manifest_by_filename[saved_name] = {
"index": len(manifest_by_filename) + 1,
"filename": saved_name,
"original_filename": converted_from or saved_name,
"asset_kind": "bitmap",
"svg_renderable": True,
"pptx_native_supported": True,
"source_kind": "web_image",
"source_url": abs_url,
"source_page_url": page_url,
"content_type": content_type.split(";")[0] if content_type else "",
"occurrences": [],
}
saved += 1 saved += 1
except Exception as e: except Exception as e:
print(f" [WARN] Skip image {abs_url}: {e}") print(f" [WARN] Skip image {abs_url}: {e}")
@@ -321,6 +339,27 @@ def download_and_rewrite_images(
rel_path = os.path.join( rel_path = os.path.join(
rel_prefix, saved_name) if rel_prefix else saved_name rel_prefix, saved_name) if rel_prefix else saved_name
img["src"] = rel_path img["src"] = rel_path
manifest_item = manifest_by_filename.get(saved_name)
if manifest_item is not None:
occurrences = manifest_item.setdefault("occurrences", [])
if isinstance(occurrences, list):
occurrences.append({
"occurrence_index": idx + 1,
"source_url": abs_url,
"alt_text": img.get("alt", ""),
})
manifest_item["usage_count"] = len(occurrences)
if manifest_by_filename:
manifest_path = os.path.join(image_dir, "image_manifest.json")
with open(manifest_path, "w", encoding="utf-8") as f:
json.dump(
list(manifest_by_filename.values()),
f,
ensure_ascii=False,
indent=2,
)
f.write("\n")
return saved return saved
@@ -18,6 +18,7 @@ from .drawingml_utils import (
rect_to_dml_xfrm, rect_to_dml_xfrm,
parse_hex_color, resolve_url_id, get_effective_filter_id, parse_hex_color, resolve_url_id, get_effective_filter_id,
parse_font_family, is_cjk_char, estimate_text_width, parse_font_family, is_cjk_char, estimate_text_width,
detect_text_lang, resolve_text_run_fonts,
parse_transform_matrix, _xml_escape, parse_transform_matrix, _xml_escape,
) )
from .drawingml_styles import ( from .drawingml_styles import (
@@ -1013,6 +1014,8 @@ def _build_run_xml(
strike_attr = ' strike="sngStrike"' if 'line-through' in text_dec else '' strike_attr = ' strike="sngStrike"' if 'line-through' in text_dec else ''
fonts = parse_font_family(ff) if ff else default_fonts fonts = parse_font_family(ff) if ff else default_fonts
run_fonts = resolve_text_run_fonts(text, fonts)
lang = detect_text_lang(text)
fill_xml = _build_text_fill_xml(fill, fill_raw, opacity, ctx) fill_xml = _build_text_fill_xml(fill, fill_raw, opacity, ctx)
outline_xml = _build_text_outline_xml(run) outline_xml = _build_text_outline_xml(run)
@@ -1020,13 +1023,13 @@ def _build_run_xml(
space_attr = ' xml:space="preserve"' if text != text.strip() or ' ' in text else '' space_attr = ' xml:space="preserve"' if text != text.strip() or ' ' in text else ''
return f'''<a:r> return f'''<a:r>
<a:rPr lang="zh-CN" sz="{sz}"{b_attr}{i_attr}{u_attr}{strike_attr} dirty="0"> <a:rPr lang="{lang}" sz="{sz}"{b_attr}{i_attr}{u_attr}{strike_attr} dirty="0">
{outline_xml} {outline_xml}
{fill_xml} {fill_xml}
{effect_xml} {effect_xml}
<a:latin typeface="{_xml_escape(fonts['latin'])}"/> <a:latin typeface="{_xml_escape(run_fonts['latin'])}"/>
<a:ea typeface="{_xml_escape(fonts['ea'])}"/> <a:ea typeface="{_xml_escape(run_fonts['ea'])}"/>
<a:cs typeface="{_xml_escape(fonts['latin'])}"/> <a:cs typeface="{_xml_escape(run_fonts['cs'])}"/>
</a:rPr> </a:rPr>
<a:t{space_attr}>{_xml_escape(text)}</a:t> <a:t{space_attr}>{_xml_escape(text)}</a:t>
</a:r>''' </a:r>'''
@@ -441,6 +441,21 @@ def is_cjk_char(ch: str) -> bool:
0x20000 <= cp <= 0x2A6DF) 0x20000 <= cp <= 0x2A6DF)
def detect_text_lang(text: str) -> str:
"""Return a DrawingML language tag for a text run."""
return 'zh-CN' if any(is_cjk_char(ch) for ch in text) else 'en-US'
def resolve_text_run_fonts(text: str, fonts: dict[str, str]) -> dict[str, str]:
"""Return DrawingML latin/ea/cs typefaces for one text run."""
latin = fonts['latin']
if detect_text_lang(text) == 'zh-CN':
ea = fonts['ea']
else:
ea = latin
return {'latin': latin, 'ea': ea, 'cs': latin}
def estimate_text_width(text: str, font_size: float, font_weight: str = '400') -> float: def estimate_text_width(text: str, font_size: float, font_weight: str = '400') -> float:
"""Estimate text width in SVG pixels.""" """Estimate text width in SVG pixels."""
width = 0.0 width = 0.0
@@ -4,6 +4,8 @@ from __future__ import annotations
import re import re
from .drawingml_utils import detect_text_lang
def markdown_to_plain_text(md_content: str) -> str: def markdown_to_plain_text(md_content: str) -> str:
"""Convert Markdown notes to plain text for PPTX notes. """Convert Markdown notes to plain text for PPTX notes.
@@ -70,19 +72,20 @@ def create_notes_slide_xml(slide_num: int, notes_text: str) -> str:
paragraphs: list[str] = [] paragraphs: list[str] = []
for para in notes_text.split('\n'): for para in notes_text.split('\n'):
if para.strip(): if para.strip():
lang = detect_text_lang(para)
paragraphs.append(f'''<a:p> paragraphs.append(f'''<a:p>
<a:r> <a:r>
<a:rPr lang="zh-CN" dirty="0"/> <a:rPr lang="{lang}" dirty="0"/>
<a:t>{para}</a:t> <a:t>{para}</a:t>
</a:r> </a:r>
</a:p>''') </a:p>''')
else: else:
paragraphs.append('<a:p><a:endParaRPr lang="zh-CN" dirty="0"/></a:p>') paragraphs.append('<a:p><a:endParaRPr lang="en-US" dirty="0"/></a:p>')
paragraphs_xml = ( paragraphs_xml = (
'\n '.join(paragraphs) '\n '.join(paragraphs)
if paragraphs if paragraphs
else '<a:p><a:endParaRPr lang="zh-CN" dirty="0"/></a:p>' else '<a:p><a:endParaRPr lang="en-US" dirty="0"/></a:p>'
) )
return f'''<?xml version="1.0" encoding="UTF-8" standalone="yes"?> return f'''<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
@@ -4,9 +4,9 @@
Delegates to the template_fill_pptx package. Kept as the CLI entry point so the Delegates to the template_fill_pptx package. Kept as the CLI entry point so the
documented command paths keep working: documented command paths keep working:
python3 scripts/template_fill_pptx.py analyze <deck.pptx> -o slide_library.json python3 scripts/template_fill_pptx.py analyze <deck.pptx> -o <stem>.slide_library.json
python3 scripts/template_fill_pptx.py scaffold slide_library.json -o fill_plan.json python3 scripts/template_fill_pptx.py scaffold <stem>.slide_library.json -o fill_plan.json
python3 scripts/template_fill_pptx.py check-plan slide_library.json fill_plan.json python3 scripts/template_fill_pptx.py check-plan <stem>.slide_library.json fill_plan.json
python3 scripts/template_fill_pptx.py apply <deck.pptx> fill_plan.json -o output.pptx python3 scripts/template_fill_pptx.py apply <deck.pptx> fill_plan.json -o output.pptx
Implementation lives in the template_fill_pptx/ package (ooxml, analyzer, Implementation lives in the template_fill_pptx/ package (ooxml, analyzer,
@@ -77,9 +77,17 @@ def _chart_type_with_series(chart_root: ET.Element) -> ET.Element:
plot_area = chart_root.find(".//c:plotArea", NS) plot_area = chart_root.find(".//c:plotArea", NS)
if plot_area is None: if plot_area is None:
raise RuntimeError("Chart XML has no plotArea") raise RuntimeError("Chart XML has no plotArea")
chart_types: list[ET.Element] = []
for child in list(plot_area): for child in list(plot_area):
if child.tag.endswith("Chart") and child.findall("c:ser", NS): if child.tag.endswith("Chart") and child.findall("c:ser", NS):
return child chart_types.append(child)
if len(chart_types) > 1:
raise RuntimeError(
"template-fill chart edits do not support multi-plot / combination charts; "
"use beautify/main pipeline to redraw the chart, or leave the native chart untouched"
)
if chart_types:
return chart_types[0]
raise RuntimeError("Chart XML has no editable series") raise RuntimeError("Chart XML has no editable series")
@@ -27,14 +27,15 @@ def _local_name(tag: str) -> str:
return tag.rsplit("}", 1)[-1] return tag.rsplit("}", 1)[-1]
def _chart_type_with_series(chart_root: ET.Element) -> ET.Element | None: def _chart_types_with_series(chart_root: ET.Element) -> list[ET.Element]:
plot_area = chart_root.find(".//c:plotArea", NS) plot_area = chart_root.find(".//c:plotArea", NS)
if plot_area is None: if plot_area is None:
return None return []
chart_types: list[ET.Element] = []
for child in list(plot_area): for child in list(plot_area):
if _local_name(child.tag).endswith("Chart") and child.findall("c:ser", NS): if _local_name(child.tag).endswith("Chart") and child.findall("c:ser", NS):
return child chart_types.append(child)
return None return chart_types
def _coerce_number(value: str) -> int | float | str: def _coerce_number(value: str) -> int | float | str:
@@ -75,23 +76,33 @@ def _series_name(series: ET.Element, fallback: str) -> str:
def read_chart_data(chart_root: ET.Element) -> dict[str, Any]: def read_chart_data(chart_root: ET.Element) -> dict[str, Any]:
"""Return a compact summary of chart type, categories, series, and values.""" """Return a compact summary of chart type, categories, series, and values."""
chart_type = _chart_type_with_series(chart_root) chart_types = _chart_types_with_series(chart_root)
if chart_type is None: if not chart_types:
return empty_chart_data() return empty_chart_data()
series_nodes = chart_type.findall("c:ser", NS) first_series = chart_types[0].find("c:ser", NS)
categories = _cache_values(series_nodes[0].find("c:cat", NS)) if series_nodes else [] categories = _cache_values(first_series.find("c:cat", NS)) if first_series is not None else []
series_payload: list[dict[str, Any]] = [] series_payload: list[dict[str, Any]] = []
for index, series in enumerate(series_nodes, start=1): plot_types: list[str] = []
series_payload.append( for chart_type in chart_types:
{ plot_name = _local_name(chart_type.tag)
"name": _series_name(series, f"系列{index}"), plot_types.append(plot_name)
"values": _cache_values(series.find("c:val", NS), numeric=True), for series in chart_type.findall("c:ser", NS):
} index = len(series_payload) + 1
) series_categories = _cache_values(series.find("c:cat", NS))
if not categories and series_categories:
categories = series_categories
series_payload.append(
{
"name": _series_name(series, f"系列{index}"),
"values": _cache_values(series.find("c:val", NS), numeric=True),
"chart_type": plot_name,
}
)
return { return {
"chart_type": _local_name(chart_type.tag), "chart_type": plot_types[0] if len(set(plot_types)) == 1 else "comboChart",
"plot_types": plot_types,
"category_count": len(categories), "category_count": len(categories),
"series_count": len(series_payload), "series_count": len(series_payload),
"categories": categories, "categories": categories,
@@ -387,6 +387,22 @@ def check_plan(library: dict[str, Any], plan: dict[str, Any]) -> dict[str, Any]:
) )
summary["error"] += 1 summary["error"] += 1
continue continue
if len(chart.get("plot_types") or []) > 1:
results.append(
{
"status": "ERROR",
"plan_slide": slide_index,
"source_slide": source_slide,
"selector": selectors[0] if selectors else "",
"chart_id": chart.get("chart_id"),
"message": (
"template-fill chart edits do not support multi-plot / combination charts; "
"use beautify/main pipeline to redraw the chart, or leave the native chart untouched"
),
}
)
summary["error"] += 1
continue
categories = chart_edit.get("categories", []) categories = chart_edit.get("categories", [])
series = chart_edit.get("series", []) series = chart_edit.get("series", [])
if not isinstance(categories, list) or not isinstance(series, list) or not series: if not isinstance(categories, list) or not isinstance(series, list) or not series:
@@ -69,10 +69,10 @@ def build_parser() -> argparse.ArgumentParser:
analyze = subparsers.add_parser("analyze", help="Extract slide library JSON from a PPTX") analyze = subparsers.add_parser("analyze", help="Extract slide library JSON from a PPTX")
analyze.add_argument("pptx_file", help="Source PPTX file") analyze.add_argument("pptx_file", help="Source PPTX file")
analyze.add_argument("-o", "--output", required=True, help="Output slide_library.json path") analyze.add_argument("-o", "--output", required=True, help="Output <stem>.slide_library.json path")
scaffold = subparsers.add_parser("scaffold", help="Create an editable fill plan skeleton") scaffold = subparsers.add_parser("scaffold", help="Create an editable fill plan skeleton")
scaffold.add_argument("library_json", help="slide_library.json from analyze") scaffold.add_argument("library_json", help="<stem>.slide_library.json from analyze")
scaffold.add_argument("-o", "--output", required=True, help="Output fill_plan.json path") scaffold.add_argument("-o", "--output", required=True, help="Output fill_plan.json path")
scaffold.add_argument( scaffold.add_argument(
"--slides", "--slides",
@@ -85,7 +85,7 @@ def build_parser() -> argparse.ArgumentParser:
) )
check = subparsers.add_parser("check-plan", help="Check a fill plan against source slot capacity") check = subparsers.add_parser("check-plan", help="Check a fill plan against source slot capacity")
check.add_argument("library_json", help="slide_library.json from analyze") check.add_argument("library_json", help="<stem>.slide_library.json from analyze")
check.add_argument("plan_json", help="Fill plan JSON") check.add_argument("plan_json", help="Fill plan JSON")
check.add_argument("-o", "--output", help="Optional JSON report output path") check.add_argument("-o", "--output", help="Optional JSON report output path")
@@ -14,6 +14,7 @@
| **Design Style** | {design_style} | | **Design Style** | {design_style} |
| **Target Audience** | [Filled by Strategist] | | **Target Audience** | [Filled by Strategist] |
| **Use Case** | [Filled by Strategist] | | **Use Case** | [Filled by Strategist] |
| **Content Strategy** | [Material divergence — the user's free-text intent on how closely to follow the source vs how freely to reshape it (or "balanced default"); facts stay sourced however free. Confirmed at c; consumed when authoring §IX. Not in spec_lock.] |
| **Created Date** | {date_str} | | **Created Date** | {date_str} |
--- ---
@@ -300,7 +301,8 @@ Catalog read: 71 templates
#### Slide 01 - Cover #### Slide 01 - Cover
- **Layout**: Full-screen background image + centered title - **Cover impact**: [MANDATORY — see strategist.md §6.2. Name one concrete hook (provocative core claim / hero number / object-scene metaphor / founder-product-audience moment / distilled conflict) + one composition strategy (full-bleed image + floating title / typographic poster / hero object / data hook / editorial scene / high-contrast abstract geometry / or a fresh one the subject suggests). This is the cover's spine — do NOT fall back to "title + subtitle + decorative background".]
- **Layout**: [realize the Cover impact above; choose the composition that delivers it — not a default centered title block]
- **Title**: [Main title] - **Title**: [Main title]
- **Subtitle**: [Subtitle] - **Subtitle**: [Subtitle]
- **Info**: [Author / Date / Organization] - **Info**: [Author / Date / Organization]
@@ -324,6 +326,14 @@ Catalog read: 71 templates
--- ---
#### Slide NN - Closing *(only if the deck genuinely lands on a conclusion / CTA / final-takeaway page — do NOT invent one to fill this slot; see strategist.md §6.2)*
- **Closing impact**: [MANDATORY for the closing page — name the one thing the audience leaves with (distilled takeaway / forward call / memorable restatement of the core claim) + one composition that lands it. Do NOT write a generic "Thank you" / contact-only / centered-title reprise of the cover.]
- **Layout**: [realize the Closing impact above — the deck's final impression, not a default sign-off]
- **Content**: [the takeaway / call-to-action itself, phrased to land]
---
## X. Speaker Notes Requirements ## X. Speaker Notes Requirements
One speaker note file per page, saved to `notes/`: One speaker note file per page, saved to `notes/`:
@@ -26,7 +26,7 @@ Re-lays-out an existing `.pptx`: the text is preserved **verbatim**, the source
**Distinct from mirror templates**: `replication_mode: mirror` (executor §1.1) keeps layout + visuals verbatim and edits text. Beautify is the inverse — content verbatim, layout redone, identity inherited. **Distinct from mirror templates**: `replication_mode: mirror` (executor §1.1) keeps layout + visuals verbatim and edits text. Beautify is the inverse — content verbatim, layout redone, identity inherited.
**When this is the wrong route — re-architecture belongs to the main pipeline**: beautify preserves the source's page count and page order 1:1. It is for "keep this deck, just lay it out better". When the user instead wants the original page breakdown reconsidered — merge / split / reorder pages, re-outline the structure, build a *better deck* from the same content rather than a prettier version of the same pages — that is not beautify. Convert the deck with [`ppt_to_md`](../scripts/source_to_md/ppt_to_md.py) and run the main SKILL.md pipeline, where the Strategist re-architects the outline freely from the extracted content. The deciding question: is the source's page split information to preserve, or just the previous author's structure to improve? Preserve → beautify (here); improve → `ppt_to_md` + main pipeline. **When this is the wrong route — re-architecture belongs to the main pipeline**: beautify preserves the source's page count and page order 1:1. It is for "keep this deck, just lay it out better". When the user instead wants the original page breakdown reconsidered — merge / split / reorder pages, re-outline the structure, build a *better deck* from the same content rather than a prettier version of the same pages — that is not beautify. This includes re-pagination for fit: "keep every word but split a crowded page so it reads better" changes page count, so it is the main pipeline, not beautify. Convert the deck with [`ppt_to_md`](../scripts/source_to_md/ppt_to_md.py) and run the main SKILL.md pipeline, where the Strategist re-architects the outline freely from the extracted content. The deciding question: is the source's page split information to preserve, or just the previous author's structure to improve? Preserve → beautify (here); improve → `ppt_to_md` + main pipeline.
--- ---
@@ -43,7 +43,7 @@ Re-lays-out an existing `.pptx`: the text is preserved **verbatim**, the source
## 3. Create the Project Workspace ## 3. Create the Project Workspace
Match the canvas to the source so 1:1 pages and paste-back align. Determine the source aspect first — before the project exists, run `beautify_identity.py <source.pptx>` to **stdout** and read `canvas.aspect` (the formal `analysis/identity.json` is written in Step 4, after `init`) — then `init` with the matching format: Match the canvas to the source so 1:1 pages and paste-back align. Determine the source aspect first — before the project exists, run `beautify_identity.py <source.pptx>` to **stdout** and read `canvas.aspect` (the formal standard intake bundle is written in Step 4, after `init`) — then `init` with the matching format:
| Source aspect | Format | | Source aspect | Format |
|---|---| |---|---|
@@ -60,15 +60,15 @@ python3 ${SKILL_DIR}/scripts/project_manager.py import-sources <project_path> <s
## 4. Extract Identity and Data; Assemble Inventory ## 4. Extract Identity and Data; Assemble Inventory
Two reads off the source PPTX (text + images already exist from Step 3). Neither rewrites the deck. Use the standard PPTX intake bundle from Step 3. `project_manager.py import-sources` already writes it under `analysis/` for PPTX-family inputs. If the bundle is missing because the project predates this workflow, generate it once:
```bash
python3 ${SKILL_DIR}/scripts/pptx_intake.py <project_path>/sources/<source.pptx> -o <project_path>/analysis
```
**Content + images — already produced by Step 3.** `import-sources` ran `ppt_to_md` on the deck, so the **frozen content contract** is `sources/<stem>.md` (one source slide per block, in order). If the source deck contains pictures, they are already propagated to `images/` with per-slide binding in `images/image_manifest.json` (`occurrences[].slide_index`). Do **not** re-run `ppt_to_md` — it would duplicate the conversion and write images to `analysis/<stem>_files/` instead of `images/`. **Content + images — already produced by Step 3.** `import-sources` ran `ppt_to_md` on the deck, so the **frozen content contract** is `sources/<stem>.md` (one source slide per block, in order). If the source deck contains pictures, they are already propagated to `images/` with per-slide binding in `images/image_manifest.json` (`occurrences[].slide_index`). Do **not** re-run `ppt_to_md` — it would duplicate the conversion and write images to `analysis/<stem>_files/` instead of `images/`.
**Visual identity (theme + observed sample + canvas)**: **Visual identity (theme + observed sample + canvas)**: read `<project_path>/analysis/<stem>.identity.json` (intake prefixes per-deck artifacts by source-file stem).
```bash
python3 ${SKILL_DIR}/scripts/beautify_identity.py <project_path>/sources/<source.pptx> -o <project_path>/analysis/identity.json
```
| Field | Use | | Field | Use |
|---|---| |---|---|
@@ -79,20 +79,16 @@ python3 ${SKILL_DIR}/scripts/beautify_identity.py <project_path>/sources/<source
> Note: `theme` is what the deck declares; `observed` is a frequency sample of run-level overrides (not a complete style resolution — it misses `schemeClr` and master/layout inheritance, and counts chart/gradient fills). A hand-edited deck can diverge from `theme` — Step 5 recommends which to inherit and the user confirms. > Note: `theme` is what the deck declares; `observed` is a frequency sample of run-level overrides (not a complete style resolution — it misses `schemeClr` and master/layout inheritance, and counts chart/gradient fills). A hand-edited deck can diverge from `theme` — Step 5 recommends which to inherit and the user confirms.
**Chart + table data (for regeneration)**: read the source's chart and table *data* so they can be redrawn natively in the inherited style: **Chart + table data (for regeneration)**: read `<project_path>/analysis/<stem>.slide_library.json`. It contains the source chart and table *data* so they can be redrawn natively in the inherited style:
```bash | `<stem>.slide_library.json` field | Use |
python3 ${SKILL_DIR}/scripts/template_fill_pptx.py analyze <project_path>/sources/<source.pptx> -o <project_path>/analysis/slide_library.json
```
| `slide_library.json` field | Use |
|---|---| |---|---|
| `slides[].charts[]` (`chart_type` / `categories` / `series[].values`) | regenerate as a native SVG chart via the `§VII` `templates/charts/` path | | `slides[].charts[]` (`chart_type` / `categories` / `series[].values`) | regenerate as a native SVG chart via the `§VII` `templates/charts/` path |
| `slides[].tables[]` (`row_count` / `column_count` / cell text) | regenerate as a native SVG table | | `slides[].tables[]` (`row_count` / `column_count` / cell text) | regenerate as a native SVG table |
**Hard rule — regenerate visuals, do not carry them over**: charts / tables / images are rebuilt from their data in the inherited style, never spliced in byte-for-byte. This keeps the deck style-consistent and natively editable. **Data values are frozen** (categories / series / cell text / numbers unchanged); only their rendering is the deck's own. Pictures (`ppt_to_md`-extracted files) are reused but re-laid-out — position / crop / size follow the new layout, not the source slot. A user who wants an original element verbatim copies it across themselves. **Hard rule — regenerate visuals, do not carry them over**: charts / tables / images are rebuilt from their data in the inherited style, never spliced in byte-for-byte. This keeps the deck style-consistent and natively editable. **Data values are frozen** (categories / series / cell text / numbers unchanged); only their rendering is the deck's own. Pictures (`ppt_to_md`-extracted files) are reused but re-laid-out — position / crop / size follow the new layout, not the source slot. A user who wants an original element verbatim copies it across themselves.
**Optional source-SVG visual reference**: when the source deck has complex vector decoration, distinctive page chrome, or a visual language that cannot be captured by `identity.json` colors/fonts alone, create a read-only SVG reference package under `analysis/`. This is for understanding style only; it is not a carry-over asset path. **Optional source-SVG visual reference**: when the source deck has complex vector decoration, distinctive page chrome, or a visual language that cannot be captured by `<stem>.identity.json` colors/fonts alone, create a read-only SVG reference package under `analysis/`. This is for understanding style only; it is not a carry-over asset path.
```bash ```bash
python3 ${SKILL_DIR}/scripts/pptx_to_svg.py <project_path>/sources/<source.pptx> -o <project_path>/analysis/source_svg_import python3 ${SKILL_DIR}/scripts/pptx_to_svg.py <project_path>/sources/<source.pptx> -o <project_path>/analysis/source_svg_import
@@ -110,7 +106,7 @@ Optional reuse gate: if a candidate is a non-text brand/logo/motif/decorative as
**Assemble the inventory** — the deterministic join into one per-slide ledger, `analysis/beautify_inventory.json`, the contract Step 5 confirms and Step 7 verifies against: **Assemble the inventory** — the deterministic join into one per-slide ledger, `analysis/beautify_inventory.json`, the contract Step 5 confirms and Step 7 verifies against:
```bash ```bash
python3 ${SKILL_DIR}/scripts/beautify_inventory.py <project_path>/analysis/slide_library.json \ python3 ${SKILL_DIR}/scripts/beautify_inventory.py <project_path>/analysis/<stem>.slide_library.json \
--images <project_path>/images/image_manifest.json -o <project_path>/analysis/beautify_inventory.json --images <project_path>/images/image_manifest.json -o <project_path>/analysis/beautify_inventory.json
``` ```
@@ -125,8 +121,9 @@ If `images/image_manifest.json` does not exist because the source deck has no ex
## ✅ Extraction Complete ## ✅ Extraction Complete
- [x] `sources/<stem>.md` (from Step 3) holds every source slide's text, in order; extracted pictures, if any, are in `images/` + `images/image_manifest.json` - [x] `sources/<stem>.md` (from Step 3) holds every source slide's text, in order; extracted pictures, if any, are in `images/` + `images/image_manifest.json`
- [x] `analysis/identity.json` has theme + observed identity + canvas aspect - [x] `analysis/<stem>.identity.json` has theme + observed identity + canvas aspect
- [x] `analysis/slide_library.json` holds chart + table data for regeneration - [x] `analysis/<stem>.slide_library.json` holds chart + table data for regeneration
- [x] `analysis/source_profile.json` (multi-deck index) summarizes the source facts in its `decks[]` entry
- [x] `analysis/beautify_inventory.json` ledgers per-slide text / images / data + ignored + needs-confirmation - [x] `analysis/beautify_inventory.json` ledgers per-slide text / images / data + ignored + needs-confirmation
- [ ] **Next**: Step 5 — Beautify Plan (recommend & confirm) - [ ] **Next**: Step 5 — Beautify Plan (recommend & confirm)
``` ```
@@ -138,12 +135,12 @@ If `images/image_manifest.json` does not exist because the source deck has no ex
**BLOCKING**: the scope is not hard-coded — same spirit as the Eight Confirmations. Recommend each item below from what the deck actually contains (the Step 4 inventory), present the plan, and **wait for the user to confirm or adjust** before writing any spec. Chat is the canonical channel; the confirm UI below is the visual convenience surface over it for the palette + typography review (its result is honored identically to a chat reply). **BLOCKING**: the scope is not hard-coded — same spirit as the Eight Confirmations. Recommend each item below from what the deck actually contains (the Step 4 inventory), present the plan, and **wait for the user to confirm or adjust** before writing any spec. Chat is the canonical channel; the confirm UI below is the visual convenience surface over it for the palette + typography review (its result is honored identically to a chat reply).
This step has two halves: This step has two halves:
- **Visual re-confirm via the confirm UI** — the **full** Step 4 confirm page (below), seeded from the source so every targeted-confirmation field (canvas, mode, visual style, palette, icons, typography incl. body baseline, image strategy, generation mode) is **pre-filled with the inherited / source-derived default and left editable**. Beautify *recommends* keeping the source's identity, but never removes the user's place to override any field — you may choose not to change a value, but you must not deny the place to change it. This is also where the deck's text size is set: `identity.json` carries fonts and palette but **no body font size** (source decks inherit sizes from master placeholders), so the body baseline is undetermined and must be chosen here, not silently defaulted to a small dense value. - **Visual re-confirm via the confirm UI** — the **full** Step 4 confirm page (below), seeded from the source so every targeted-confirmation field (canvas, mode, visual style, palette, icons, typography incl. body baseline, image strategy, generation mode) is **pre-filled with the inherited / source-derived default and left editable**. Beautify *recommends* keeping the source's identity, but never removes the user's place to override any field — you may choose not to change a value, but you must not deny the place to change it. This is also where the deck's text size is set: `<stem>.identity.json` carries fonts and palette but **no body font size** (source decks inherit sizes from master placeholders), so the body baseline is undetermined and must be chosen here, not silently defaulted to a small dense value.
- **Structural scope** — the inventory-driven list decisions below (ignored, reuse, needs-confirmation, verification level) stay in **chat**; they have no confirm-UI widget. - **Structural scope** — the inventory-driven list decisions below (ignored, reuse, needs-confirmation, verification level) stay in **chat**; they have no confirm-UI widget.
| Plan item | Recommend from | Default lean | | Plan item | Recommend from | Default lean |
|---|---|---| |---|---|---|
| Identity source | `identity.json` `theme` vs `observed` | present **both as color / typography candidates in the confirm UI** so the user picks the one that looks right (theme first when the deck is theme-driven; observed first when slides override heavily) — recommend a default ordering and say why | | Identity source | `<stem>.identity.json` `theme` vs `observed` | present **both as color / typography candidates in the confirm UI** so the user picks the one that looks right (theme first when the deck is theme-driven; observed first when slides override heavily) — recommend a default ordering and say why |
| Preserve scope | inventory `text_blocks` / `images` / `charts` / `tables` | all text verbatim; data values frozen; pictures reused | | Preserve scope | inventory `text_blocks` / `images` / `charts` / `tables` | all text verbatim; data values frozen; pictures reused |
| Ignored | inventory `ignored` | name them so the user sees what drops (hidden / master-only text / image crop / rotation) | | Ignored | inventory `ignored` | name them so the user sees what drops (hidden / master-only text / image crop / rotation) |
| Needs confirmation | inventory `needs_confirmation` | flag complex charts + overcrowded pages explicitly; ask how to handle | | Needs confirmation | inventory `needs_confirmation` | flag complex charts + overcrowded pages explicitly; ask how to handle |
@@ -187,7 +184,7 @@ Write `<project_path>/confirm_ui/recommendations.json` and launch the same confi
} }
``` ```
- **Recommend keep, allow override**: pre-fill canvas / mode / visual style / icons / image strategy with the source-faithful default (canvas = Step 3 format, mode = `briefing`, image_usage = `provided` since pictures are reused). Enumerable fields already list every catalog option with the source-faithful one badged, so the user can switch. Beautify's only true non-choices are the frozen text and the strict 1:1 page count (changing those means routing to the main pipeline instead — see CLAUDE.md). - **Recommend keep, allow override**: pre-fill canvas / mode / visual style / icons / image strategy with the source-faithful default (canvas = Step 3 format, mode = `briefing`, image_usage = `provided` since pictures are reused). Enumerable fields already list every catalog option with the source-faithful one badged, so the user can switch. Beautify's only true non-choices are the frozen text and the strict 1:1 page count (changing those means routing to the main pipeline instead — see CLAUDE.md). The §c material-divergence field is therefore not surfaced here — beautify never reshapes content (text is verbatim).
- **Our recommendation is the pre-selected default = the source replica**: for color and typography, author **several candidates** like the from-scratch flow. The pre-selected default (`selected: 0`, the first card) is what beautify recommends — the candidate that **best replicates the source deck's style** (the truest reading of `theme` / `observed`). Replicate-by-default. - **Our recommendation is the pre-selected default = the source replica**: for color and typography, author **several candidates** like the from-scratch flow. The pre-selected default (`selected: 0`, the first card) is what beautify recommends — the candidate that **best replicates the source deck's style** (the truest reading of `theme` / `observed`). Replicate-by-default.
- **Judge the other alternatives exactly as the from-scratch flow does — fonts as much as colors**: don't invent a beautify-specific rule. Author each non-replica candidate with the **same content-driven judgment the Strategist uses when generating from scratch** (color §e, typography §g), applied to the material this project provides — the source document's content and subject, the company's own theme colors, and any brand signal. Pick the palette **and** the font pairing by what fits *this* deck's content; fonts are chosen by content fit, not just defaulted to a safe face. Reach **≥3 candidates total** (PPT-safe stacks; the same creative-choice rule used elsewhere) so a user who departs from the replica still lands on a considered, content-fitting direction — depart-by-choice. - **Judge the other alternatives exactly as the from-scratch flow does — fonts as much as colors**: don't invent a beautify-specific rule. Author each non-replica candidate with the **same content-driven judgment the Strategist uses when generating from scratch** (color §e, typography §g), applied to the material this project provides — the source document's content and subject, the company's own theme colors, and any brand signal. Pick the palette **and** the font pairing by what fits *this* deck's content; fonts are chosen by content fit, not just defaulted to a safe face. Reach **≥3 candidates total** (PPT-safe stacks; the same creative-choice rule used elsewhere) so a user who departs from the replica still lands on a considered, content-fitting direction — depart-by-choice.
- **`body_size` is the load-bearing field**: seed it from a canvas-appropriate baseline (the page hints ≈2.53.3% of canvas height) — for a presentation / projection deck lean toward the relaxed end (e.g. `24` → 18pt), for a dense document deck the compact end (e.g. `18` → 13.5pt). The user sets the final value here; this is what prevents the deck from exporting at an unintentionally small size. - **`body_size` is the load-bearing field**: seed it from a canvas-appropriate baseline (the page hints ≈2.53.3% of canvas height) — for a presentation / projection deck lean toward the relaxed end (e.g. `24` → 18pt), for a dense document deck the compact end (e.g. `18` → 13.5pt). The user sets the final value here; this is what prevents the deck from exporting at an unintentionally small size.
@@ -227,7 +224,7 @@ python3 ${SKILL_DIR}/scripts/source_to_md/ppt_to_md.py <project_path>/exports/<o
| Data fidelity | chart categories / series / table cells match the source exactly | | Data fidelity | chart categories / series / table cells match the source exactly |
| Page count | output slide count equals the source slide count | | Page count | output slide count equals the source slide count |
| Regenerated visuals | charts / tables are native SVG re-themed to the inherited palette | | Regenerated visuals | charts / tables are native SVG re-themed to the inherited palette |
| Identity | generated text / shapes use only `identity.json` colors + fonts | | Identity | generated text / shapes use only `<stem>.identity.json` colors + fonts |
| Paste-back | copying a beautified element into the original deck looks native | | Paste-back | copying a beautified element into the original deck looks native |
```markdown ```markdown
@@ -60,15 +60,15 @@ Use this fixed layout:
--- ---
## Step 3: Extract the Slide Library ## Step 3: Extract the PPTX Intake Bundle
Run: Run:
```bash ```bash
python3 skills/ppt-master/scripts/template_fill_pptx.py analyze "<project_dir>/sources/<source.pptx>" -o "<project_dir>/analysis/slide_library.json" python3 skills/ppt-master/scripts/pptx_intake.py "<project_dir>/sources/<source.pptx>" -o "<project_dir>/analysis"
``` ```
Read `<project_dir>/analysis/slide_library.json` and identify: Read `<project_dir>/analysis/<stem>.slide_library.json` (intake prefixes per-deck artifacts by the template deck's file stem) and identify:
| Field | Use | | Field | Use |
|---|---| |---|---|
@@ -83,7 +83,7 @@ Read `<project_dir>/analysis/slide_library.json` and identify:
A page's layout already encodes a rhetorical shape — a single hero statement, a lead-then-detail split, a 2×2 comparison, a stepwise progression, a metric row. Match the source material's own logic to a page whose structure expresses that same logic; do not pour unrelated content into a slot just because it is empty. When no selected page fits a piece of content well, drop that page or that content rather than forcing it — a forced fill reads as stiff. It is fine to use fewer pages than the source deck has. A page's layout already encodes a rhetorical shape — a single hero statement, a lead-then-detail split, a 2×2 comparison, a stepwise progression, a metric row. Match the source material's own logic to a page whose structure expresses that same logic; do not pour unrelated content into a slot just because it is empty. When no selected page fits a piece of content well, drop that page or that content rather than forcing it — a forced fill reads as stiff. It is fine to use fewer pages than the source deck has.
**Layout-first planning**: Treat `slide_library.json` as a layout inventory, not as an ordered deck outline. Before writing `fill_plan.json`, infer each reusable source page's affordance from JSON fields: **Layout-first planning**: Treat `<stem>.slide_library.json` as a layout inventory, not as an ordered deck outline. Before writing `fill_plan.json`, infer each reusable source page's affordance from JSON fields:
| JSON signal | Layout planning use | | JSON signal | Layout planning use |
|---|---| |---|---|
@@ -104,14 +104,14 @@ A page's layout already encodes a rhetorical shape — a single hero statement,
Create a scaffold: Create a scaffold:
```bash ```bash
python3 skills/ppt-master/scripts/template_fill_pptx.py scaffold "<project_dir>/analysis/slide_library.json" -o "<project_dir>/analysis/fill_plan.json" --slides "1,3,4" python3 skills/ppt-master/scripts/template_fill_pptx.py scaffold "<project_dir>/analysis/<stem>.slide_library.json" -o "<project_dir>/analysis/fill_plan.json" --slides "1,3,4"
``` ```
Then edit `<project_dir>/analysis/fill_plan.json` by hand from the source material. The plan is the single execution contract. Then edit `<project_dir>/analysis/fill_plan.json` by hand from the source material. The plan is the single execution contract.
**Pages are reusable**: the output is the ordered `slides` list, not a one-to-one copy of the source deck. A source page is not single-use — list the same `source_slide` as many times as you need, each entry with its own `replacements`, to drive several output slides from one good layout (e.g., reuse a single content layout for five content pages). Likewise you may omit source pages entirely and put the selected ones in any order. **Pages are reusable**: the output is the ordered `slides` list, not a one-to-one copy of the source deck. A source page is not single-use — list the same `source_slide` as many times as you need, each entry with its own `replacements`, to drive several output slides from one good layout (e.g., reuse a single content layout for five content pages). Likewise you may omit source pages entirely and put the selected ones in any order.
**Scaffold boundary**: `scaffold --slides` is only a convenience starter. If the final plan needs repeated source pages or a story order that differs from the template order, duplicate / reorder entries in `fill_plan.json` manually or generate the plan from `slide_library.json`; do not let scaffold output constrain the deck structure. **Scaffold boundary**: `scaffold --slides` is only a convenience starter. If the final plan needs repeated source pages or a story order that differs from the template order, duplicate / reorder entries in `fill_plan.json` manually or generate the plan from `<stem>.slide_library.json`; do not let scaffold output constrain the deck structure.
The plan structure: The plan structure:
@@ -169,6 +169,7 @@ The plan structure:
| Empty slots | Use `scaffold --include-empty` only when a real placeholder is empty in the source deck | | Empty slots | Use `scaffold --include-empty` only when a real placeholder is empty in the source deck |
| Native tables | Keep the original table row and column count; this workflow edits existing cells, not table structure | | Native tables | Keep the original table row and column count; this workflow edits existing cells, not table structure |
| Native charts | Each series `values` list must match the category count; this workflow edits chart data, not chart styling | | Native charts | Each series `values` list must match the category count; this workflow edits chart data, not chart styling |
| Multi-plot / combo charts | Not supported for direct `chart_edits`; `check-plan` reports an error rather than silently writing the wrong plot. Use beautify / main pipeline to redraw them, or leave the native chart untouched. |
| Facts | Every substantive claim must come from the user material | | Facts | Every substantive claim must come from the user material |
**Fit check before apply**: **Fit check before apply**:
@@ -206,7 +207,7 @@ Example `notes` value for a Chinese content slide:
Run the data-based capacity check before applying the plan: Run the data-based capacity check before applying the plan:
```bash ```bash
python3 skills/ppt-master/scripts/template_fill_pptx.py check-plan "<project_dir>/analysis/slide_library.json" "<project_dir>/analysis/fill_plan.json" -o "<project_dir>/analysis/check_report.json" python3 skills/ppt-master/scripts/template_fill_pptx.py check-plan "<project_dir>/analysis/<stem>.slide_library.json" "<project_dir>/analysis/fill_plan.json" -o "<project_dir>/analysis/check_report.json"
``` ```
Interpret the report: Interpret the report:
@@ -278,7 +279,7 @@ If the extracted text is correct but visual overflow is likely, reduce the text
```markdown ```markdown
## ✅ Template Fill Complete ## ✅ Template Fill Complete
- [x] `slide_library.json` extracted from the source PPTX - [x] Standard PPTX intake extracted from the source deck, including `<stem>.slide_library.json`
- [x] `fill_plan.json` selects only pages that fit the target story - [x] `fill_plan.json` selects only pages that fit the target story
- [x] `check-plan` run and capacity warnings resolved or explicitly accepted - [x] `check-plan` run and capacity warnings resolved or explicitly accepted
- [x] Output PPTX generated through direct OOXML text replacement - [x] Output PPTX generated through direct OOXML text replacement
@@ -303,3 +304,4 @@ If the extracted text is correct but visual overflow is likely, reduce the text
| Edit chart formatting / axes / legend layout | Not in v1 | | Edit chart formatting / axes / legend layout | Not in v1 |
| Edit SmartArt deeply | Not in v1 | | Edit SmartArt deeply | Not in v1 |
| Automatic visual overflow detection | Not in v1; use text-capacity judgment from the library slots | | Automatic visual overflow detection | Not in v1; use text-capacity judgment from the library slots |
| Material-divergence reshaping (§c content strategy) | Not applicable — this workflow fills text into existing slots, it does not author an outline from a source, so the main pipeline's `content_divergence` free-text field has no role here |