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:
@@ -69,8 +69,8 @@
|
||||
"repo": "https://github.com/hugohe3/ppt-master.git",
|
||||
"ref": "main",
|
||||
"adapter": "claude-skill",
|
||||
"commit": "ae6d9096d64d0291933c7a3e6bba2ae4eb533741",
|
||||
"syncedAt": "2026-06-19T16:00:00Z"
|
||||
"commit": "c948611f54ae8aae089083bf18b02b10bdb79957",
|
||||
"syncedAt": "2026-06-20T16:00:01Z"
|
||||
},
|
||||
{
|
||||
"id": "next-skills",
|
||||
|
||||
@@ -3,5 +3,5 @@
|
||||
"name": "playwright浏览器自动化操作",
|
||||
"version": "20260605",
|
||||
"keySource": "none",
|
||||
"syncedAt": "2026-06-19T16:01:21Z"
|
||||
"syncedAt": "2026-06-20T16:01:37Z"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# PPT Master — AI generates natively editable PPTX from any document
|
||||
|
||||
[](https://github.com/hugohe3/ppt-master/releases)
|
||||
[](https://github.com/hugohe3/ppt-master/releases)
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](https://github.com/hugohe3/ppt-master/stargazers)
|
||||
[](https://atomgit.com/hugohe3/ppt-master)
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
"sourceId": "ppt-master",
|
||||
"repo": "https://github.com/hugohe3/ppt-master.git",
|
||||
"ref": "main",
|
||||
"commit": "ae6d9096d64d0291933c7a3e6bba2ae4eb533741",
|
||||
"commit": "c948611f54ae8aae089083bf18b02b10bdb79957",
|
||||
"adapter": "claude-skill",
|
||||
"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/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/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/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) |
|
||||
@@ -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 |
|
||||
| `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
|
||||
@@ -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>` |
|
||||
| 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 |
|
||||
| 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>` |
|
||||
| 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`) |
|
||||
@@ -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` |
|
||||
| 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.
|
||||
|
||||
**✅ 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 I–XI 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`):
|
||||
|
||||
⛔ **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:
|
||||
|
||||
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:
|
||||
```bash
|
||||
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.
|
||||
|
||||
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
|
||||
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**:
|
||||
- `<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**:
|
||||
```markdown
|
||||
## ✅ 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] 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)
|
||||
@@ -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 |
|
||||
|---|---|---|
|
||||
| `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) |
|
||||
|
||||
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.
|
||||
|
||||
> ⚠️ **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.
|
||||
|
||||
Workflow:
|
||||
@@ -409,6 +444,7 @@ Workflow:
|
||||
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
|
||||
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**:
|
||||
```markdown
|
||||
@@ -417,6 +453,7 @@ Workflow:
|
||||
- [x] image_prompts.md sidecar rendered (when any ai 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] 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:
|
||||
@@ -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.
|
||||
|
||||
> 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.
|
||||
|
||||
> ⚠️ **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 |
|
||||
|-----|-------------------|
|
||||
| `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. |
|
||||
| `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`.
|
||||
|
||||
**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)
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
- 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 (~3–4), 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
|
||||
- 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
|
||||
|
||||
### 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 |
|
||||
| `--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 |
|
||||
|----------|-----------------------|-------|
|
||||
| Card / panel shadow (only when floating over photo/colored panel) | Filter soft shadow (`flood-opacity` 0.06–0.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.06–0.10, single light source) | Hard black shadow, full-page abundance |
|
||||
| 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 |
|
||||
| Accent / CTA button (one per page) | Colored shadow (same hue family, `flood-opacity` 0.12–0.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.
|
||||
|
||||
> **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
|
||||
|
||||
@@ -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.
|
||||
|
||||
**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
|
||||
|
||||
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`.
|
||||
|
||||
**Source**:
|
||||
- User named a style → map to the closest preset; if none fits, `custom` with a `visual_style_behavior` paragraph.
|
||||
- No user description → recommend by the index's auto-selection table (content vibe / industry → style). Present as a recommendation; the user may override.
|
||||
- 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 → **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.
|
||||
|
||||
@@ -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) |
|
||||
| **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 A–E 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.
|
||||
|
||||
**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.
|
||||
- **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.
|
||||
- **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_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`.
|
||||
|
||||
+1
-1
@@ -6,7 +6,7 @@ Classroom chalkboard — a dark slate field, soft chalk-stroke line work, powder
|
||||
|
||||
## 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.
|
||||
- Whitespace: the dark board reads as room; let chalk marks breathe rather than crowd.
|
||||
|
||||
|
||||
+2
@@ -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.
|
||||
- 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
|
||||
|
||||
- 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).)
|
||||
|
||||
+2
@@ -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).
|
||||
- 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
|
||||
|
||||
- Flat to barely-raised. Rules and whitespace separate content, not shadows. Shadow only on a genuine floating element.
|
||||
|
||||
+1
-1
@@ -7,7 +7,7 @@ Frosted-glass SaaS — translucent layered panels, flowing gradient light, float
|
||||
## 1. Shape & decoration
|
||||
|
||||
- Shape language: rounded translucent glass panels (low fill-opacity over the dark field) with bright hairline edges; layered, floating cards that imply blur and frost; rounded corners (`rx` 12-20).
|
||||
- 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.
|
||||
|
||||
## 2. Typography character
|
||||
|
||||
+1
-1
@@ -6,7 +6,7 @@ Whiteboard-ink minimalism — a pale field, confident black hand-ink line work,
|
||||
|
||||
## 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.
|
||||
- Whitespace: generous and empty; the pale field carries most of the canvas, elements float with room around them.
|
||||
|
||||
|
||||
+1
-1
@@ -6,7 +6,7 @@ New-Chinese ink-wash — a rice-paper field, vast literati whitespace, restraine
|
||||
|
||||
## 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.
|
||||
- Whitespace: vast and intentional — the rice-paper field carries most of the page; a few elements float in great calm.
|
||||
|
||||
|
||||
+1
-1
@@ -6,7 +6,7 @@ Layered paper-craft — scissor-cut shapes stacked in tactile layers, soft shado
|
||||
|
||||
## 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.
|
||||
- Whitespace: cozy, composed — the backing sheet shows through as breathing room.
|
||||
|
||||
|
||||
+1
-1
@@ -6,7 +6,7 @@ Warm hand-drawn sketchnote — soft paper field, black ink doodle line work, gen
|
||||
|
||||
## 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.
|
||||
- Whitespace: airy and well-organized; generous gaps between elements keep it friendly, never dense.
|
||||
|
||||
|
||||
+2
@@ -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.
|
||||
- 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
|
||||
|
||||
- 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 |
|
||||
|------|-----------------|---------------|
|
||||
| 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) |
|
||||
| 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) |
|
||||
@@ -80,9 +80,9 @@ Template fill (direct PPTX, no SVG conversion):
|
||||
|
||||
```bash
|
||||
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 scaffold <project_path>/analysis/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 analyze <project_path>/sources/<source.pptx> -o <project_path>/analysis/<stem>.slide_library.json
|
||||
python3 scripts/template_fill_pptx.py scaffold <project_path>/analysis/<stem>.slide_library.json -o <project_path>/analysis/fill_plan.json --slides "1,3,4"
|
||||
python3 scripts/template_fill_pptx.py check-plan <project_path>/analysis/<stem>.slide_library.json <project_path>/analysis/fill_plan.json -o <project_path>/analysis/check_report.json
|
||||
python3 scripts/template_fill_pptx.py apply <project_path>/sources/<source.pptx> <project_path>/analysis/fill_plan.json -o <project_path>/exports/filled.pptx
|
||||
```
|
||||
|
||||
|
||||
@@ -18,7 +18,8 @@ Usage:
|
||||
|
||||
Output:
|
||||
- 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
|
||||
@@ -572,9 +573,12 @@ def main() -> None:
|
||||
print_results(results)
|
||||
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)
|
||||
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)
|
||||
else:
|
||||
print("No image files found in the directory.")
|
||||
|
||||
@@ -14,7 +14,7 @@ Usage:
|
||||
|
||||
Examples:
|
||||
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:
|
||||
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]
|
||||
|
||||
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
|
||||
|
||||
Dependencies:
|
||||
|
||||
+31
-2
@@ -32,6 +32,8 @@
|
||||
sec_refine: "Refine spec first",
|
||||
sub_mode: "Narrative mode",
|
||||
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_placeholder: "Type your own…",
|
||||
recommended: "Recommended",
|
||||
@@ -100,6 +102,8 @@
|
||||
sec_refine: "先精修设计规范",
|
||||
sub_mode: "叙事模式",
|
||||
sub_visual: "视觉风格",
|
||||
sub_divergence: "材料发散度(多大程度重塑,还是贴近源材料)",
|
||||
placeholder_divergence: "用你自己的话写,例如「严格贴着文档来」/「在源材料范围内自由重组并展开」。留空则按平衡处理。",
|
||||
custom: "自定义",
|
||||
custom_placeholder: "输入自定义内容…",
|
||||
recommended: "推荐",
|
||||
@@ -287,6 +291,14 @@
|
||||
var grouped = list.length && list[0] && list[0].items;
|
||||
var flat = grouped ? list.reduce(function (a, g) { return a.concat(g.items || []); }, []) : list;
|
||||
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 customSentinel = opts2.customSentinel || "";
|
||||
var customInvalidValues = opts2.customInvalidValues || [];
|
||||
@@ -312,9 +324,15 @@
|
||||
if (o.dim) label += " · " + o.dim;
|
||||
var desc = optionDesc(o);
|
||||
if (desc) label += (LANG === "zh" ? ":" : " — ") + desc;
|
||||
var spec = specById[o.id];
|
||||
if (spec && spec.note) label += " · " + spec.note;
|
||||
var chip = el("div", "chip");
|
||||
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.appendChild(el("span", "rec-badge", "★ " + t("recommended")));
|
||||
}
|
||||
@@ -494,6 +512,15 @@
|
||||
var sec = section(3, "sec_audience");
|
||||
textField(sec, function () { return STATE.audience; },
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -505,7 +532,8 @@
|
||||
var sub2 = el("div", "subfield");
|
||||
sub2.appendChild(el("div", "subfield-label", t("sub_visual")));
|
||||
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);
|
||||
host.appendChild(sec);
|
||||
}
|
||||
@@ -1031,6 +1059,7 @@
|
||||
STATE.canvas = pick("canvas", CAT.canvas);
|
||||
STATE.page_count = (REC.page_count && REC.page_count.value != null) ? String(REC.page_count.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.visual_style = pick("visual_style", CAT.visual_styles);
|
||||
|
||||
|
||||
@@ -29,9 +29,9 @@ pip install flask
|
||||
|
||||
## 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.
|
||||
- **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.
|
||||
|
||||
@@ -61,6 +61,7 @@ Both files live under `<project_path>/confirm_ui/`.
|
||||
},
|
||||
"page_count": { "value": "12-15" },
|
||||
"audience": { "value": "..." },
|
||||
"content_divergence": { "value": "" },
|
||||
"color": {
|
||||
"selected": 0,
|
||||
"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 }
|
||||
}
|
||||
```
|
||||
@@ -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.5–3.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.
|
||||
- **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.
|
||||
- `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.
|
||||
|
||||
### Output — `result.json` (written on submit, read by the AI)
|
||||
@@ -121,6 +129,7 @@ Both files live under `<project_path>/confirm_ui/`.
|
||||
"canvas": "ppt169",
|
||||
"page_count": "12-15",
|
||||
"audience": "...",
|
||||
"content_divergence": "freely restructure and expand within the source",
|
||||
"mode": "pyramid",
|
||||
"visual_style": "swiss-minimal",
|
||||
"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:
|
||||
- extracts slide text in reading order
|
||||
- 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
|
||||
- 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.
|
||||
|
||||
## `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`
|
||||
|
||||
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
|
||||
```
|
||||
|
||||
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):
|
||||
|
||||
| Provider | Config | Strength |
|
||||
|
||||
@@ -22,6 +22,11 @@ Notes:
|
||||
note), to avoid leaving unintended artifacts that could be committed by mistake.
|
||||
Pass `--copy` to force a copy for in-repo sources instead.
|
||||
- `--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:
|
||||
- `ppt169`
|
||||
|
||||
@@ -39,10 +39,13 @@ Examples:
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import concurrent.futures
|
||||
import importlib
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
@@ -85,6 +88,29 @@ ALL_PROVIDERS: tuple[str, ...] = ZERO_CONFIG_PROVIDERS + KEYED_PROVIDERS
|
||||
|
||||
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
|
||||
@@ -531,6 +557,241 @@ def promote_candidate(
|
||||
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
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -544,11 +805,19 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
),
|
||||
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(
|
||||
"--filename",
|
||||
required=True,
|
||||
help="Local filename for the chosen image (e.g. cover_bg.jpg).",
|
||||
default=None,
|
||||
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(
|
||||
"-o",
|
||||
@@ -606,6 +875,27 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
default=None,
|
||||
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(
|
||||
"--no-candidates",
|
||||
action="store_true",
|
||||
@@ -651,6 +941,8 @@ def main(argv: Optional[list[str]] = None) -> int:
|
||||
|
||||
# --- Promote mode ---
|
||||
if args.promote:
|
||||
if not args.filename:
|
||||
parser.error("--filename is required in --promote mode")
|
||||
return promote_candidate(
|
||||
output_dir,
|
||||
args.filename,
|
||||
@@ -658,7 +950,52 @@ def main(argv: Optional[list[str]] = None) -> int:
|
||||
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(
|
||||
query=args.query,
|
||||
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
|
||||
|
||||
import argparse
|
||||
import filecmp
|
||||
import json
|
||||
import re
|
||||
import shutil
|
||||
@@ -147,6 +148,7 @@ class ProjectManager:
|
||||
"notes",
|
||||
"templates",
|
||||
SOURCE_DIRNAME,
|
||||
"analysis",
|
||||
"exports",
|
||||
):
|
||||
(project_path / rel_path).mkdir(parents=True, exist_ok=True)
|
||||
@@ -161,11 +163,12 @@ class ProjectManager:
|
||||
"## Directories\n\n"
|
||||
"- `svg_output/`: raw 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"
|
||||
"- `notes/`: speaker notes\n"
|
||||
"- `templates/`: project templates\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"
|
||||
"- `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)
|
||||
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:
|
||||
if not path.exists():
|
||||
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:
|
||||
self._run_tool(
|
||||
[
|
||||
@@ -307,6 +331,15 @@ class ProjectManager:
|
||||
]
|
||||
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:
|
||||
file_path = self._ensure_unique_path(sources_dir / f"{derive_url_basename(url)}.url.txt")
|
||||
file_path.write_text(
|
||||
@@ -420,11 +453,53 @@ class ProjectManager:
|
||||
suffix = "_files"
|
||||
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:
|
||||
"""Copy converter-generated image assets and manifest into project images/.
|
||||
|
||||
Files are namespaced by source stem to avoid collisions when multiple
|
||||
DOCX/PPTX sources contain identically-named internal media (image1.png, ...).
|
||||
Filenames are preserved when possible because source Markdown commonly
|
||||
uses short names that are meaningful in context. Only real collisions
|
||||
receive a compact numeric suffix.
|
||||
"""
|
||||
manifest_path = asset_dir / "image_manifest.json"
|
||||
if not manifest_path.is_file():
|
||||
@@ -443,6 +518,19 @@ class ProjectManager:
|
||||
images_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
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] = {}
|
||||
|
||||
copied_count = 0
|
||||
@@ -451,8 +539,15 @@ class ProjectManager:
|
||||
continue
|
||||
if source_file.suffix.lower() not in IMAGE_ASSET_SUFFIXES:
|
||||
continue
|
||||
new_name = f"{namespace}__{source_file.name}"
|
||||
shutil.copy2(source_file, images_dir / new_name)
|
||||
new_name = self._image_destination_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
|
||||
copied_count += 1
|
||||
|
||||
@@ -464,7 +559,7 @@ class ProjectManager:
|
||||
if not isinstance(original, str):
|
||||
continue
|
||||
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
|
||||
rebased_items.append(new_item)
|
||||
|
||||
@@ -534,6 +629,7 @@ class ProjectManager:
|
||||
"archived": [],
|
||||
"markdown": [],
|
||||
"assets": [],
|
||||
"analysis": [],
|
||||
"notes": [],
|
||||
"skipped": [],
|
||||
}
|
||||
@@ -548,17 +644,24 @@ class ProjectManager:
|
||||
|
||||
for item in source_items:
|
||||
if is_url(item):
|
||||
archived = self._archive_url_record(sources_dir, item)
|
||||
markdown_path = self._ensure_unique_path(
|
||||
sources_dir / f"{derive_url_basename(item)}.md"
|
||||
)
|
||||
try:
|
||||
self._import_url(item, markdown_path)
|
||||
except Exception as exc: # pragma: no cover - summary path
|
||||
archived = self._archive_url_record(sources_dir, item)
|
||||
summary["archived"].append(str(archived))
|
||||
summary["skipped"].append(f"{item}: {exc}")
|
||||
continue
|
||||
|
||||
if not self._is_valid_imported_url_markdown(markdown_path):
|
||||
markdown_path.unlink(missing_ok=True)
|
||||
archived = self._archive_url_record(sources_dir, item)
|
||||
summary["archived"].append(str(archived))
|
||||
summary["skipped"].append(f"{item}: URL conversion produced no usable Markdown")
|
||||
continue
|
||||
|
||||
summary["markdown"].append(str(markdown_path))
|
||||
self._propagate_companion_image_assets(markdown_path, project_dir)
|
||||
continue
|
||||
@@ -640,6 +743,13 @@ class ProjectManager:
|
||||
summary["skipped"].append(f"{item}: PDF conversion failed ({exc})")
|
||||
elif suffix in PRESENTATION_SUFFIXES:
|
||||
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:
|
||||
summary["notes"].append(
|
||||
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:")
|
||||
for item in summary["assets"]:
|
||||
print(f" - {item}")
|
||||
if summary["analysis"]:
|
||||
print("\nAnalysis artifacts:")
|
||||
for item in summary["analysis"]:
|
||||
print(f" - {item}")
|
||||
if summary["notes"]:
|
||||
print("\nNotes:")
|
||||
for item in summary["notes"]:
|
||||
|
||||
@@ -55,6 +55,10 @@ PANDOC_FORMATS = {
|
||||
# Formats pandoc should extract embedded media from
|
||||
PANDOC_MEDIA_FORMATS = {".odt"}
|
||||
OFFICE_VECTOR_EXTENSIONS = {".emf", ".wmf"}
|
||||
IMAGE_ASSET_SUFFIXES = {
|
||||
".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".tiff", ".tif",
|
||||
".emf", ".wmf", ".svg",
|
||||
}
|
||||
|
||||
DOCX_NS = {
|
||||
"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
|
||||
|
||||
|
||||
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:
|
||||
"""Return an XML element local name without its namespace."""
|
||||
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
|
||||
markdown = re.sub(r"\n{3,}", "\n\n", markdown).strip() + "\n"
|
||||
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()):
|
||||
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 = re.sub(r"\n{3,}", "\n\n", markdown).strip() + "\n"
|
||||
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()):
|
||||
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
|
||||
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)
|
||||
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)
|
||||
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)
|
||||
return markdown
|
||||
|
||||
@@ -7,6 +7,7 @@ Supports heading levels, bold, italic, and list detection.
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
@@ -712,6 +713,7 @@ def extract_pdf_to_markdown(
|
||||
img_dir = output_path.parent / rel_img_dir
|
||||
|
||||
img_count = 0
|
||||
image_manifest: list[dict[str, object]] = []
|
||||
|
||||
for page_num, page in enumerate(doc, 1):
|
||||
if page_num > 1:
|
||||
@@ -951,6 +953,27 @@ def extract_pdf_to_markdown(
|
||||
if prev_was_list:
|
||||
markdown_content += "\n"
|
||||
markdown_content += f"\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
|
||||
prev_was_list = False
|
||||
print(f" [OK] Extracted image: {image_name}")
|
||||
@@ -980,6 +1003,29 @@ def extract_pdf_to_markdown(
|
||||
if prev_was_list:
|
||||
markdown_content += "\n"
|
||||
markdown_content += f"\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
|
||||
prev_was_list = False
|
||||
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)
|
||||
with open(output_path, 'w', encoding='utf-8') as f:
|
||||
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}")
|
||||
|
||||
return markdown_content
|
||||
|
||||
@@ -359,6 +359,76 @@ def table_to_markdown(table: object) -> str:
|
||||
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:
|
||||
"""Return the first embedded image part referenced by a shape."""
|
||||
element = getattr(shape, "element", None)
|
||||
@@ -710,6 +780,9 @@ def convert_presentation_to_markdown(
|
||||
continue
|
||||
|
||||
if getattr(shape, "has_chart", False):
|
||||
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:
|
||||
|
||||
@@ -29,6 +29,7 @@ TLS fingerprint handling:
|
||||
import argparse
|
||||
import datetime
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
@@ -219,6 +220,7 @@ def download_and_rewrite_images(
|
||||
|
||||
os.makedirs(image_dir, exist_ok=True)
|
||||
downloaded = {}
|
||||
manifest_by_filename: dict[str, dict[str, object]] = {}
|
||||
saved = 0
|
||||
|
||||
for idx, img in enumerate(images):
|
||||
@@ -243,6 +245,8 @@ def download_and_rewrite_images(
|
||||
img["src"] = src
|
||||
|
||||
abs_url = urljoin(page_url, src)
|
||||
content_type = ""
|
||||
converted_from = ""
|
||||
if abs_url in downloaded:
|
||||
saved_name = downloaded[abs_url]
|
||||
else:
|
||||
@@ -269,6 +273,7 @@ def download_and_rewrite_images(
|
||||
pil_image = Image.open(img_data)
|
||||
|
||||
# Update filename to .png
|
||||
converted_from = filename
|
||||
filename = f"{stem}.png"
|
||||
local_path = os.path.join(image_dir, filename)
|
||||
|
||||
@@ -313,6 +318,19 @@ def download_and_rewrite_images(
|
||||
f.write(resp.content)
|
||||
downloaded[abs_url] = 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
|
||||
except Exception as e:
|
||||
print(f" [WARN] Skip image {abs_url}: {e}")
|
||||
@@ -321,6 +339,27 @@ def download_and_rewrite_images(
|
||||
rel_path = os.path.join(
|
||||
rel_prefix, saved_name) if rel_prefix else saved_name
|
||||
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
|
||||
|
||||
|
||||
+7
-4
@@ -18,6 +18,7 @@ from .drawingml_utils import (
|
||||
rect_to_dml_xfrm,
|
||||
parse_hex_color, resolve_url_id, get_effective_filter_id,
|
||||
parse_font_family, is_cjk_char, estimate_text_width,
|
||||
detect_text_lang, resolve_text_run_fonts,
|
||||
parse_transform_matrix, _xml_escape,
|
||||
)
|
||||
from .drawingml_styles import (
|
||||
@@ -1013,6 +1014,8 @@ def _build_run_xml(
|
||||
strike_attr = ' strike="sngStrike"' if 'line-through' in text_dec else ''
|
||||
|
||||
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)
|
||||
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 ''
|
||||
|
||||
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}
|
||||
{fill_xml}
|
||||
{effect_xml}
|
||||
<a:latin typeface="{_xml_escape(fonts['latin'])}"/>
|
||||
<a:ea typeface="{_xml_escape(fonts['ea'])}"/>
|
||||
<a:cs typeface="{_xml_escape(fonts['latin'])}"/>
|
||||
<a:latin typeface="{_xml_escape(run_fonts['latin'])}"/>
|
||||
<a:ea typeface="{_xml_escape(run_fonts['ea'])}"/>
|
||||
<a:cs typeface="{_xml_escape(run_fonts['cs'])}"/>
|
||||
</a:rPr>
|
||||
<a:t{space_attr}>{_xml_escape(text)}</a:t>
|
||||
</a:r>'''
|
||||
|
||||
+15
@@ -441,6 +441,21 @@ def is_cjk_char(ch: str) -> bool:
|
||||
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:
|
||||
"""Estimate text width in SVG pixels."""
|
||||
width = 0.0
|
||||
|
||||
+6
-3
@@ -4,6 +4,8 @@ from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
from .drawingml_utils import detect_text_lang
|
||||
|
||||
|
||||
def markdown_to_plain_text(md_content: str) -> str:
|
||||
"""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] = []
|
||||
for para in notes_text.split('\n'):
|
||||
if para.strip():
|
||||
lang = detect_text_lang(para)
|
||||
paragraphs.append(f'''<a:p>
|
||||
<a:r>
|
||||
<a:rPr lang="zh-CN" dirty="0"/>
|
||||
<a:rPr lang="{lang}" dirty="0"/>
|
||||
<a:t>{para}</a:t>
|
||||
</a:r>
|
||||
</a:p>''')
|
||||
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 = (
|
||||
'\n '.join(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"?>
|
||||
|
||||
@@ -4,9 +4,9 @@
|
||||
Delegates to the template_fill_pptx package. Kept as the CLI entry point so the
|
||||
documented command paths keep working:
|
||||
|
||||
python3 scripts/template_fill_pptx.py analyze <deck.pptx> -o slide_library.json
|
||||
python3 scripts/template_fill_pptx.py scaffold 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 analyze <deck.pptx> -o <stem>.slide_library.json
|
||||
python3 scripts/template_fill_pptx.py scaffold <stem>.slide_library.json -o 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
|
||||
|
||||
Implementation lives in the template_fill_pptx/ package (ooxml, analyzer,
|
||||
|
||||
+9
-1
@@ -77,9 +77,17 @@ def _chart_type_with_series(chart_root: ET.Element) -> ET.Element:
|
||||
plot_area = chart_root.find(".//c:plotArea", NS)
|
||||
if plot_area is None:
|
||||
raise RuntimeError("Chart XML has no plotArea")
|
||||
chart_types: list[ET.Element] = []
|
||||
for child in list(plot_area):
|
||||
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")
|
||||
|
||||
|
||||
|
||||
+21
-10
@@ -27,14 +27,15 @@ def _local_name(tag: str) -> str:
|
||||
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)
|
||||
if plot_area is None:
|
||||
return None
|
||||
return []
|
||||
chart_types: list[ET.Element] = []
|
||||
for child in list(plot_area):
|
||||
if _local_name(child.tag).endswith("Chart") and child.findall("c:ser", NS):
|
||||
return child
|
||||
return None
|
||||
chart_types.append(child)
|
||||
return chart_types
|
||||
|
||||
|
||||
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]:
|
||||
"""Return a compact summary of chart type, categories, series, and values."""
|
||||
chart_type = _chart_type_with_series(chart_root)
|
||||
if chart_type is None:
|
||||
chart_types = _chart_types_with_series(chart_root)
|
||||
if not chart_types:
|
||||
return empty_chart_data()
|
||||
|
||||
series_nodes = chart_type.findall("c:ser", NS)
|
||||
categories = _cache_values(series_nodes[0].find("c:cat", NS)) if series_nodes else []
|
||||
first_series = chart_types[0].find("c:ser", NS)
|
||||
categories = _cache_values(first_series.find("c:cat", NS)) if first_series is not None else []
|
||||
series_payload: list[dict[str, Any]] = []
|
||||
for index, series in enumerate(series_nodes, start=1):
|
||||
plot_types: list[str] = []
|
||||
for chart_type in chart_types:
|
||||
plot_name = _local_name(chart_type.tag)
|
||||
plot_types.append(plot_name)
|
||||
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 {
|
||||
"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),
|
||||
"series_count": len(series_payload),
|
||||
"categories": categories,
|
||||
|
||||
+16
@@ -387,6 +387,22 @@ def check_plan(library: dict[str, Any], plan: dict[str, Any]) -> dict[str, Any]:
|
||||
)
|
||||
summary["error"] += 1
|
||||
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", [])
|
||||
series = chart_edit.get("series", [])
|
||||
if not isinstance(categories, list) or not isinstance(series, list) or not series:
|
||||
|
||||
+3
-3
@@ -69,10 +69,10 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
|
||||
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("-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.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(
|
||||
"--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.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("-o", "--output", help="Optional JSON report output path")
|
||||
|
||||
|
||||
+11
-1
@@ -14,6 +14,7 @@
|
||||
| **Design Style** | {design_style} |
|
||||
| **Target Audience** | [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} |
|
||||
|
||||
---
|
||||
@@ -300,7 +301,8 @@ Catalog read: 71 templates
|
||||
|
||||
#### 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]
|
||||
- **Subtitle**: [Subtitle]
|
||||
- **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
|
||||
|
||||
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.
|
||||
|
||||
**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
|
||||
|
||||
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 |
|
||||
|---|---|
|
||||
@@ -60,15 +60,15 @@ python3 ${SKILL_DIR}/scripts/project_manager.py import-sources <project_path> <s
|
||||
|
||||
## 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/`.
|
||||
|
||||
**Visual identity (theme + observed sample + canvas)**:
|
||||
|
||||
```bash
|
||||
python3 ${SKILL_DIR}/scripts/beautify_identity.py <project_path>/sources/<source.pptx> -o <project_path>/analysis/identity.json
|
||||
```
|
||||
**Visual identity (theme + observed sample + canvas)**: read `<project_path>/analysis/<stem>.identity.json` (intake prefixes per-deck artifacts by source-file stem).
|
||||
|
||||
| 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.
|
||||
|
||||
**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
|
||||
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 |
|
||||
| `<stem>.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[].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.
|
||||
|
||||
**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
|
||||
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:
|
||||
|
||||
```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
|
||||
```
|
||||
|
||||
@@ -125,8 +121,9 @@ If `images/image_manifest.json` does not exist because the source deck has no ex
|
||||
## ✅ 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] `analysis/identity.json` has theme + observed identity + canvas aspect
|
||||
- [x] `analysis/slide_library.json` holds chart + table data for regeneration
|
||||
- [x] `analysis/<stem>.identity.json` has theme + observed identity + canvas aspect
|
||||
- [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
|
||||
- [ ] **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).
|
||||
|
||||
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.
|
||||
|
||||
| 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 |
|
||||
| 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 |
|
||||
@@ -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.
|
||||
- **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.5–3.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 |
|
||||
| Page count | output slide count equals the source slide count |
|
||||
| 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 |
|
||||
|
||||
```markdown
|
||||
|
||||
@@ -60,15 +60,15 @@ Use this fixed layout:
|
||||
|
||||
---
|
||||
|
||||
## Step 3: Extract the Slide Library
|
||||
## Step 3: Extract the PPTX Intake Bundle
|
||||
|
||||
Run:
|
||||
|
||||
```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 |
|
||||
|---|---|
|
||||
@@ -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.
|
||||
|
||||
**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 |
|
||||
|---|---|
|
||||
@@ -104,14 +104,14 @@ A page's layout already encodes a rhetorical shape — a single hero statement,
|
||||
Create a scaffold:
|
||||
|
||||
```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.
|
||||
|
||||
**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:
|
||||
|
||||
@@ -169,6 +169,7 @@ The plan structure:
|
||||
| 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 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 |
|
||||
|
||||
**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:
|
||||
|
||||
```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:
|
||||
@@ -278,7 +279,7 @@ If the extracted text is correct but visual overflow is likely, reduce the text
|
||||
```markdown
|
||||
## ✅ 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] `check-plan` run and capacity warnings resolved or explicitly accepted
|
||||
- [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 SmartArt deeply | Not in v1 |
|
||||
| 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 |
|
||||
|
||||
Reference in New Issue
Block a user