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:
@@ -268,18 +268,6 @@
|
|||||||
},
|
},
|
||||||
"category": "开发工具"
|
"category": "开发工具"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"name": "ui-ux-pro-max",
|
|
||||||
"source": {
|
|
||||||
"source": "local",
|
|
||||||
"path": "./plugins/codex/plugins/ui-ux-pro-max"
|
|
||||||
},
|
|
||||||
"policy": {
|
|
||||||
"installation": "AVAILABLE",
|
|
||||||
"authentication": "ON_INSTALL"
|
|
||||||
},
|
|
||||||
"category": "设计"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"name": "shadcn",
|
"name": "shadcn",
|
||||||
"source": {
|
"source": {
|
||||||
@@ -292,6 +280,18 @@
|
|||||||
},
|
},
|
||||||
"category": "开发工具"
|
"category": "开发工具"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "ui-ux-pro-max",
|
||||||
|
"source": {
|
||||||
|
"source": "local",
|
||||||
|
"path": "./plugins/codex/plugins/ui-ux-pro-max"
|
||||||
|
},
|
||||||
|
"policy": {
|
||||||
|
"installation": "AVAILABLE",
|
||||||
|
"authentication": "ON_INSTALL"
|
||||||
|
},
|
||||||
|
"category": "设计"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "ppt-master",
|
"name": "ppt-master",
|
||||||
"source": {
|
"source": {
|
||||||
|
|||||||
@@ -24,8 +24,8 @@
|
|||||||
"repo": "https://github.com/nextlevelbuilder/ui-ux-pro-max-skill.git",
|
"repo": "https://github.com/nextlevelbuilder/ui-ux-pro-max-skill.git",
|
||||||
"ref": "main",
|
"ref": "main",
|
||||||
"adapter": "claude-skill",
|
"adapter": "claude-skill",
|
||||||
"commit": "65e23199492fa911af32d9078e627ab4de01f4c8",
|
"commit": "8a81ed60272d21d4b8808f7308d49a0b1b000555",
|
||||||
"syncedAt": "2026-06-27T16:00:00Z"
|
"syncedAt": "2026-06-28T16:00:00Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "shadcn",
|
"id": "shadcn",
|
||||||
@@ -69,8 +69,8 @@
|
|||||||
"repo": "https://github.com/hugohe3/ppt-master.git",
|
"repo": "https://github.com/hugohe3/ppt-master.git",
|
||||||
"ref": "main",
|
"ref": "main",
|
||||||
"adapter": "claude-skill",
|
"adapter": "claude-skill",
|
||||||
"commit": "13a25616e6354fe5abe16f41da18f532e51ac644",
|
"commit": "8e2b5b9a0312df3710568d8a0638d1b53356af2b",
|
||||||
"syncedAt": "2026-06-27T16:00:00Z"
|
"syncedAt": "2026-06-28T16:00:00Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "next-skills",
|
"id": "next-skills",
|
||||||
@@ -78,8 +78,8 @@
|
|||||||
"repo": "https://github.com/vercel/next.js.git",
|
"repo": "https://github.com/vercel/next.js.git",
|
||||||
"ref": "canary",
|
"ref": "canary",
|
||||||
"adapter": "skill-collection",
|
"adapter": "skill-collection",
|
||||||
"commit": "4b164d765e16de0b277960a27d3938815ac03b97",
|
"commit": "f509282984a400b3316265c24e585873c97b8309",
|
||||||
"syncedAt": "2026-06-27T16:00:00Z"
|
"syncedAt": "2026-06-28T16:00:00Z"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -268,18 +268,6 @@
|
|||||||
},
|
},
|
||||||
"category": "开发工具"
|
"category": "开发工具"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"name": "ui-ux-pro-max",
|
|
||||||
"source": {
|
|
||||||
"source": "local",
|
|
||||||
"path": "./plugins/ui-ux-pro-max"
|
|
||||||
},
|
|
||||||
"policy": {
|
|
||||||
"installation": "AVAILABLE",
|
|
||||||
"authentication": "ON_INSTALL"
|
|
||||||
},
|
|
||||||
"category": "设计"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"name": "shadcn",
|
"name": "shadcn",
|
||||||
"source": {
|
"source": {
|
||||||
@@ -292,6 +280,18 @@
|
|||||||
},
|
},
|
||||||
"category": "开发工具"
|
"category": "开发工具"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "ui-ux-pro-max",
|
||||||
|
"source": {
|
||||||
|
"source": "local",
|
||||||
|
"path": "./plugins/ui-ux-pro-max"
|
||||||
|
},
|
||||||
|
"policy": {
|
||||||
|
"installation": "AVAILABLE",
|
||||||
|
"authentication": "ON_INSTALL"
|
||||||
|
},
|
||||||
|
"category": "设计"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "ppt-master",
|
"name": "ppt-master",
|
||||||
"source": {
|
"source": {
|
||||||
|
|||||||
@@ -3,5 +3,5 @@
|
|||||||
"name": "playwright浏览器自动化操作",
|
"name": "playwright浏览器自动化操作",
|
||||||
"version": "20260605",
|
"version": "20260605",
|
||||||
"keySource": "none",
|
"keySource": "none",
|
||||||
"syncedAt": "2026-06-27T16:01:26Z"
|
"syncedAt": "2026-06-28T16:01:19Z"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
"sourceId": "next-skills",
|
"sourceId": "next-skills",
|
||||||
"repo": "https://github.com/vercel/next.js.git",
|
"repo": "https://github.com/vercel/next.js.git",
|
||||||
"ref": "canary",
|
"ref": "canary",
|
||||||
"commit": "4b164d765e16de0b277960a27d3938815ac03b97",
|
"commit": "f509282984a400b3316265c24e585873c97b8309",
|
||||||
"adapter": "skill-collection",
|
"adapter": "skill-collection",
|
||||||
"sourcePath": "skills",
|
"sourcePath": "skills",
|
||||||
"syncedAt": "2026-06-27T16:00:00Z"
|
"syncedAt": "2026-06-28T16:00:00Z"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
"sourceId": "ppt-master",
|
"sourceId": "ppt-master",
|
||||||
"repo": "https://github.com/hugohe3/ppt-master.git",
|
"repo": "https://github.com/hugohe3/ppt-master.git",
|
||||||
"ref": "main",
|
"ref": "main",
|
||||||
"commit": "13a25616e6354fe5abe16f41da18f532e51ac644",
|
"commit": "8e2b5b9a0312df3710568d8a0638d1b53356af2b",
|
||||||
"adapter": "claude-skill",
|
"adapter": "claude-skill",
|
||||||
"sourcePath": "skills/ppt-master",
|
"sourcePath": "skills/ppt-master",
|
||||||
"syncedAt": "2026-06-27T16:00:00Z"
|
"syncedAt": "2026-06-28T16:00:00Z"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,11 +23,13 @@ description: "多格式源文档到高质量 SVG 页面再导出 PPTX 的多阶
|
|||||||
> 7. **SEQUENTIAL PAGE GENERATION ONLY** — In Executor Step 6, after the global design context is confirmed, SVG pages MUST be generated sequentially page by page in one continuous pass. Grouped page batches (for example, 5 pages at a time) are FORBIDDEN
|
> 7. **SEQUENTIAL PAGE GENERATION ONLY** — In Executor Step 6, after the global design context is confirmed, SVG pages MUST be generated sequentially page by page in one continuous pass. Grouped page batches (for example, 5 pages at a time) are FORBIDDEN
|
||||||
> 8. **SPEC_LOCK RE-READ PER PAGE** — Before generating each SVG page, Executor MUST `read_file <project_path>/spec_lock.md`. All colors / fonts / icons / images MUST come from this file — no values from memory or invented on the fly. Executor MUST also look up the current page's `page_rhythm` (`anchor` / `dense` / `breathing`), `page_layouts` (which template SVG to inherit, if any), and `page_charts` (which chart template to adapt, if any). Empty / absent entries are intentional Strategist signals — see executor-base.md §2.1. This rule exists to resist context-compression drift on long decks and to break the uniform "every page is a card grid" default
|
> 8. **SPEC_LOCK RE-READ PER PAGE** — Before generating each SVG page, Executor MUST `read_file <project_path>/spec_lock.md`. All colors / fonts / icons / images MUST come from this file — no values from memory or invented on the fly. Executor MUST also look up the current page's `page_rhythm` (`anchor` / `dense` / `breathing`), `page_layouts` (which template SVG to inherit, if any), and `page_charts` (which chart template to adapt, if any). Empty / absent entries are intentional Strategist signals — see executor-base.md §2.1. This rule exists to resist context-compression drift on long decks and to break the uniform "every page is a card grid" default
|
||||||
> 9. **SVG MUST BE HAND-WRITTEN, NOT SCRIPT-GENERATED** — Every SVG page is written by the main agent directly, one page at a time (see rules 6 and 7). Writing or running a Python / Node / shell script that produces the SVG files in batch — looping over pages, templating from data, or emitting them via a generator — is FORBIDDEN, including under "save tokens", "quick draft", or "user is in a hurry" pretexts. The script-generation path was tried on a feature branch and abandoned: cross-page visual consistency depends on per-page authoring with full upstream context, which a generator script cannot reproduce
|
> 9. **SVG MUST BE HAND-WRITTEN, NOT SCRIPT-GENERATED** — Every SVG page is written by the main agent directly, one page at a time (see rules 6 and 7). Writing or running a Python / Node / shell script that produces the SVG files in batch — looping over pages, templating from data, or emitting them via a generator — is FORBIDDEN, including under "save tokens", "quick draft", or "user is in a hurry" pretexts. The script-generation path was tried on a feature branch and abandoned: cross-page visual consistency depends on per-page authoring with full upstream context, which a generator script cannot reproduce
|
||||||
|
> 10. **FOLLOW DETERMINISTIC ROUTING RULES** — Do not add blocking routing questions when this skill defines a route. If the user request violates a route precondition, state the required prerequisite and stop that route instead of asking the user to choose around the rule. Ordinary finite options, stylistic preferences, and recoverable details are surfaced with a recommended value plus alternatives at the next existing confirmation gate.
|
||||||
|
|
||||||
> [!IMPORTANT]
|
> [!IMPORTANT]
|
||||||
> ## 🌐 Language & Communication Rule
|
> ## 🌐 Language & Communication Rule
|
||||||
>
|
>
|
||||||
> - **Response language**: match the user's input and source materials. Explicit user override (e.g., "请用英文回答") takes precedence.
|
> - **Response language**: match the user's input and source materials. Explicit user override (e.g., "请用英文回答") takes precedence.
|
||||||
|
> - **User-facing option labels**: when presenting confirmations, brief proposals, choices, or finite option sets, use the user's language for labels and explanations. English enum IDs / file fields may appear in parentheses for precision, but never rely on English-only labels such as `deck`, `layout`, `mirror`, or `fidelity` without a localized explanation.
|
||||||
> - **Template format**: `design_spec.md` MUST follow its original English template structure (section headings, field names) regardless of conversation language. Content values may be in the user's language.
|
> - **Template format**: `design_spec.md` MUST follow its original English template structure (section headings, field names) regardless of conversation language. Content values may be in the user's language.
|
||||||
|
|
||||||
> [!IMPORTANT]
|
> [!IMPORTANT]
|
||||||
@@ -52,6 +54,7 @@ description: "多格式源文档到高质量 SVG 页面再导出 PPTX 的多阶
|
|||||||
| `${SKILL_DIR}/scripts/analyze_images.py` | Image analysis |
|
| `${SKILL_DIR}/scripts/analyze_images.py` | Image analysis |
|
||||||
| `${SKILL_DIR}/scripts/latex_render.py` | LaTeX formula rendering (manifest-driven PNG assets) |
|
| `${SKILL_DIR}/scripts/latex_render.py` | LaTeX formula rendering (manifest-driven PNG assets) |
|
||||||
| `${SKILL_DIR}/scripts/image_gen.py` | AI image generation (multi-provider) |
|
| `${SKILL_DIR}/scripts/image_gen.py` | AI image generation (multi-provider) |
|
||||||
|
| `${SKILL_DIR}/scripts/slice_images.py` | Slice one AI illustration sheet into individual spot-illustration elements |
|
||||||
| `${SKILL_DIR}/scripts/svg_quality_checker.py` | SVG quality check |
|
| `${SKILL_DIR}/scripts/svg_quality_checker.py` | SVG quality check |
|
||||||
| `${SKILL_DIR}/scripts/total_md_split.py` | Speaker notes splitting |
|
| `${SKILL_DIR}/scripts/total_md_split.py` | Speaker notes splitting |
|
||||||
| `${SKILL_DIR}/scripts/finalize_svg.py` | SVG post-processing (unified entry) |
|
| `${SKILL_DIR}/scripts/finalize_svg.py` | SVG post-processing (unified entry) |
|
||||||
@@ -92,13 +95,34 @@ For complete tool documentation, see `${SKILL_DIR}/scripts/README.md`.
|
|||||||
|
|
||||||
### PPTX Route Boundary
|
### PPTX Route Boundary
|
||||||
|
|
||||||
|
> [!CAUTION]
|
||||||
|
> **Raw PPTX template requests route to `template-fill` by default.** A `.pptx` may enter the main pipeline as source material. But when the user provides a raw `.pptx` template plus new material / a new topic and asks to generate a `.pptx`, use [`workflows/template-fill-pptx.md`](workflows/template-fill-pptx.md). The SVG generation route can consume only an explicit template directory path; if the user wants SVG/template-based generation from that PPTX, they must run [`workflows/create-template.md`](workflows/create-template.md) first and return with the generated template directory path.
|
||||||
|
|
||||||
When the user provides an existing `.pptx`, route by the role of the source deck:
|
When the user provides an existing `.pptx`, route by the role of the source deck:
|
||||||
|
|
||||||
|
**Template-fill vs template-based generation — mutually exclusive routes.** Do not treat these as two implementations of the same request. They answer different user intents:
|
||||||
|
|
||||||
|
- `template-fill` means **native PPTX fill**: clone selected source slides and patch their existing text / tables / charts.
|
||||||
|
- `create-template` first, then main pipeline means **template-based generation**: turn the source deck into a reusable template package, then generate a new deck through the SVG pipeline from that template directory.
|
||||||
|
- A raw PPTX template plus a request to output a PPTX defaults to `template-fill`; it does not enter the SVG route unless the user explicitly asks to create / use a reusable template package.
|
||||||
|
|
||||||
|
| Axis | `template-fill` | `create-template` first, then main pipeline |
|
||||||
|
|---|---|---|
|
||||||
|
| Source deck role | Native slide library to clone and patch | Design reference to convert into a reusable template package |
|
||||||
|
| Output mechanics | Direct OOXML editing; no SVG pipeline | SVG generation pipeline after a template directory exists |
|
||||||
|
| User expectation | "Use this PPT template to generate a PPTX", "fill / replace text / keep these PowerPoint slide shells" | "Create / use this as a reusable template style for SVG-generated decks" |
|
||||||
|
| Design freedom | Low to moderate: selected source slides are reused as native shells | Moderate to high: generated deck may select, repeat, skip, reorder, and adapt template pages |
|
||||||
|
| Reusability | One-off project output | Reusable `templates/<kind>/<id>/` asset |
|
||||||
|
| Direct signal phrases | "fill back", "replace the copy", "keep these slides", "edit this PPTX directly" | "create a template", "make this reusable", "generate from this template directory", "use this SVG template package" |
|
||||||
|
|
||||||
|
Apply the route deterministically: raw `.pptx` template + "generate PPTX" → `template-fill`. SVG/template-based generation → requires a pre-created template directory path. If the user asks for SVG/template-based generation from a raw PPTX, tell them to run `create-template` first and return with the generated template directory path; do not ask a route-choice question.
|
||||||
|
|
||||||
| User intent | Route | Contract |
|
| 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 |
|
| 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 |
|
| 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 |
|
| Use a raw PPTX template to generate a new PPTX with new material | `template-fill` | Clone selected source slides and replace text / table / chart data directly in OOXML; no SVG generation, no reusable template package |
|
||||||
|
| Use the SVG generation route with this deck's design language | `create-template` first, then main pipeline | A raw PPTX is not a Step 3 template. The user must create a reusable template package first; once they provide the resulting template directory path, Step 3 can consume it like any other explicit template path |
|
||||||
| Harvest the deck as a reusable future template | `create-template` | Build a template package, not a one-off generated deck |
|
| Harvest the deck as a reusable future template | `create-template` | Build a template package, not a one-off generated deck |
|
||||||
| Keep the finished deck visually stable and append native optimizations such as notes / narration audio / automatic playback | `native-enhance-pptx` | Archive the source PPTX into the project (`projects/` sources move; external sources copy) and patch enhancement metadata/media directly in OOXML; no SVG generation |
|
| Keep the finished deck visually stable and append native optimizations such as notes / narration audio / automatic playback | `native-enhance-pptx` | Archive the source PPTX into the project (`projects/` sources move; external sources copy) and patch enhancement metadata/media directly in OOXML; no SVG generation |
|
||||||
|
|
||||||
@@ -156,7 +180,7 @@ When the user provides non-Markdown content, convert immediately:
|
|||||||
python3 ${SKILL_DIR}/scripts/project_manager.py init <project_name> --format <format>
|
python3 ${SKILL_DIR}/scripts/project_manager.py init <project_name> --format <format>
|
||||||
```
|
```
|
||||||
|
|
||||||
Format options: `ppt169` (default), `ppt43`, `xhs`, `story`, etc. For the full format list, see `references/canvas-formats.md`.
|
Format options must be named with concrete dimensions. Default: `ppt169` = `1280x720`, `viewBox="0 0 1280 720"`. Other examples: `ppt43` = `1024x768`, `story` = `1080x1920`, `banner` = `1920x1080`. For the full format list, see `references/canvas-formats.md`.
|
||||||
|
|
||||||
Import source content (choose based on the situation):
|
Import source content (choose based on the situation):
|
||||||
|
|
||||||
@@ -187,6 +211,10 @@ Multi-deck: several PPTX files may be imported into one main-pipeline project
|
|||||||
|
|
||||||
**Default — free design.** Proceed directly to Step 4. Do NOT query any `*_index.json` unless triggered. Do NOT ask the user. Do NOT proactively suggest, hint at, or fuzzy-match any template based on content, slug-like words, or vague style descriptions.
|
**Default — free design.** Proceed directly to Step 4. Do NOT query any `*_index.json` unless triggered. Do NOT ask the user. Do NOT proactively suggest, hint at, or fuzzy-match any template based on content, slug-like words, or vague style descriptions.
|
||||||
|
|
||||||
|
**Hard boundary — raw PPTX template references are not Step 3 templates.** PPTX-as-source remains valid in Step 1 / Step 2, and raw PPTX template + generated PPTX routes to `template-fill`. But if the user wants the SVG/template-based generation route from that PPTX, stop before Step 3. The user must first run [`workflows/create-template.md`](workflows/create-template.md), then return with the generated template directory path. Step 3 only consumes an explicit template directory that already contains `design_spec.md` with `kind: brand` / `kind: layout` / `kind: deck`.
|
||||||
|
|
||||||
|
Do **not** reinterpret this boundary as 1:1 redesign or free SVG generation. Use `template-fill` for raw PPTX template + generated PPTX requests; use `beautify` only when the source deck's page count, order, and wording are preserved.
|
||||||
|
|
||||||
**Template flow triggers ONLY on explicit directory paths** supplied by the user in their initial message. The trigger rule is mechanical, not interpretive:
|
**Template flow triggers ONLY on explicit directory paths** supplied by the user in their initial message. The trigger rule is mechanical, not interpretive:
|
||||||
|
|
||||||
| User input contains | Step 3 action |
|
| User input contains | Step 3 action |
|
||||||
@@ -331,7 +359,7 @@ Read references/strategist.md
|
|||||||
| Tier | Confirms | Driven by |
|
| Tier | Confirms | Driven by |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| **1 — anchors** | canvas · audience + core message + `content_divergence` + `delivery_purpose` *(PPT only — omitted on non-PPT canvases)* (all §c key info) · `mode` + `visual_style` | the source + user intent |
|
| **1 — anchors** | canvas · audience + core message + `content_divergence` + `delivery_purpose` *(PPT only — omitted on non-PPT canvases)* (all §c key info) · `mode` + `visual_style` | the source + user intent |
|
||||||
| **2 — realization** (re-derived from Tier 1) | page count · color · typography (font + size) · icons · formula policy · image usage + strategy · generation mode · refine-spec toggle | the confirmed Tier 1 |
|
| **2 — realization** (re-derived from Tier 1) | page count · color · typography (font + size) · icons · formula policy · image usage + generated-image style · generation mode · refine-spec toggle | the confirmed Tier 1 |
|
||||||
|
|
||||||
> **Why two tiers.** Every realization field is anchored by the same few choices (`visual_style` anchors color / icon / typography / image; `delivery_purpose` sets the body size, page density, **and** the page-count recommendation). Confirming anchors first, then re-deriving, means Tier 2's candidates fit the user's *real* anchors instead of your originals — the coherence reconciliation below is done by construction on this path. Page count is a **derived** field (content volume × `delivery_purpose`), which is why it lives in Tier 2, not up front.
|
> **Why two tiers.** Every realization field is anchored by the same few choices (`visual_style` anchors color / icon / typography / image; `delivery_purpose` sets the body size, page density, **and** the page-count recommendation). Confirming anchors first, then re-deriving, means Tier 2's candidates fit the user's *real* anchors instead of your originals — the coherence reconciliation below is done by construction on this path. Page count is a **derived** field (content volume × `delivery_purpose`), which is why it lives in Tier 2, not up front.
|
||||||
|
|
||||||
@@ -345,7 +373,7 @@ Steps:
|
|||||||
python3 ${SKILL_DIR}/scripts/confirm_ui/server.py <project_path> --daemon --wait
|
python3 ${SKILL_DIR}/scripts/confirm_ui/server.py <project_path> --daemon --wait
|
||||||
```
|
```
|
||||||
Page opens at `http://localhost:5050` — the **same port as the Step 6 live preview** (they never run at once: this page shuts down at the end of Step 4). If 5050 is held, the launcher **auto-advances** (5051, …) — read the actual URL from the launch log and report it. The page does **not** close after Tier 1: it shows a "deriving…" state and polls for Tier 2. **Launch or wait failure is non-fatal**: if it fails or times out (flask missing, port blocked, no GUI / remote / web host), do **NOT** troubleshoot — **on any non-zero exit, re-check `result.json` once** (a fresh `status: tier1-confirmed`) before dropping to the chat fallback. **On success (exit 0 with a tier-1 result), do not pause or report — go straight to step 3 in the same turn.**
|
Page opens at `http://localhost:5050` — the **same port as the Step 6 live preview** (they never run at once: this page shuts down at the end of Step 4). If 5050 is held, the launcher **auto-advances** (5051, …) — read the actual URL from the launch log and report it. The page does **not** close after Tier 1: it shows a "deriving…" state and polls for Tier 2. **Launch or wait failure is non-fatal**: if it fails or times out (flask missing, port blocked, no GUI / remote / web host), do **NOT** troubleshoot — **on any non-zero exit, re-check `result.json` once** (a fresh `status: tier1-confirmed`) before dropping to the chat fallback. **On success (exit 0 with a tier-1 result), do not pause or report — go straight to step 3 in the same turn.**
|
||||||
3. **Re-derive Tier 2 from the confirmed anchors, then write it — immediately, same turn (the page is polling for it).** Read the tier-1 `result.json` (`status: tier1-confirmed`). Using the user's **actual** confirmed anchors (not your originals), author the realization candidates and **overwrite** `recommendations.json` with `"tier": 2`: page count (content volume × `delivery_purpose`); color, typography, and generated-image style as **generative ≥3-candidate** fields (creative recommendations always offer real choice — same rule as h.5; fewer than 3 only on the honest-shortfall exception, with a stated reason; color: core `palette` with background/secondary_bg/primary/accent/secondary_accent/body_text; typography: CJK + Latin for `heading` and `body` with `css` preview stacks + `body_size` as the body baseline in **px** (every canvas) — **one fixed value per confirmed `delivery_purpose`** (`text` 20 / `balanced` 24 / `presentation` 32), not a range; images: `image_strategy.candidates` rendering × palette from h.5); enumerable `icons` / `formula_policy` / `generation_mode` (recommended `id`); `image_usage` (`ai` / `web` / `provided` / `placeholder` / `none`, or a custom prose plan when several sources mix — never bare `"custom"`; write `image_ai_path` only when the plan includes AI). The still-open page polls, renders Tier 2, and preserves the user's Tier 1 picks. Closed fields (`image_ai_path`, `formula_policy`, `generation_mode`, `refine_spec`) stay finite; open fields (`icons`, `image_usage`, typography custom text) show a Custom box.
|
3. **Re-derive Tier 2 from the confirmed anchors, then write it — immediately, same turn (the page is polling for it).** Read the tier-1 `result.json` (`status: tier1-confirmed`). Using the user's **actual** confirmed anchors (not your originals), author the realization candidates and **overwrite** `recommendations.json` with `"tier": 2`: page count (content volume × `delivery_purpose`); color, typography, and generated-image style as **generative ≥3-candidate** fields (creative recommendations always offer real choice — same rule as h.5; fewer than 3 only on the honest-shortfall exception, with a stated reason; color: core `palette` with background/secondary_bg/primary/accent/secondary_accent/body_text; typography: CJK + Latin for `heading` and `body` with `css` preview stacks + `body_size` as the body baseline in **px** (every canvas) — **one fixed value per confirmed `delivery_purpose`** (`text` 20 / `balanced` 24 / `presentation` 32), not a range; each typography candidate must include topic-matched `sample_heading` / `sample_heading_latin` / `sample_body` / `sample_body_latin` preview text, never a fixed unrelated industry sample; images: `image_strategy.candidates` rendering × palette from h.5); enumerable `icons` / `formula_policy` / `generation_mode` (recommended `id`); `image_usage` as one or more source ids (`["ai"]`, `["ai","provided"]`, `["web","placeholder"]`, or `["none"]`; `none` is exclusive). If the recommendation involves several sources, keep the source list structured in `recommend.image_usage` and write the usage rationale / page-role guidance into `image_notes` (for example, "封面和章节页用 AI 主视觉,产品页优先用户素材,行业背景页可用网络参考"). Write `image_ai_path` only when `image_usage` includes `ai`. Spot-illustration lean is **not** a candidate field here: it derives from the locked `visual_style`'s illustration propensity and is expressed only in the recommendation rationale / `image_notes`, never as a new confirmation field. The still-open page polls, renders Tier 2, and preserves the user's Tier 1 picks. Closed fields (`image_ai_path`, `formula_policy`, `generation_mode`, `refine_spec`) stay finite; open fields (`icons`, typography custom text, image notes) expose free-text controls.
|
||||||
4. **Wait for the final confirmation** — attach to the already-running page, do **not** relaunch (same 600000 ms budget):
|
4. **Wait for the final confirmation** — attach to the already-running page, do **not** relaunch (same 600000 ms budget):
|
||||||
```bash
|
```bash
|
||||||
python3 ${SKILL_DIR}/scripts/confirm_ui/server.py <project_path> --wait-only
|
python3 ${SKILL_DIR}/scripts/confirm_ui/server.py <project_path> --wait-only
|
||||||
@@ -359,17 +387,20 @@ Steps:
|
|||||||
|
|
||||||
**Always also print each tier's recommendations + URL in chat** as the always-valid fallback. **The chat fallback is two-step too**: if the page never opens or a wait times out with no fresh result, present Tier 1 in chat → get confirmation → re-derive → present Tier 2 → get confirmation → take those values. Either path converges.
|
**Always also print each tier's recommendations + URL in chat** as the always-valid fallback. **The chat fallback is two-step too**: if the page never opens or a wait times out with no fresh result, present Tier 1 in chat → get confirmation → re-derive → present Tier 2 → get confirmation → take those values. Either path converges.
|
||||||
|
|
||||||
**Honoring the confirmation (result.json is authoritative — Mandatory)**: the confirmed values **override your own recommendations** when you write `design_spec.md` / `spec_lock.md`. A user who changed any field changed it on purpose. In particular, map `image_usage` to §VIII `Acquire Via` (its value names differ from §h options — translate):
|
**Honoring the confirmation (result.json is authoritative — Mandatory)**: the confirmed values **override your own recommendations** when you write `design_spec.md` / `spec_lock.md`. A user who changed any field changed it on purpose. In particular, map `image_usage` to §VIII `Acquire Via` (its value names differ from §h options — translate). `image_usage` may be either a legacy single string or a Confirm UI multi-select array; for arrays, apply every selected source. `image_notes`, when present, is a user-authored image intent note that Strategist must honor while assigning per-page §VIII rows:
|
||||||
|
|
||||||
| `result.json.image_usage` | §VIII `Acquire Via` | h.5 + Step 5 generation |
|
| `result.json.image_usage` | §VIII `Acquire Via` | h.5 + Step 5 generation |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `ai` (or a custom plan that includes AI) | `ai` rows | Run h.5 (lock rendering + palette); Step 5 generates |
|
| `ai` | `ai` rows | Run h.5 (lock rendering + palette); Step 5 generates |
|
||||||
| `web` | `web` rows | None |
|
| `web` | `web` rows | None |
|
||||||
| `provided` | **`user`** rows | None — never generate |
|
| `provided` | **`user`** rows | None — never generate |
|
||||||
| `placeholder` | `placeholder` rows | None |
|
| `placeholder` | `placeholder` rows | None |
|
||||||
| `none` | no image rows (§h option A) | None |
|
| `none` | no image rows (§h option A) | None |
|
||||||
|
| Legacy custom prose | Infer the intended rows from the prose | Run h.5 only if the prose includes AI |
|
||||||
|
|
||||||
When the confirmed `image_usage` is not `ai` (and the plan has no AI part), do **NOT** run h.5, do **NOT** write `ai` rows, and do **NOT** generate images in Step 5 — regardless of what you recommended. The same "confirmed value wins" rule applies to every field (color → §III, typography → §IV, etc.).
|
When the confirmed `image_usage` does not include `ai` (and no legacy custom prose includes AI), do **NOT** run h.5, do **NOT** write `ai` rows, and do **NOT** generate images in Step 5 — regardless of what you recommended. `none` is exclusive: if confirmed, write no §VIII image rows. The same "confirmed value wins" rule applies to every field (color → §III, typography → §IV, etc.).
|
||||||
|
|
||||||
|
**Small spot illustrations are a Strategist judgment, not a confirmation field.** The user chooses image *source* through `image_usage`; whether the deck leans into decorative illustrations is anchored by the locked `visual_style`'s **illustration propensity** (`core` / `supportive` / `sparse`), expressed only in the `image_notes` rationale — never a new confirmation control. An explicit user request to use or skip illustrations overrides that default either way; `image_usage: none` still wins (write no illustration rows); and source still comes from `image_usage` — a `core` style does not silently generate AI spots when the user did not pick AI. They are ordinary §VIII image rows (`Type: Illustration` / `Illustration Sheet`) using normal `Acquire Via` values. If the plan needs ≥3 same-family AI spot illustrations, use the `ai` Illustration Sheet + `slice` workflow by default; do not generate one AI image per spot. Full rule + precedence: [`references/strategist.md`](references/strategist.md) §h. Use them on suitable pages and omit them where they would weaken clarity.
|
||||||
|
|
||||||
**Upstream override → re-derive untouched downstream (Mandatory — chat-fallback / single-pass path).** On the **two-tier page path this is already handled** (Step 3 re-derives Tier 2 from the user's actual anchors). It still applies whenever anchors and realization are confirmed **together** — the two-step chat fallback collapsed into one bundle, or a legacy single-pass `result.json`. "Confirmed value wins" governs each field's *own* value — never recompute a value the user set (a size, canvas, or palette they edited stays verbatim). But a single-pass `result.json` can carry a changed **anchor** beside downstream fields still holding your original — now incoherent — recommendation (e.g. switched to `dark-tech` while the light palette you proposed is untouched). Before writing the spec, reconcile: when the user changed an anchor, re-derive the downstream fields the user did **not** themselves edit so they realize the new anchor; fields the user pinned stay as confirmed.
|
**Upstream override → re-derive untouched downstream (Mandatory — chat-fallback / single-pass path).** On the **two-tier page path this is already handled** (Step 3 re-derives Tier 2 from the user's actual anchors). It still applies whenever anchors and realization are confirmed **together** — the two-step chat fallback collapsed into one bundle, or a legacy single-pass `result.json`. "Confirmed value wins" governs each field's *own* value — never recompute a value the user set (a size, canvas, or palette they edited stays verbatim). But a single-pass `result.json` can carry a changed **anchor** beside downstream fields still holding your original — now incoherent — recommendation (e.g. switched to `dark-tech` while the light palette you proposed is untouched). Before writing the spec, reconcile: when the user changed an anchor, re-derive the downstream fields the user did **not** themselves edit so they realize the new anchor; fields the user pinned stay as confirmed.
|
||||||
|
|
||||||
@@ -449,7 +480,7 @@ python3 ${SKILL_DIR}/scripts/analyze_images.py <project_path>/images
|
|||||||
|
|
||||||
🚧 **GATE**: Step 4 complete; Design Specification & Content Outline generated and user confirmed. Any formula rows already have `Acquire Via: formula` and `Status: Rendered`.
|
🚧 **GATE**: Step 4 complete; Design Specification & Content Outline generated and user confirmed. Any formula rows already have `Acquire Via: formula` and `Status: Rendered`.
|
||||||
|
|
||||||
> **Trigger**: At least one row in the resource list has `Acquire Via: ai` and/or `Acquire Via: web`. If every row is `user`, `formula`, or `placeholder`, skip to Step 6.
|
> **Trigger**: At least one row in the resource list has `Acquire Via: ai`, `web`, and/or `slice`. If every row is `user`, `formula`, or `placeholder`, skip to Step 6.
|
||||||
|
|
||||||
**Always load the common framework**:
|
**Always load the common framework**:
|
||||||
|
|
||||||
@@ -461,24 +492,28 @@ Then **lazy-load the path-specific reference** for each row that actually needs
|
|||||||
|
|
||||||
| Acquire Via | Load reference (only if any such row exists) | Run |
|
| Acquire Via | Load reference (only if any such row exists) | Run |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `ai` | `references/image-generator.md` | `python3 ${SKILL_DIR}/scripts/image_gen.py --manifest <project_path>/images/image_prompts.json` |
|
| `ai` | `references/image-generator.md` | write `<project_path>/images/image_prompts.json`, then follow `image-generator.md §7 Path Selection` (`image_gen.py --manifest` is **Path A only**) |
|
||||||
| `web` | `references/image-searcher.md` | `python3 ${SKILL_DIR}/scripts/image_search.py ...` (≥2 web rows → `--batch images/image_queries.json`) |
|
| `web` | `references/image-searcher.md` | `python3 ${SKILL_DIR}/scripts/image_search.py ...` (≥2 web rows → `--batch images/image_queries.json`) |
|
||||||
| `user` / `placeholder` | (skip) | (skip) |
|
| `slice` | `references/image-generator.md` §4.3 | derived — **after** the parent `ai` sheet row is `Generated`, run `python3 ${SKILL_DIR}/scripts/slice_images.py <project_path>/images/<sheet>.png --grid RxC --names ... --trim --alpha` (see workflow step 2.5) |
|
||||||
|
| `user` / `formula` / `placeholder` | (skip) | (skip) |
|
||||||
|
|
||||||
A deck with only `ai` rows never loads `image-searcher.md`; a deck with only `web` rows never loads `image-generator.md`. A mixed deck loads both, processes each row through its own path, and writes both `image_prompts.json` and `image_sources.json`.
|
A deck with only `ai` rows never loads `image-searcher.md`; a deck with only `web` rows never loads `image-generator.md`. A mixed deck loads both, processes each row through its own path, and writes both `image_prompts.json` and `image_sources.json`.
|
||||||
|
|
||||||
> ⚠️ **In-pipeline ai path MUST use manifest mode** — even when only 1 ai row exists. Write `images/image_prompts.json` first, then run `image_gen.py --manifest`, then `image_gen.py --render-md` to produce the `image_prompts.md` sidecar. The positional form (`image_gen.py "prompt" ...`) is reserved for **out-of-pipeline one-off testing / single-image fixups** — it skips manifest + sidecar, leaving no audit trail.
|
> ⚠️ **In-pipeline ai rows MUST use the manifest contract** — even when only 1 ai row exists. Always write `images/image_prompts.json` first and render `image_prompts.md` with `image_gen.py --render-md`. Then execute the confirmed path from `image-generator.md §7`: `image_gen.py --manifest` is **Path A only**; `host-native` is **Path B** and MUST skip `--manifest`; `manual` writes the prompts and stops for external generation. 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.
|
> ⚠️ **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.
|
> 💡 **ai path — spot illustrations as one sheet**: when the §VIII image resource plan needs ≥3 same-family spot illustrations as decorative accessories, generate **one grid sheet** (a single `ai` sheet row) instead of one row per element, then slice it (workflow step 2.5 below). Choose sheet geometry from intended placement: `1xN` / `Nx1` are useful for extreme portrait / landscape cells, and a designed `MxN` grid is valid when its cell ratio fits the planned elements. The sheet row is generated but not placed; each cut **element row** (`Acquire Via: slice`) is placed and must appear in `spec_lock.md images`. One generation = one coherent style across all pieces. Resource contract + the geometry rules: [image-generator.md](references/image-generator.md) §4.3.
|
||||||
|
|
||||||
|
> ⚠️ **Honor the confirmed image source before running any generation command**: 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. Never run `image_gen.py --manifest` when the confirmed value is `host-native` or `manual`. Full selection rule: [image-generator.md](references/image-generator.md) §7 Path Selection.
|
||||||
|
|
||||||
Workflow:
|
Workflow:
|
||||||
|
|
||||||
1. Extract all rows with `Status: Pending` and `Acquire Via ∈ {ai, web}` from the design spec
|
1. Extract all resource rows from the design spec and group them by `Acquire Via`; rows with `Status: Pending` and `Acquire Via ∈ {ai, web, slice}` must all reach a terminal state before Executor starts
|
||||||
2. Generate prompts (ai rows) and/or run search (web rows) per [image-base.md](references/image-base.md) §2 dispatch table
|
2. Generate prompts (ai rows) and/or run search (web rows) per [image-base.md](references/image-base.md) §3 dispatch table
|
||||||
3. Verify every row reaches a terminal status: `Generated` (ai success), `Sourced` (web success), or `Needs-Manual`
|
2.5. **Slice any spot-illustration sheets (only if `slice` rows exist).** For each generated `ai` **sheet** row, run `slice_images.py` (grid + the element `--names` matching the `slice` rows, `--trim --alpha`) so every element file lands in `images/`; mark each `slice` row `Generated`. A sheet still in `Needs-Manual` cannot be sliced — leave its `slice` rows `Needs-Manual` and surface them at the Step 7 readiness gate. Contract: [image-generator.md](references/image-generator.md) §4.3.
|
||||||
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).
|
3. Verify every row reaches a terminal status: `Generated` (ai success / sliced element), `Sourced` (web success), or `Needs-Manual`
|
||||||
|
4. Re-derive image facts now that web / AI / sliced files are in the folder — `python3 ${SKILL_DIR}/scripts/analyze_images.py <project_path>/images` — so `analysis/image_analysis.csv` reflects every acquired image **including the sliced elements** (real measured sizes) before the Executor lays them out. Image facts are regenerated on use, never a stale store (see Step 4's image-facts note).
|
||||||
|
|
||||||
**✅ Checkpoint — Confirm acquisition attempted for every row**:
|
**✅ Checkpoint — Confirm acquisition attempted for every row**:
|
||||||
```markdown
|
```markdown
|
||||||
@@ -486,8 +521,9 @@ Workflow:
|
|||||||
- [x] image_prompts.json created (when any ai rows processed)
|
- [x] image_prompts.json created (when any ai rows processed)
|
||||||
- [x] image_prompts.md sidecar rendered (when any ai rows processed)
|
- [x] image_prompts.md sidecar rendered (when any ai rows processed)
|
||||||
- [x] image_sources.json created (when any web rows processed)
|
- [x] image_sources.json created (when any web rows processed)
|
||||||
|
- [x] Spot-illustration sheets sliced (when any `slice` rows exist); every element file present in `images/` and listed in `spec_lock.md images`
|
||||||
- [x] Each row: status is `Generated` / `Sourced` / `Needs-Manual` (no `Pending` remaining)
|
- [x] Each row: status is `Generated` / `Sourced` / `Needs-Manual` (no `Pending` remaining)
|
||||||
- [x] analyze_images.py re-run so image_analysis.csv covers the acquired web / AI images
|
- [x] analyze_images.py re-run so image_analysis.csv covers the acquired web / AI / sliced images
|
||||||
```
|
```
|
||||||
|
|
||||||
**Default — auto-proceed to Step 6.** Only when the user's Step 4 response explicitly opted into split mode (in chat or via Confirm UI `result.json` with `generation_mode: "split"`), output the Phase A hand-off below and stop this conversation:
|
**Default — auto-proceed to Step 6.** Only when the user's Step 4 response explicitly opted into split mode (in chat or via Confirm UI `result.json` with `generation_mode: "split"`), output the Phase A hand-off below and stop this conversation:
|
||||||
@@ -573,6 +609,8 @@ python3 ${SKILL_DIR}/scripts/svg_quality_checker.py <project_path>
|
|||||||
|
|
||||||
> If files are missing: PAUSE, list the missing filenames, point the user to `images/image_prompts.md` (each `### Image N:` block is paste-ready for ChatGPT / Gemini / Midjourney; auto-generated from `image_prompts.json`) and the required placement `project/images/<filename>`. Resume Step 7.1 only after all expected files are in place. `finalize_svg.py` and `svg_to_pptx.py` do not detect missing files at this layer — proceeding with gaps produces a deck with broken image references.
|
> If files are missing: PAUSE, list the missing filenames, point the user to `images/image_prompts.md` (each `### Image N:` block is paste-ready for ChatGPT / Gemini / Midjourney; auto-generated from `image_prompts.json`) and the required placement `project/images/<filename>`. Resume Step 7.1 only after all expected files are in place. `finalize_svg.py` and `svg_to_pptx.py` do not detect missing files at this layer — proceeding with gaps produces a deck with broken image references.
|
||||||
|
|
||||||
|
> **Spot-illustration sheets at this gate**: `slice` element files are **derived**, not placed by the user. If a sheet was `Needs-Manual` (offline), the element files do not exist yet — list the **sheet** filename (`images/<sheet>.png`) plus its element target names, and instruct: place the sheet, then run the Step 5 `slice_images.py` command for it, then re-run `analyze_images.py`, before resuming 7.1. Never tell the user to hand-place the individual element files — they only come from slicing the sheet.
|
||||||
|
|
||||||
> ⚠️ Run the three sub-steps **one at a time** — each must complete successfully before the next.
|
> ⚠️ Run the three sub-steps **one at a time** — each must complete successfully before the next.
|
||||||
> ❌ **NEVER** combine them into a single code block or shell invocation.
|
> ❌ **NEVER** combine them into a single code block or shell invocation.
|
||||||
|
|
||||||
@@ -583,10 +621,11 @@ Canonical three-command pipeline (mirrors `references/shared-standards.md` §5):
|
|||||||
python3 ${SKILL_DIR}/scripts/total_md_split.py <project_path>
|
python3 ${SKILL_DIR}/scripts/total_md_split.py <project_path>
|
||||||
```
|
```
|
||||||
|
|
||||||
**Step 7.2** — SVG post-processing (icon embedding / image crop & embed / text flattening / rounded rect to path):
|
**Step 7.2** — SVG post-processing (icon embedding / image crop & embed / raster image optimization / text flattening / rounded rect to path):
|
||||||
```bash
|
```bash
|
||||||
python3 ${SKILL_DIR}/scripts/finalize_svg.py <project_path>
|
python3 ${SKILL_DIR}/scripts/finalize_svg.py <project_path>
|
||||||
```
|
```
|
||||||
|
Default raster handling for `svg_final/`: images are embedded at the rendered SVG size budget (`--image-scale 2`, `--max-dimension 2560`), opaque PNG photos may be written as JPEG, and transparent assets remain PNG. Use `--no-compress` or a higher `--max-dimension` only for diagnostic / high-fidelity SVG snapshots.
|
||||||
|
|
||||||
**Step 7.3** — Export PPTX (embeds speaker notes by default):
|
**Step 7.3** — Export PPTX (embeds speaker notes by default):
|
||||||
```bash
|
```bash
|
||||||
@@ -601,7 +640,16 @@ python3 ${SKILL_DIR}/scripts/svg_to_pptx.py <project_path>
|
|||||||
|
|
||||||
> The native pptx consumes `svg_output/` directly so the converter can preserve
|
> The native pptx consumes `svg_output/` directly so the converter can preserve
|
||||||
> high-fidelity primitives (icon `<use>` placeholders, image `preserveAspectRatio`
|
> high-fidelity primitives (icon `<use>` placeholders, image `preserveAspectRatio`
|
||||||
> → `srcRect`, rounded rect `rx/ry` → `prstGeom roundRect`). The `svg_output/`
|
> → native picture crop metadata, rounded rect `rx/ry` → `prstGeom roundRect`).
|
||||||
|
> Native raster images are optimized by default before writing `ppt/media`
|
||||||
|
> (`--image-sizing cap`, `--image-max-dimension 2560`, `--image-quality 85`).
|
||||||
|
> This optimization downscales only oversized full source images; it does not
|
||||||
|
> crop pixels out of embedded PPTX media, and it does not reduce a small
|
||||||
|
> placement image merely because it is currently displayed small. Display
|
||||||
|
> cropping remains editable PPT picture-crop metadata. Add `--no-image-optimize`
|
||||||
|
> only when the deck must retain original image bytes. Use
|
||||||
|
> `--image-sizing display --image-scale 2` only for aggressive size reduction.
|
||||||
|
> The `svg_output/`
|
||||||
> snapshot in `backup/<timestamp>/` is always written so the project can be
|
> snapshot in `backup/<timestamp>/` is always written so the project can be
|
||||||
> re-exported from frozen SVG sources without re-running the LLM. The SVG-rendered
|
> re-exported from frozen SVG sources without re-running the LLM. The SVG-rendered
|
||||||
> preview pptx is opt-in via `--svg-snapshot` — live preview already provides the
|
> preview pptx is opt-in via `--svg-snapshot` — live preview already provides the
|
||||||
|
|||||||
@@ -4,17 +4,18 @@
|
|||||||
|
|
||||||
## Format Quick Reference
|
## Format Quick Reference
|
||||||
|
|
||||||
| Format | viewBox | Ratio | Use Case |
|
| ID | Format | Size | viewBox | Ratio | Use Case |
|
||||||
|--------|---------|-------|----------|
|
|----|--------|------|---------|-------|----------|
|
||||||
| PPT 16:9 | `0 0 1280 720` | 16:9 | Business presentations, meetings |
|
| `ppt169` | PPT 16:9 | `1280x720` | `0 0 1280 720` | 16:9 | Business presentations, meetings |
|
||||||
| PPT 4:3 | `0 0 1024 768` | 4:3 | Traditional projectors, academic talks |
|
| `ppt43` | PPT 4:3 | `1024x768` | `0 0 1024 768` | 4:3 | Traditional projectors, academic talks |
|
||||||
| Xiaohongshu (RED) | `0 0 1242 1660` | 3:4 | Image-text sharing, knowledge posts |
|
| `xiaohongshu` | Xiaohongshu (RED) | `1242x1660` | `0 0 1242 1660` | 3:4 | Image-text sharing, knowledge posts |
|
||||||
| WeChat Moments / IG | `0 0 1080 1080` | 1:1 | Square posters, brand showcases |
|
| `moments` | WeChat Moments / IG | `1080x1080` | `0 0 1080 1080` | 1:1 | Square posters, brand showcases |
|
||||||
| Story / TikTok | `0 0 1080 1920` | 9:16 | Vertical stories, short video covers |
|
| `story` | Story / TikTok | `1080x1920` | `0 0 1080 1920` | 9:16 | Vertical stories, short video covers |
|
||||||
| WeChat Article Header | `0 0 900 383` | 2.35:1 | WeChat article cover images |
|
| `wechat` | WeChat Article Header | `900x383` | `0 0 900 383` | 2.35:1 | WeChat article cover images |
|
||||||
| Landscape Banner | `0 0 1920 1080` | 16:9 | Web banners, digital screens |
|
| `banner` | Landscape Banner | `1920x1080` | `0 0 1920 1080` | 16:9 | Web banners, digital screens |
|
||||||
| Portrait Poster | `0 0 1080 1920` | 9:16 | Phone screens, elevator ads |
|
| `a4` | A4 Print | `1240x1754` | `0 0 1240 1754` | 1:sqrt(2) | Print posters, flyers |
|
||||||
| A4 Print | `0 0 1240 1754` | 1:sqrt(2) | Print posters, flyers |
|
|
||||||
|
`ppt169` is the canonical PPT wide-screen canvas in this repo: `1280x720`, not any arbitrary 16:9 size. Same-ratio canvases such as `banner` (`1920x1080`) must be treated as different coordinate systems.
|
||||||
|
|
||||||
## Format Selection Decision Tree
|
## Format Selection Decision Tree
|
||||||
|
|
||||||
|
|||||||
@@ -169,6 +169,7 @@ Before drawing each page, look up its entry in `page_charts` to decide which cha
|
|||||||
- **Template structure**: if templates exist, inherit the visual framework
|
- **Template structure**: if templates exist, inherit the visual framework
|
||||||
- **Main-agent ownership**: SVG generation must run in the main agent (not sub-agents) — pages share upstream context for cross-page visual continuity
|
- **Main-agent ownership**: SVG generation must run in the main agent (not sub-agents) — pages share upstream context for cross-page visual continuity
|
||||||
- **Generation rhythm**: lock global design context first, then generate pages sequentially in one continuous context. No batched groups (e.g., 5 at a time).
|
- **Generation rhythm**: lock global design context first, then generate pages sequentially in one continuous context. No batched groups (e.g., 5 at a time).
|
||||||
|
- **Reference — image-led promotional pages (not a constraint)**: for travel, venue, product-introduction, hospitality, event, real-estate, and brochure-style decks, let images define the page skeleton before placing text. Consult [`image-layout-patterns.md`](image-layout-patterns.md) §Imported Deck Patterns and prefer patterns such as `#74` TOC image-navigation cards, `#75` asymmetric chapter banners, `#77` photo mosaic with a text cell, `#78` ambient banner + evidence photo + text panel, `#79` ribbon-header image cards, and `#80` side hero image + staggered evidence cards before falling back to plain left/right image-text splits.
|
||||||
- **Phased batch generation** (recommended):
|
- **Phased batch generation** (recommended):
|
||||||
1. **Visual Construction Phase**: generate all SVG pages sequentially for visual consistency. Use layout judgment for chart marks during the draft. **MUST embed plot-area markers** per §3.1 below on every chart page — coordinate calibration is a post-generation step (see [`workflows/verify-charts.md`](../workflows/verify-charts.md)) that depends on these markers.
|
1. **Visual Construction Phase**: generate all SVG pages sequentially for visual consistency. Use layout judgment for chart marks during the draft. **MUST embed plot-area markers** per §3.1 below on every chart page — coordinate calibration is a post-generation step (see [`workflows/verify-charts.md`](../workflows/verify-charts.md)) that depends on these markers.
|
||||||
2. **Quality Check Gate**: run `python3 scripts/svg_quality_checker.py <project_path>` on `svg_output/`. Any `error` (banned features, viewBox mismatch, spec_lock drift, non-PPT-safe font, etc.) MUST be fixed on the offending page before proceeding — regenerate and re-check. Address `warning`s when straightforward. Do NOT defer to after `finalize_svg.py` — finalize rewrites SVG and masks some violations.
|
2. **Quality Check Gate**: run `python3 scripts/svg_quality_checker.py <project_path>` on `svg_output/`. Any `error` (banned features, viewBox mismatch, spec_lock drift, non-PPT-safe font, etc.) MUST be fixed on the offending page before proceeding — regenerate and re-check. Address `warning`s when straightforward. Do NOT defer to after `finalize_svg.py` — finalize rewrites SVG and masks some violations.
|
||||||
@@ -355,6 +356,7 @@ Whenever the slide uses an image with `Status: Sourced`, look up the correspondi
|
|||||||
|---|---|
|
|---|---|
|
||||||
| `no-attribution` | Embed the `<image>` element only. **No credit element needed.** |
|
| `no-attribution` | Embed the `<image>` element only. **No credit element needed.** |
|
||||||
| `attribution-required` | Embed the `<image>` element **plus** a small inline `<text>` credit element per the visual spec in [image-searcher.md §7](./image-searcher.md). |
|
| `attribution-required` | Embed the `<image>` element **plus** a small inline `<text>` credit element per the visual spec in [image-searcher.md §7](./image-searcher.md). |
|
||||||
|
| `manual` | Embed the `<image>` element only. **No credit element** — a user-supplied `--from-url` replacement; verifying usage rights / any required credit is the user's responsibility. |
|
||||||
|
|
||||||
The credit text is **not** rendered by post-processing or export — it must be present in the SVG you produce. The shape of the credit element (size, position, color, multi-image source line, hero gradient overlay) is specified in [image-searcher.md §7](./image-searcher.md). Do not invent a different style.
|
The credit text is **not** rendered by post-processing or export — it must be present in the SVG you produce. The shape of the credit element (size, position, color, multi-image source line, hero gradient overlay) is specified in [image-searcher.md §7](./image-searcher.md). Do not invent a different style.
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ Shared baseline for both acquisition paths. Path-specific behavior lives in the
|
|||||||
|
|
||||||
## 1. Trigger Condition
|
## 1. Trigger Condition
|
||||||
|
|
||||||
Active when at least one resource list row has `Acquire Via: ai` or `Acquire Via: web`. Rows with `user` / `formula` / `placeholder` are skipped.
|
Active when at least one resource list row has `Acquire Via: ai` / `web` / `slice`. Rows with `user` / `formula` / `placeholder` are skipped.
|
||||||
|
|
||||||
| Mode | Trigger |
|
| Mode | Trigger |
|
||||||
|---|---|
|
|---|---|
|
||||||
@@ -26,6 +26,7 @@ Defined in `design_spec.md §VIII`. Status enum: see [`svg-image-embedding.md`](
|
|||||||
| cover.png | 1280x720 | Cover background | Background | `ai` | Pending | Modern tech abstract, deep blue gradient #0A2540 |
|
| cover.png | 1280x720 | Cover background | Background | `ai` | Pending | Modern tech abstract, deep blue gradient #0A2540 |
|
||||||
| team.jpg | 800x600 | Team photo | Photography | `web` | Pending | Diverse engineering team in modern office |
|
| team.jpg | 800x600 | Team photo | Photography | `web` | Pending | Diverse engineering team in modern office |
|
||||||
| formula_001.png | 736x168 | Block equation on P03 | Latex Formula | `formula` | Rendered | `E = mc^2` |
|
| formula_001.png | 736x168 | Block equation on P03 | Latex Formula | `formula` | Rendered | `E = mc^2` |
|
||||||
|
| spot_team.png | TBD after slicing | Team spot illustration | Illustration | `slice` | Pending | From `spot_sheet.png` cell 1,1 |
|
||||||
|
|
||||||
**Required per non-skipped row**: `Acquire Via`, `Status`, `Reference`.
|
**Required per non-skipped row**: `Acquire Via`, `Status`, `Reference`.
|
||||||
|
|
||||||
@@ -39,6 +40,7 @@ For each row with `Status: Pending`:
|
|||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| `ai` | [`image-generator.md`](./image-generator.md) | `image_gen.py` | `Generated` |
|
| `ai` | [`image-generator.md`](./image-generator.md) | `image_gen.py` | `Generated` |
|
||||||
| `web` | [`image-searcher.md`](./image-searcher.md) | `image_search.py` | `Sourced` |
|
| `web` | [`image-searcher.md`](./image-searcher.md) | `image_search.py` | `Sourced` |
|
||||||
|
| `slice` | [`image-generator.md`](./image-generator.md) §4.3 | `slice_images.py` after parent AI sheet is `Generated` | `Generated` |
|
||||||
| `user` | — | — | (already `Existing`) |
|
| `user` | — | — | (already `Existing`) |
|
||||||
| `formula` | — | — | (already `Rendered`) |
|
| `formula` | — | — | (already `Rendered`) |
|
||||||
| `placeholder` | — | — | (already `Placeholder`) |
|
| `placeholder` | — | — | (already `Placeholder`) |
|
||||||
@@ -62,9 +64,10 @@ Before processing any row:
|
|||||||
After all rows reach terminal status:
|
After all rows reach terminal status:
|
||||||
|
|
||||||
- Every non-skipped row has a file at `project/images/<filename>`, or is marked `Needs-Manual`
|
- Every non-skipped row has a file at `project/images/<filename>`, or is marked `Needs-Manual`
|
||||||
|
- Every `slice` row has a generated element file, or is marked `Needs-Manual` because its parent sheet is not available
|
||||||
- No `Pending` rows remain
|
- No `Pending` rows remain
|
||||||
- `image_prompts.json` exists when ≥1 ai row processed; every entry has `status ∈ {Generated, Failed, Needs-Manual}` (no `Pending` remaining)
|
- `image_prompts.json` exists when ≥1 ai row processed; every entry has `status ∈ {Generated, Failed, Needs-Manual}` (no `Pending` remaining)
|
||||||
- `image_sources.json` exists when ≥1 web row processed; every entry has `license_tier ∈ {no-attribution, attribution-required}`
|
- `image_sources.json` exists when ≥1 web row processed; every entry has `license_tier ∈ {no-attribution, attribution-required, manual}` (`manual` = a user-supplied `--from-url` replacement)
|
||||||
|
|
||||||
> `Needs-Manual` is a legitimate terminal state for ai rows — Step 7 entry waits for the user to place the file. See [`image-generator.md`](./image-generator.md) §3.2 Offline Manual Mode.
|
> `Needs-Manual` is a legitimate terminal state for ai rows — Step 7 entry waits for the user to place the file. See [`image-generator.md`](./image-generator.md) §3.2 Offline Manual Mode.
|
||||||
|
|
||||||
@@ -77,7 +80,7 @@ After all rows reach terminal status:
|
|||||||
1. Try once
|
1. Try once
|
||||||
2. On recoverable failure (network, no candidates, license rejection, rate limit), retry once with broadened parameters
|
2. On recoverable failure (network, no candidates, license rejection, rate limit), retry once with broadened parameters
|
||||||
3. On second failure, set `Status: Needs-Manual`, log the reason in conversation, continue
|
3. On second failure, set `Status: Needs-Manual`, log the reason in conversation, continue
|
||||||
4. After the phase completes, summarize all `Needs-Manual` rows for the user — list filenames, where prompts live (`images/image_prompts.md` paste-ready blocks for ai rows; refresh via `image_gen.py --render-md` if stale), and where to place generated files (`project/images/<filename>`)
|
4. After the phase completes, summarize all `Needs-Manual` rows for the user — list filenames, where prompts live (`images/image_prompts.md` paste-ready blocks for ai rows; refresh via `image_gen.py --render-md` if stale), and where to place generated files (`project/images/<filename>`). For `slice` rows, list the parent sheet filename and target element names; the user places the sheet, then the agent reruns `slice_images.py`.
|
||||||
|
|
||||||
`Needs-Manual` is also the entry status for **Offline Manual Mode** (no `IMAGE_BACKEND` configured, no host-native image tool in use). Affected ai rows are marked `Needs-Manual` from the start without a failed attempt — see [`image-generator.md`](./image-generator.md) §3.2.
|
`Needs-Manual` is also the entry status for **Offline Manual Mode** (no `IMAGE_BACKEND` configured, no host-native image tool in use). Affected ai rows are marked `Needs-Manual` from the start without a failed attempt — see [`image-generator.md`](./image-generator.md) §3.2.
|
||||||
|
|
||||||
@@ -120,7 +123,7 @@ Executor consumes the resource list plus:
|
|||||||
| Image files | `project/images/*.{jpg,png,webp}` | `<image>` references |
|
| Image files | `project/images/*.{jpg,png,webp}` | `<image>` references |
|
||||||
| Manifest | `project/images/image_sources.json` | `license_tier` per Sourced image |
|
| Manifest | `project/images/image_sources.json` | `license_tier` per Sourced image |
|
||||||
|
|
||||||
Executor does NOT invoke `image_gen.py` / `image_search.py`.
|
Executor does NOT invoke `image_gen.py` / `image_search.py` / `slice_images.py`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -128,8 +131,8 @@ Executor does NOT invoke `image_gen.py` / `image_search.py`.
|
|||||||
|
|
||||||
```markdown
|
```markdown
|
||||||
## ✅ Image Acquisition Phase Complete
|
## ✅ Image Acquisition Phase Complete
|
||||||
- [x] {N} rows processed (`ai`: {a} / `web`: {b})
|
- [x] {N} rows processed (`ai`: {a} / `web`: {b} / `slice`: {s})
|
||||||
- [x] {a} `Generated`, {b} `Sourced`, {c} `Needs-Manual`
|
- [x] {a} `Generated`, {b} `Sourced`, {s} sliced `Generated`, {c} `Needs-Manual`
|
||||||
- [x] image_prompts.json / image_sources.json written
|
- [x] image_prompts.json / image_sources.json written
|
||||||
- [ ] **Next**: Auto-proceed to Executor phase
|
- [ ] **Next**: Auto-proceed to Executor phase
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -2,9 +2,9 @@
|
|||||||
|
|
||||||
# Image_Generator Reference Manual
|
# Image_Generator Reference Manual
|
||||||
|
|
||||||
Role definition for the **AI image generation path**: convert each `Acquire Via: ai` row into an optimized prompt, generate the image, and save it to `project/images/`.
|
Role definition for the **AI image generation path**: convert each `Acquire Via: ai` row into an optimized prompt, generate the image, and save it to `project/images/`; also defines the `slice` derivation path for AI-generated illustration sheets.
|
||||||
|
|
||||||
**Trigger**: resource list rows with `Acquire Via: ai`. The role is loaded only when at least one such row exists.
|
**Trigger**: resource list rows with `Acquire Via: ai` or `slice`. The role is loaded only when at least one such row exists.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -130,9 +130,9 @@ For each `Acquire Via: ai` row in `design_spec.md §VIII`:
|
|||||||
|
|
||||||
The assembled prompt is **one cohesive paragraph**, not a bulleted list of tags. See §4 for the assembly template.
|
The assembled prompt is **one cohesive paragraph**, not a bulleted list of tags. See §4 for the assembly template.
|
||||||
|
|
||||||
### Step 4 — Write the manifest and generate
|
### Step 4 — Write the manifest and execute the confirmed path
|
||||||
|
|
||||||
Write `project/images/image_prompts.json` per §6. Then run `image_gen.py --manifest` (§7 Path A). The CLI iterates `items[]`, writes status back, and re-renders the Markdown sidecar.
|
Write `project/images/image_prompts.json` per §6. Then follow §7 Path Selection. `image_gen.py --manifest` is Path A only; confirmed `host-native` runs the host image tool directly, and confirmed `manual` renders the Markdown sidecar and hands off without API generation.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -240,6 +240,62 @@ Example opening for a triptych hero:
|
|||||||
|
|
||||||
**When uncertain about field conventions**: read `sources/` before drafting the prompt.
|
**When uncertain about field conventions**: read `sources/` before drafting the prompt.
|
||||||
|
|
||||||
|
### 4.3 Illustration sheets — one generation, many spot elements
|
||||||
|
|
||||||
|
When a deck wants several small **spot illustrations** scattered as decorative accessories across pages (the illustration counterpart to icons), do **not** generate them one image per slot — that multiplies generation cost and lets the style drift between calls. Generate **one sheet** that lays out all the elements in a grid, then slice it. One call buys a set of elements with an identical style, palette, and line quality — the same cross-page consistency the deck-wide `deck_rendering` / `deck_palette` lock exists to protect.
|
||||||
|
|
||||||
|
**When to use**: the §VIII image resource plan needs ≥3 small spot illustrations from the same family across the deck. For a single hero/local image, stay with the normal one-row-per-image flow (§4.1). Use sheets only where decorative illustration genuinely lifts the page; an unused element costs nothing, but a deck papered in decoration reads cheap.
|
||||||
|
|
||||||
|
**Hard rule**: a spot sheet is a generation source, not a slide asset. The sheet row is never listed in `spec_lock.md images` and never referenced from SVG. Only the sliced element rows are placed.
|
||||||
|
|
||||||
|
**Sheet prompt convention** (one manifest item, `page_role: local`, `text_policy: none`, `image_size` chosen from final placement size):
|
||||||
|
|
||||||
|
- Choose the sheet `aspect_ratio` and `--grid` from the target element shape. Do not default every sheet to `1:1` + a symmetric grid.
|
||||||
|
- Lay the elements out in an explicit **R×C grid, evenly spaced with clear gutters**, each element **centered in its own cell** and isolated (no element bleeds into a neighbor).
|
||||||
|
- State the intended cell shape in the prompt: compact square object, tall portrait element, or wide landscape vignette. Do not let the model shrink every subject into a centered square sticker.
|
||||||
|
- One **flat single-color background** across the whole sheet, set to the deck's background/secondary HEX — this is what lets the slicer key it out cleanly and lets the cut element sit on the slide without a visible box.
|
||||||
|
- Shared `deck_rendering` + `deck_palette` as always. NO text, labels, or numbers anywhere (§5.1, §5.3).
|
||||||
|
|
||||||
|
**Cell geometry is designed, not assumed.** `slice_images.py --grid RxC` cuts rows first and columns second. The cell ratio is:
|
||||||
|
|
||||||
|
```text
|
||||||
|
cell_ratio = sheet_ratio * rows / cols
|
||||||
|
```
|
||||||
|
|
||||||
|
Use that deliberately. On a wide sheet (`16:9`, `21:9`, `4:1`, `8:1`), `1xN` makes each cell tall/portrait because the width is divided by `N` while height is kept; `Nx1` makes each cell wide/landscape because height is divided by `N` while width is kept. A designed `MxN` grid is also valid when the resulting cell ratio matches the intended placements.
|
||||||
|
|
||||||
|
| Target spot shape | Sheet plan | Slice grid |
|
||||||
|
|---|---|---|
|
||||||
|
| Compact objects / badges | `1:1` sheet | `2x2`, `2x3`, or `3x3` |
|
||||||
|
| Tall side accents / upright objects | wide or square sheet | `1xN`, or any `MxN` whose cells are portrait |
|
||||||
|
| Wide banners / horizontal vignettes | wide sheet | `Nx1`, or any `MxN` whose cells are landscape |
|
||||||
|
|
||||||
|
If one deck needs mixed shapes, create separate sheets per shape family unless one carefully designed grid gives every element enough room in its own cell. Keep the visual family consistent through the same `deck_rendering` and `deck_palette`, not by forcing all cells into one square sheet.
|
||||||
|
|
||||||
|
**Resource contract — the sheet and its elements are different row kinds.** A sliced element can only be placed if it exists as a resource the Executor is allowed to reference (`spec_lock.md images`). So §VIII carries two row kinds (full rules: [`design_spec_reference.md`](../templates/design_spec_reference.md) §VIII):
|
||||||
|
|
||||||
|
- **Sheet row** — `Acquire Via: ai`, `Type: Illustration Sheet`, the intent prompt, named as the slice source with its intended cell shape and placement purpose (`Reference: landscape footer-vignette spot set`). It is generated in Step 5 but **never placed on a slide** — keep it **out of** `spec_lock.md images`. Image_Generator resolves the exact `aspect_ratio`, grid, and slice command from this intent.
|
||||||
|
- **Element rows** — one per used element, `Acquire Via: slice`, filename matching a `--names` output, `Reference` naming the parent sheet + cell/element. These **are** placed — list every one in `spec_lock.md images`, usually with ` | no-crop` (a tight-trimmed transparent spot should be fit, not cover-cropped). Their dimensions are filled in after slicing (Step 5 re-runs `analyze_images.py`). **Set each element row's Layout pattern from the decorative-cutout family, never a boxed container** — see Placement below.
|
||||||
|
|
||||||
|
For traceability, add optional `slice_grid` and `slice_names` fields to the sheet item in `image_prompts.json` after choosing the geometry. `image_gen.py` ignores unknown item fields but preserves them in the manifest, so these fields document the exact command that must be used for slicing.
|
||||||
|
|
||||||
|
**Slice** with [`slice_images.py`](../scripts/slice_images.py) — cells are cut row-major into individual files in `images/`. With `--alpha` they are **transparent cutout stickers** (image-layout-patterns `#63`), not rectangular content images. Recommended flags: `--names` (semantic per-cell filenames matching the element rows; the count **must** equal `rows*cols`), `--trim` (tight-crop each cell so imprecise placement inside a cell doesn't leave lopsided margins), `--alpha` (knock the flat background out to transparency so an element drops onto any slide color):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 scripts/slice_images.py <project>/images/illus_sheet.png --grid 2x3 \
|
||||||
|
--names team,product,customer,growth,risk,vision --trim --alpha
|
||||||
|
```
|
||||||
|
|
||||||
|
**Three constraints that decide whether it looks good**:
|
||||||
|
|
||||||
|
1. **Flat background, matched to the slide.** `image_gen.py` has no transparent-background mode, so the cut element carries whatever was behind it. A flat sheet background (= deck background HEX) is what `--alpha` keys out and what makes non-keyed pieces blend.
|
||||||
|
2. **Clean grid, or it cuts ugly.** The model will not place every element perfectly; force a clear grid with gutters, and generate **a few sheets** (re-roll the same prompt) to pick the cleanest-laid-out one before slicing. State the exact row/column structure and cell shape so the model does not invent a square matrix. `--trim` absorbs the rest.
|
||||||
|
3. **Generate only as large as needed.** Each cell is a fraction of the sheet. Pick the smallest sheet size that keeps each sliced cell at least **1.5-2x** the intended display size. `1K` is usually enough for small 80-160px decorative spots; use `2K` for medium 180-320px placements; reserve `4K` for large, cropped, or potentially enlarged elements.
|
||||||
|
|
||||||
|
**Placement — these are decorative accessories, not boxed pictures.** A transparent spot wasted in a centered rectangle looks cheaper than no spot at all. Each element row's Layout pattern comes from the decorative-cutout family in [`image-layout-patterns.md`](./image-layout-patterns.md): `#63` sticker/cutout, `#4` bleed off the canvas edge, `#58` corner fragment, `#66` fade into the background, `#69` slight editorial rotation, `#49` asymmetric cluster. Push spots to the margins, let them run off-edge or sit behind/beside text, vary size and angle across pages, and overlap the content rather than reserving a tidy tile for them. Anchor most pages on one primary element and let the rest stay small ([primary-per-page](./strategist.md) §h) — scattered same-weight tiles are exactly the generic look to avoid.
|
||||||
|
|
||||||
|
**Through-line — one family, many roles.** A spot sheet pays off more when the same motif family also drives the deck's cover and section dividers, so the deck reads as one designed system rather than a hero plus unrelated doodles. Because the slicer cuts a **uniform grid**, a large cover / divider anchor is **not** a giant cell in the spot sheet — generate it as its own `page_role: hero_page` image (§4.1 primitives) that shares this sheet's `deck_rendering`, `deck_palette`, and subject world. In §VIII the hero_page anchor row(s) and the `slice` spot rows then belong to one visual family (name the shared subject world in each `Reference`), differing only in scale and role. Plan this only when the deck leans into illustration — never a per-section quota; the planning rule lives in [strategist.md](./strategist.md) (deck illustration motif).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 5. Global Hard Rules
|
## 5. Global Hard Rules
|
||||||
@@ -395,6 +451,8 @@ Write `project/images/image_prompts.json` with this shape:
|
|||||||
| `items[].prompt` | yes | §4 assembly | The full assembled paragraph |
|
| `items[].prompt` | yes | §4 assembly | The full assembled paragraph |
|
||||||
| `items[].image_size` | no | Container sizing | `512px` / `1K` / `2K` / `4K` |
|
| `items[].image_size` | no | Container sizing | `512px` / `1K` / `2K` / `4K` |
|
||||||
| `items[].alt_text` | no | Accessibility | Short caption |
|
| `items[].alt_text` | no | Accessibility | Short caption |
|
||||||
|
| `items[].slice_grid` | no | §4.3 sheet geometry | Illustration sheet only; exact `RxC` grid to pass to `slice_images.py --grid` |
|
||||||
|
| `items[].slice_names` | no | §4.3 sheet geometry | Illustration sheet only; semantic filenames to pass to `slice_images.py --names` |
|
||||||
| `items[].status` | yes | CLI manages | `Pending` initially; CLI updates to `Generated` / `Failed` / `Needs-Manual` |
|
| `items[].status` | yes | CLI manages | `Pending` initially; CLI updates to `Generated` / `Failed` / `Needs-Manual` |
|
||||||
|
|
||||||
> **Back-compat for legacy `type` values**: existing manifests using `background` / `hero` / `portrait` / `typography` (the four removed pseudo-types) remain readable. Read them as: `background` → `page_role: hero_page` + no type; `hero` → `page_role: hero_page` + no type (use §4.1 Primitive A in prompt); `portrait` → `page_role: local` + no type (use §4.1 Primitive B); `typography` → `page_role: hero_page` + `text_policy: embedded` + no type (use §4.1 Primitive C). New manifests should follow the rule above (omit `type` when `page_role: hero_page`).
|
> **Back-compat for legacy `type` values**: existing manifests using `background` / `hero` / `portrait` / `typography` (the four removed pseudo-types) remain readable. Read them as: `background` → `page_role: hero_page` + no type; `hero` → `page_role: hero_page` + no type (use §4.1 Primitive A in prompt); `portrait` → `page_role: local` + no type (use §4.1 Primitive B); `typography` → `page_role: hero_page` + `text_policy: embedded` + no type (use §4.1 Primitive C). New manifests should follow the rule above (omit `type` when `page_role: hero_page`).
|
||||||
@@ -405,7 +463,7 @@ Write `project/images/image_prompts.json` with this shape:
|
|||||||
|
|
||||||
## 7. Generation Execution
|
## 7. Generation Execution
|
||||||
|
|
||||||
> Prerequisite: §3 Steps 1-3 complete; `images/image_prompts.json` exists and validates.
|
> Prerequisite: §3 Steps 1-3 complete; `images/image_prompts.json` exists and validates. The manifest is the shared audit/source contract for all modes. It does **not** imply that `image_gen.py --manifest` should run; that command is Path A only.
|
||||||
|
|
||||||
### Path Selection (Deterministic)
|
### Path Selection (Deterministic)
|
||||||
|
|
||||||
@@ -421,8 +479,8 @@ C (AI-generated) supports three implementation modes sharing one `image_prompts.
|
|||||||
|
|
||||||
0. **Confirmed override (wins)** — honor the confirmed image source. The **chat choice is canonical**; the Confirm UI is only a convenience surface that, when used, records the same choice to `<project>/confirm_ui/result.json` as `image_ai_path` (so there is no `result.json` on the chat path — read the choice from the conversation). From either channel, if the choice is set and not `auto`, honor it directly, **even when it contradicts `IMAGE_BACKEND`**:
|
0. **Confirmed override (wins)** — honor the confirmed image source. The **chat choice is canonical**; the Confirm UI is only a convenience surface that, when used, records the same choice to `<project>/confirm_ui/result.json` as `image_ai_path` (so there is no `result.json` on the chat path — read the choice from the conversation). From either channel, if the choice is set and not `auto`, honor it directly, **even when it contradicts `IMAGE_BACKEND`**:
|
||||||
- `api` → **Path A** (`image_gen.py --manifest`).
|
- `api` → **Path A** (`image_gen.py --manifest`).
|
||||||
- `host-native` → **Path B** (host's native image tool) — skip A *even if `IMAGE_BACKEND` is configured*.
|
- `host-native` → **Path B** (host's native image tool) — skip A and do **not** run `image_gen.py --manifest`, *even if `IMAGE_BACKEND` is configured*.
|
||||||
- `manual` → **Offline Manual** (write prompts, hand off).
|
- `manual` → **Offline Manual** (write prompts, render the Markdown sidecar, hand off; do **not** run `image_gen.py --manifest`).
|
||||||
("use Codex's image tool" / "走接口生成" in chat = `host-native` / `api`.) If the chosen path turns out unavailable (e.g. `host-native` but the host has no image tool), fall through along the chain below from that point. Only when no source named a path (chat silent, and `image_ai_path` `auto` / absent) does the automatic chain decide.
|
("use Codex's image tool" / "走接口生成" in chat = `host-native` / `api`.) If the chosen path turns out unavailable (e.g. `host-native` but the host has no image tool), fall through along the chain below from that point. Only when no source named a path (chat silent, and `image_ai_path` `auto` / absent) does the automatic chain decide.
|
||||||
1. **Try Path A** — if `IMAGE_BACKEND` is configured (env or `.env`), run `image_gen.py --manifest`. If it fails twice in a row, fall to Path B.
|
1. **Try Path A** — if `IMAGE_BACKEND` is configured (env or `.env`), run `image_gen.py --manifest`. If it fails twice in a row, fall to Path B.
|
||||||
2. **Try Path B** — if `IMAGE_BACKEND` was not configured (A skipped), or A failed, and the host has a native image tool (Codex / Antigravity / Claude Code / similar), the agent invokes the host's image capability directly.
|
2. **Try Path B** — if `IMAGE_BACKEND` was not configured (A skipped), or A failed, and the host has a native image tool (Codex / Antigravity / Claude Code / similar), the agent invokes the host's image capability directly.
|
||||||
@@ -496,6 +554,8 @@ Precedence:
|
|||||||
Triggered automatically when `IMAGE_BACKEND` is not configured (or Path A fails) **and** the host provides a native image generation tool (Codex, Antigravity, Claude Code's image tool, and similar). No user prompting required — the agent detects the host capability and proceeds. The user may also explicitly name this path ("use Codex's image tool") to force it even when `IMAGE_BACKEND` is configured.
|
Triggered automatically when `IMAGE_BACKEND` is not configured (or Path A fails) **and** the host provides a native image generation tool (Codex, Antigravity, Claude Code's image tool, and similar). No user prompting required — the agent detects the host capability and proceeds. The user may also explicitly name this path ("use Codex's image tool") to force it even when `IMAGE_BACKEND` is configured.
|
||||||
|
|
||||||
- Agent invokes the host's native image tool directly; prompts come from `items[].prompt`
|
- Agent invokes the host's native image tool directly; prompts come from `items[].prompt`
|
||||||
|
- Do **not** run `image_gen.py --manifest` in Path B. That command is Path A and may use configured API/proxy backends even when the user confirmed host-native.
|
||||||
|
- Still run `python3 scripts/image_gen.py --render-md project/images/image_prompts.json` so the human-readable sidecar exists without touching any backend.
|
||||||
- **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.
|
- **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
|
- Outputs **must** land at `project/images/<filename-from-resource-list>` with dimensions matching the Image Resource List
|
||||||
- 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)
|
- 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)
|
||||||
@@ -577,7 +637,7 @@ Diagnose the failure category, adjust the **one specific dimension** responsible
|
|||||||
**Variant workflow**:
|
**Variant workflow**:
|
||||||
|
|
||||||
1. Set the unsatisfactory item's `status` back to `Pending` and update its `prompt` in place
|
1. Set the unsatisfactory item's `status` back to `Pending` and update its `prompt` in place
|
||||||
2. Re-run `image_gen.py --manifest` — only that item is re-processed
|
2. Re-run the same confirmed path used for the original item: Path A may re-run `image_gen.py --manifest` (only that item is re-processed); Path B uses the host-native tool again for that item; Offline Manual re-renders the sidecar and hands off
|
||||||
3. To try multiple stylistic approaches, append additional items with distinct filenames (e.g. `cover_bg_v2.png`) rather than overwriting
|
3. To try multiple stylistic approaches, append additional items with distinct filenames (e.g. `cover_bg_v2.png`) rather than overwriting
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
+39
-2
@@ -4,7 +4,7 @@ A vocabulary registry of ways images can be placed on a slide. The point of this
|
|||||||
|
|
||||||
Every entry has a name plus a short technical hint. Common techniques get a single line. Less obvious or easily forgotten techniques get a short paragraph — not a full tutorial, but enough that a model unfamiliar with the project can implement it without guessing. This is a registry, not a teaching document; no use-case prescriptions, no decision tables.
|
Every entry has a name plus a short technical hint. Common techniques get a single line. Less obvious or easily forgotten techniques get a short paragraph — not a full tutorial, but enough that a model unfamiliar with the project can implement it without guessing. This is a registry, not a teaching document; no use-case prescriptions, no decision tables.
|
||||||
|
|
||||||
> **Numbers are stable identifiers, not sequence.** The file is split into **Part 1 — Primary Structures** (#1–#19, #38–#56) and **Part 2 — Modifier Layers** (#20–#37, #57–#72). Numbers jump within each Part because Primary structures were grouped first; existing references to `#38`, `#48`, etc. anywhere in the project still resolve correctly.
|
> **Numbers are stable identifiers, not sequence.** The file is split into **Part 1 — Primary Structures** (#1–#19, #38–#56, #73–#81) and **Part 2 — Modifier Layers** (#20–#37, #57–#72). Numbers jump within each Part because Primary structures were grouped first; existing references to `#38`, `#48`, etc. anywhere in the project still resolve correctly.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -108,6 +108,28 @@ This is the family that opens up the largest design space and the one AI is most
|
|||||||
|
|
||||||
56. **Image triptych** — three independent `<image>` side-by-side, equal widths or 2:1:2 etc. (distinct from #26 baked-in triptych, where the three scenes are inside one image file).
|
56. **Image triptych** — three independent `<image>` side-by-side, equal widths or 2:1:2 etc. (distinct from #26 baked-in triptych, where the three scenes are inside one image file).
|
||||||
|
|
||||||
|
## Imported Deck Patterns (image-led promotional pages)
|
||||||
|
|
||||||
|
These patterns come from polished image-text decks where photos define the slide skeleton instead of sitting inside generic cards. Treat them as layout vocabulary for travel, product, venue, hospitality, real-estate, event, and brochure-style decks.
|
||||||
|
|
||||||
|
73. **Full-bleed poster image + side title stack** — full-slide image, title stack anchored to the left or lower-left third, no title card. Use native text directly over a calm image region with a subtle scrim only when needed. The title stack can mix huge Latin / display text, local-language title, and small brand/date line.
|
||||||
|
|
||||||
|
74. **TOC image-navigation cards** — 3–5 equal vertical image cards across the page. Each card gets a same-color translucent overlay, large chapter number, chapter title, and one-line summary. The TOC becomes a visual preview of the deck, not a text list.
|
||||||
|
|
||||||
|
75. **Asymmetric dual-image chapter banner** — two images occupy the upper half: one smaller panel and one wide dominant panel, usually left-small / right-wide. The chapter title lives in the lower half with an oversized section number as a background anchor.
|
||||||
|
|
||||||
|
76. **Mid-page image belt with native text inset** — a wide image strip cuts through the middle 45–60% of the slide. Put the key text inside a darker or calmer region of the strip, using native text and a small label, while the top area carries the page heading.
|
||||||
|
|
||||||
|
77. **Photo mosaic with a text cell** — an irregular grid where one grid cell is deliberately reserved for copy and the other cells are photos. The missing photo cell creates hierarchy; do not fill every grid slot just because a grid exists.
|
||||||
|
|
||||||
|
78. **Ambient banner + evidence photo + text panel** — one wide atmospheric image spans the upper portion, a smaller concrete/evidence photo sits below, and a solid color or tinted panel carries the copy on the side. Useful when one image sets mood and another proves the product/place.
|
||||||
|
|
||||||
|
79. **Ribbon-header image cards** — 3 columns, each with a colored ribbon or chevron title above the image, image in the middle, prose below. The ribbon carries category identity; the photo carries evidence; the body copy stays editable.
|
||||||
|
|
||||||
|
80. **Side hero image + staggered evidence cards** — one full-height or near-full-height image occupies a side column. The opposite side uses 2–4 smaller evidence cards placed at staggered vertical positions instead of a rigid grid, producing movement and editorial rhythm.
|
||||||
|
|
||||||
|
81. **Illustration-as-layout field** — a large decorative vector or cutout illustration behaves like an image region: it sets the page's spatial rhythm, while text blocks sit around or inside its calm areas. Use this when a photo would be too literal but the page still needs image-scale visual mass.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
# Part 2 — Modifier Layers
|
# Part 2 — Modifier Layers
|
||||||
@@ -170,7 +192,7 @@ Stack any of these freely on top of a Primary structure. Multiple Modifiers per
|
|||||||
|
|
||||||
62. **Same image, two references — full view + zoom-callout** — reference the same image file twice in two `<image>` elements: one shows the full scene at normal size; the second uses `clipPath` (circle or rectangle) plus a larger display size to "zoom into" a sub-region. Connect them with a bezier `<path>` ending in `marker-end`; ring the zoom with a `<circle stroke>` so it reads as a magnifying lens. No special asset needed — the zoom effect comes from same-source-different-display.
|
62. **Same image, two references — full view + zoom-callout** — reference the same image file twice in two `<image>` elements: one shows the full scene at normal size; the second uses `clipPath` (circle or rectangle) plus a larger display size to "zoom into" a sub-region. Connect them with a bezier `<path>` ending in `marker-end`; ring the zoom with a `<circle stroke>` so it reads as a magnifying lens. No special asset needed — the zoom effect comes from same-source-different-display.
|
||||||
|
|
||||||
63. **Transparent PNG sticker / cutout** — an RGBA PNG (with alpha channel) placed via standard `<image>` — no `clipPath` required, the transparency lives in the file itself. Useful for subjects that should not appear inside a rectangular frame (people cutouts, product shots, decorative motifs floating over backgrounds). Producing transparent PNGs is **not** a standard ppt-master pipeline step — three paths: (a) AI backend that supports transparent output natively, (b) generate a chroma-key (solid green background) image then strip the green with a separate tool, (c) user-supplied transparent asset. SVG-side usage is trivial; asset preparation is the work.
|
63. **Transparent PNG sticker / cutout** — an RGBA PNG (with alpha channel) placed via standard `<image>` — no `clipPath` required, the transparency lives in the file itself. Useful for subjects that should not appear inside a rectangular frame (people cutouts, product shots, decorative motifs floating over backgrounds). **Spot illustrations from the sheet→slice pipeline land here**: `slice_images.py --alpha` outputs transparent cutouts (see [image-generator.md](./image-generator.md) §4.3), so a sliced element is a ready sticker — never box it in a rectangle. Other sources of transparent PNGs: (a) an AI backend with native transparent output, (b) a chroma-key image stripped separately, (c) a user-supplied asset. A cutout begs for the decorative-placement family — combine with `#4` (bleed off the edge), `#58` (corner fragment), `#66` (fade into background), `#69` (slight rotation), or `#49` (asymmetric collage); the worst thing to do with a transparent spot is center it in a tidy box.
|
||||||
|
|
||||||
64. **Image with embedded text rendered by the AI** — text becomes part of the artwork: decorative lettering, designed title, hand-lettered keyword. Prompt with explicit text content — name the exact characters literally. Use for text that is part of the artwork and will not change. Anything that must be correct or editable goes in the SVG `<text>` layer (#65).
|
64. **Image with embedded text rendered by the AI** — text becomes part of the artwork: decorative lettering, designed title, hand-lettered keyword. Prompt with explicit text content — name the exact characters literally. Use for text that is part of the artwork and will not change. Anything that must be correct or editable goes in the SVG `<text>` layer (#65).
|
||||||
|
|
||||||
@@ -208,8 +230,23 @@ A page is built by layering. Pick one or more **Primary Structures** (Part 1) as
|
|||||||
|
|
||||||
Combine freely. The "AI-default" failure mode is the opposite: defaulting to bare #2 / #3 (left/right split) with no Modifier at all.
|
Combine freely. The "AI-default" failure mode is the opposite: defaulting to bare #2 / #3 (left/right split) with no Modifier at all.
|
||||||
|
|
||||||
|
**Reference — image-led promotional deck moves (not a constraint)**:
|
||||||
|
|
||||||
|
| Page intent | Pattern candidates |
|
||||||
|
|---|---|
|
||||||
|
| Cover / ending with strong atmosphere | `#73` + `#27` / `#30` only if contrast needs it |
|
||||||
|
| Visual table of contents | `#74` + `#30` / `#31` |
|
||||||
|
| Chapter divider | `#75` |
|
||||||
|
| Venue / destination overview | `#76` or `#78` |
|
||||||
|
| Many product/place photos | `#77` or `#50` when equality is the message |
|
||||||
|
| Service / feature comparison | `#79` |
|
||||||
|
| Benefits with one dominant proof image | `#80` |
|
||||||
|
| Light promotional page without photos | `#81` |
|
||||||
|
|
||||||
**Skip-detection signal** — if every page's `Layout pattern` column resolves to bare #2 / #3 / #5 / #6 with no Modifier ids, the catalog was not consulted. Re-read and reconsider.
|
**Skip-detection signal** — if every page's `Layout pattern` column resolves to bare #2 / #3 / #5 / #6 with no Modifier ids, the catalog was not consulted. Re-read and reconsider.
|
||||||
|
|
||||||
|
**Cross-page through-line (recurring motif).** The patterns above are per-page, but a deck reads as *designed* when one illustration motif family recurs across pages — a cover anchor, section dividers repeating the motif for chapter identity (`#75`), and small `#63` spots from the same family threaded through the body. Keep them one family (shared rendering / palette / subject world), vary scale and placement, and never let the recurrence harden into a per-section quota. Planning lives in [strategist.md](./strategist.md) (deck illustration motif); generation mechanics split by role — hero / divider anchors: [image-generator.md](./image-generator.md) §4.1 primitives; body spot sheets: §4.3.
|
||||||
|
|
||||||
## Hard Constraints
|
## Hard Constraints
|
||||||
|
|
||||||
- Long body copy, data points, numeric labels, and Chinese text always go in the SVG layer — never baked into the image.
|
- Long body copy, data points, numeric labels, and Chinese text always go in the SVG layer — never baked into the image.
|
||||||
|
|||||||
@@ -10,12 +10,13 @@ Role definition for the **web image acquisition path**: translate Strategist int
|
|||||||
|
|
||||||
## 1. License Tier Discipline
|
## 1. License Tier Discipline
|
||||||
|
|
||||||
Every accepted image is classified into one of two tiers. Anything else is rejected outright.
|
Every **provider-sourced** image is classified into one of two tiers; anything else is rejected outright. A third tier, `manual`, exists **only** for a user-supplied [`--from-url`](#5-running-image_searchpy) replacement — it is never the result of a provider search accepting an unknown license.
|
||||||
|
|
||||||
| Tier | Licenses | On-slide attribution |
|
| Tier | Licenses | On-slide attribution |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `no-attribution` | CC0, Public Domain, Pexels License, Pixabay Content License | None |
|
| `no-attribution` | CC0, Public Domain, Pexels License, Pixabay Content License | None |
|
||||||
| `attribution-required` | CC BY, CC BY-SA | Inline credit `<text>` on the slide |
|
| `attribution-required` | CC BY, CC BY-SA | Inline credit `<text>` on the slide |
|
||||||
|
| `manual` | User-supplied via `--from-url` (license unverified) | None — verifying rights / any credit is the user's responsibility |
|
||||||
|
|
||||||
**Forbidden — auto-rejected licenses**:
|
**Forbidden — auto-rejected licenses**:
|
||||||
|
|
||||||
@@ -84,6 +85,10 @@ Then `build_query_progression` tries: original → simplified (4 words) → simp
|
|||||||
| Quality cues | **DO NOT ADD QUALITY CUES** like `professional editorial photography` or `clean composition`. These APIs use exact keyword matching; adding long adjectives will result in 0 matches. |
|
| Quality cues | **DO NOT ADD QUALITY CUES** like `professional editorial photography` or `clean composition`. These APIs use exact keyword matching; adding long adjectives will result in 0 matches. |
|
||||||
| Language | For Chinese landmarks: use precise Chinese names (e.g., `磁器口古镇`) if specifically targeting `--provider wikimedia`. For general stock providers (Pexels/Pixabay), use simple English nouns (e.g., `Chongqing Jiefangbei`); do NOT use complex Chinese sentences or overly long English descriptive strings which fail on these platforms. |
|
| Language | For Chinese landmarks: use precise Chinese names (e.g., `磁器口古镇`) if specifically targeting `--provider wikimedia`. For general stock providers (Pexels/Pixabay), use simple English nouns (e.g., `Chongqing Jiefangbei`); do NOT use complex Chinese sentences or overly long English descriptive strings which fail on these platforms. |
|
||||||
|
|
||||||
|
When the subject is an exact entity (landmark / person / company / product / venue), write `required_terms` at the same time you write the row's `query`. Use one required group per identity anchor and `|` for aliases / translations, e.g. `["Chongqing|重庆", "Jiefangbei|解放碑|Liberation Monument"]`. This keeps the query short for provider search while preventing metadata-ranked wrong entities from being accepted.
|
||||||
|
|
||||||
|
Do **not** loosen `required_terms` to generic category words just to improve coverage. Terms like `canyon`, `grand canyon`, `stone pillar`, `ground fissure`, `ancient town`, `bridge`, `temple`, or `village` belong in the search query, not as the only identity gate. For small / Chinese-local attractions, the correct failure mode is `Needs-Manual` or a user-provided `--from-url`, not a visually plausible image of the wrong place.
|
||||||
|
|
||||||
**Forbidden — web negative prompts**: `not tourist snapshot`, `no amateur photo`, `avoid low quality`.
|
**Forbidden — web negative prompts**: `not tourist snapshot`, `no amateur photo`, `avoid low quality`.
|
||||||
|
|
||||||
> Note: Keyword APIs search negative words literally.
|
> Note: Keyword APIs search negative words literally.
|
||||||
@@ -117,7 +122,12 @@ python3 scripts/image_search.py "<query>" \
|
|||||||
| `--orientation` | no | `any` | `any` / `landscape` / `portrait` / `square` |
|
| `--orientation` | no | `any` | `any` / `landscape` / `portrait` / `square` |
|
||||||
| `--provider` | no | (chain) | Pin one provider |
|
| `--provider` | no | (chain) | Pin one provider |
|
||||||
| `--strict-no-attribution` | no | off | Restrict to no-attribution licenses; refuse CC BY / CC BY-SA |
|
| `--strict-no-attribution` | no | off | Restrict to no-attribution licenses; refuse CC BY / CC BY-SA |
|
||||||
|
| `--require-terms` | no | — | Entity-safety gate for exact subjects. Repeatable; comma separates required groups; `A|B` means aliases within one group. Example: `--require-terms Chongqing --require-terms "Jiefangbei|Liberation Monument"` |
|
||||||
| `--manifest` | no | (default) | Override manifest path |
|
| `--manifest` | no | (default) | Override manifest path |
|
||||||
|
| `--save-candidates` | no | off | Escalation only: also keep a review pool in `candidates/<stem>/`. Default downloads just the best match (+ a review copy) |
|
||||||
|
| `--max-candidates` | no | `4` | Pool size when `--save-candidates` is set |
|
||||||
|
| `--promote` | no | — | Promote a reviewed candidate to the target filename, e.g. `--promote candidate_03.jpg --filename team.jpg -o <dir>` |
|
||||||
|
| `--from-url` | no | — | Manual replace: download a user-supplied image URL into `--filename` (recorded `license_tier: manual`); works without a multimodal model |
|
||||||
|
|
||||||
### Batch mode (≥ 2 web rows) — preferred
|
### Batch mode (≥ 2 web rows) — preferred
|
||||||
|
|
||||||
@@ -134,23 +144,81 @@ python3 scripts/image_search.py --batch <project_path>/images/image_queries.json
|
|||||||
{
|
{
|
||||||
"items": [
|
"items": [
|
||||||
{
|
{
|
||||||
"filename": "team.jpg",
|
"filename": "jiefangbei.jpg",
|
||||||
"query": "executive boardroom meeting",
|
"query": "Jiefangbei Chongqing downtown monument",
|
||||||
"slide": "03_team",
|
"slide": "03_landmark",
|
||||||
"purpose": "background",
|
"purpose": "exact landmark photo",
|
||||||
"orientation": "landscape",
|
"orientation": "landscape",
|
||||||
|
"required_terms": ["Chongqing", "Jiefangbei|Liberation Monument"],
|
||||||
"status": "Pending"
|
"status": "Pending"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Required per item: `filename`, `query`, `status` (`Pending`). Optional per-item overrides: `slide`, `purpose`, `orientation`, `provider`, `strict_no_attribution`, `min_width`, `min_height`.
|
Required per item: `filename`, `query`, `status` (`Pending`). Optional per-item overrides: `slide`, `purpose`, `orientation`, `provider`, `strict_no_attribution`, `min_width`, `min_height`, `required_terms`.
|
||||||
|
|
||||||
|
Use `required_terms` for **exact-entity images**: landmarks, people, companies, products, venues, named artworks, and named institutions. Each list item is required; alternatives inside one item use `|`. Example for a Chongqing landmark: `["Chongqing|重庆", "Jiefangbei|解放碑|Liberation Monument"]`. This is a metadata gate: candidates whose title / author / source URL do not satisfy every group are rejected before ranking, so a visually polished but wrong Rome / Hoi An image cannot win a Chongqing landmark row. Do **not** use `required_terms` for generic mood / background rows such as "modern city skyline" or "team collaboration".
|
||||||
|
|
||||||
|
For less-covered local attractions, keep the strict identity gate rather than progressively deleting location anchors or replacing proper names with category words. If strict metadata cannot prove the entity, mark the row `Needs-Manual` and use the manual URL path when the user supplies a confirmed source.
|
||||||
|
|
||||||
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.
|
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.
|
**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.
|
||||||
|
|
||||||
|
### Ranking model
|
||||||
|
|
||||||
|
`image_search.py` ranks provider metadata, not pixels. The order is deliberately conservative:
|
||||||
|
|
||||||
|
1. hard reject: invalid license, zero query relevance, or any missing `required_terms`;
|
||||||
|
2. identity priority: every required term group must match; candidates whose title also contains the required entity terms get an additional boost over candidates that only match via URL;
|
||||||
|
3. query relevance: concrete query tokens dominate generic visual words like "photo", "high quality", "background";
|
||||||
|
4. layout fit: requested orientation helps; mismatched orientation is a small penalty, not a hard reject;
|
||||||
|
5. license / size tie-breakers: no-attribution is a small bonus; pixel count is capped so a huge but weakly relevant image cannot outrank a smaller accurate image.
|
||||||
|
|
||||||
|
Do not tune this into a visual taste engine. The scorer prevents obvious metadata failures and produces a reviewable best match; the `.review` copy still decides whether the image is visually fit for the slide.
|
||||||
|
|
||||||
|
### Suitability review — with or without a multimodal model
|
||||||
|
|
||||||
|
A metadata-ranked top hit is *downloadable and token-relevant*, not necessarily *visually suitable* — `score_candidate` never sees pixels. So a web best match should be reviewed before it is trusted, by whichever reviewer is available:
|
||||||
|
|
||||||
|
- **Multimodal model**: each download writes a downscaled review copy to `images/.review/<stem>.jpg` (the placed asset stays full-resolution; the bounded copy just keeps a very large original safe and quick to open). Read it and judge fit — subject, mood, quality, not just topic overlap.
|
||||||
|
- **Non-multimodal model (no vision)**: do **not** pretend to confirm. Hand off to a human — surface each web image's `source_page_url` from `image_sources.json` (live preview also shows the placed result) and let the user judge.
|
||||||
|
|
||||||
|
For exact-entity rows, suitability has two gates: `required_terms` first enforces metadata identity, then the `.review` image confirms the pixels actually show the right subject well enough. A row can still be rejected after passing `required_terms` if the visual is weak, cropped badly, or only tangentially shows the subject.
|
||||||
|
|
||||||
|
Never treat a generic `required_terms` pass as acceptance. For example, matching `Ground Fissure` can return an unrelated transit station named Yunlong, and matching `stone pillar` can return a different scenic area. If the proper name / geography cannot be retained, stop at `Needs-Manual`.
|
||||||
|
|
||||||
|
**Replacement ladder when a best match is not right** (any reviewer):
|
||||||
|
|
||||||
|
1. refine the query and re-run that row once;
|
||||||
|
2. **manual URL replace (universal, model-agnostic)** — the user finds a better image anywhere and gives its URL; download and swap it in:
|
||||||
|
```bash
|
||||||
|
python3 scripts/image_search.py --from-url <image-url> --filename <name>.jpg -o <project_path>/images
|
||||||
|
```
|
||||||
|
Recorded with `license_tier: manual` — verifying usage rights is the user's call. Human replacement is a legitimate outcome, not a failure. It updates the image and `image_sources.json` but does **not** rewrite `image_queries.json`, so a row fixed this way may still read `Needs-Manual` in the batch manifest — harmless: the file is present, so export proceeds (executor-base §6.1);
|
||||||
|
3. (opt-in) `--save-candidates` to pull auto-alternatives with their own `source_page_url`s, then `--promote` the best (below);
|
||||||
|
4. if nothing fits, mark the row `Needs-Manual`.
|
||||||
|
|
||||||
|
Web search is far cheaper than AI generation, so this review pass is well worth it.
|
||||||
|
|
||||||
|
**This review never halts the pipeline** (image-base §6 hard rule). It runs inside Step 5 image acquisition: an image that cannot be verified or replaced right now becomes `Needs-Manual` and the deck still builds (placeholder), so generation flows straight into Step 6. Manual `--from-url` replacement is an improvement step, not a blocking gate — do it now, or later from live preview, without stopping the run.
|
||||||
|
|
||||||
|
### Manual review candidates (escalation, opt-in)
|
||||||
|
|
||||||
|
Candidate-pool saving is **off by default** — reach for it only when a best match fails confirmation, on a subjective topic, or for a prominent image (cover / chapter divider / `hero_page` / photo-led page).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 scripts/image_search.py "<query>" --filename <name>.jpg -o <project_path>/images \
|
||||||
|
--save-candidates --max-candidates 4
|
||||||
|
```
|
||||||
|
|
||||||
|
Saves the top candidates to `images/candidates/<stem>/` with a `candidates.json` manifest and one downscaled review copy per candidate under `candidates/<stem>/review/`. **Read the candidate review copies**, pick the best fit, then promote it — the full-resolution original is copied to the target filename:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 scripts/image_search.py --promote candidate_03.jpg --filename <name>.jpg -o <project_path>/images
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 6. Manifest Format (`image_sources.json`)
|
## 6. Manifest Format (`image_sources.json`)
|
||||||
@@ -196,7 +264,7 @@ Every successful download appends or replaces one entry keyed on `filename`:
|
|||||||
|---|---|
|
|---|---|
|
||||||
| `width` / `height` | Measured from the file actually saved to disk. Use these for layout. |
|
| `width` / `height` | Measured from the file actually saved to disk. Use these for layout. |
|
||||||
| `metadata_dimensions` | Present only when upstream-claimed size differs from the saved file (preview vs original). Informational only. |
|
| `metadata_dimensions` | Present only when upstream-claimed size differs from the saved file (preview vs original). Informational only. |
|
||||||
| `license_tier` | Drives Executor's attribution decision. Only `no-attribution` / `attribution-required`. |
|
| `license_tier` | Drives Executor's attribution decision: `no-attribution` / `attribution-required` for provider-sourced images, or `manual` for a user-supplied `--from-url` replacement (embed only; rights/credit are the user's responsibility). |
|
||||||
| `attribution_required` | Boolean alias of `license_tier == "attribution-required"`. |
|
| `attribution_required` | Boolean alias of `license_tier == "attribution-required"`. |
|
||||||
| `attribution_text` | Pre-rendered canonical credit string. **Use as-is; do not regenerate.** |
|
| `attribution_text` | Pre-rendered canonical credit string. **Use as-is; do not regenerate.** |
|
||||||
| `stage` | `all` by default, or `no-attribution-only` when strict mode is used. |
|
| `stage` | `all` by default, or `no-attribution-only` when strict mode is used. |
|
||||||
@@ -278,6 +346,7 @@ Executor reads `image_sources.json` per slide that uses a Sourced image. For eac
|
|||||||
|---|---|
|
|---|---|
|
||||||
| `no-attribution` | Embed `<image>` only |
|
| `no-attribution` | Embed `<image>` only |
|
||||||
| `attribution-required` | Embed `<image>` **and** an inline credit element per §7 |
|
| `attribution-required` | Embed `<image>` **and** an inline credit element per §7 |
|
||||||
|
| `manual` | Embed `<image>` only — user-supplied URL (`--from-url`); verifying usage rights / any required credit is the user's responsibility |
|
||||||
|
|
||||||
Executor does not interpret raw license strings — `license_tier` is sufficient.
|
Executor does not interpret raw license strings — `license_tier` is sufficient.
|
||||||
|
|
||||||
@@ -290,7 +359,8 @@ Executor does not interpret raw license strings — `license_tier` is sufficient
|
|||||||
In addition to the shared checkpoint in [`image-base.md`](./image-base.md) §10:
|
In addition to the shared checkpoint in [`image-base.md`](./image-base.md) §10:
|
||||||
|
|
||||||
- [ ] Every web row has a downloaded file at `project/images/<filename>` OR is marked `Needs-Manual`
|
- [ ] Every web row has a downloaded file at `project/images/<filename>` OR is marked `Needs-Manual`
|
||||||
- [ ] Each `Sourced` row has a manifest entry with valid `license_tier` and non-empty `attribution_text`
|
- [ ] Each `Sourced` web image was reviewed for fit — a multimodal model via its `images/.review/<stem>.jpg` copy, otherwise handed to the user via `source_page_url`; a poor fit was re-queried, replaced with `--from-url`, escalated via `--save-candidates` + `--promote`, or marked `Needs-Manual`
|
||||||
|
- [ ] Each `Sourced` row has a manifest entry with valid `license_tier` and non-empty `attribution_text` (except `manual` `--from-url` rows, which carry no `attribution_text`)
|
||||||
- [ ] Any `attribution-required` image has visible inline credit text in the corresponding SVG
|
- [ ] Any `attribution-required` image has visible inline credit text in the corresponding SVG
|
||||||
- [ ] `metadata_dimensions` warnings surfaced when downloaded preview is much smaller than upstream-claimed size
|
- [ ] `metadata_dimensions` warnings surfaced when downloaded preview is much smaller than upstream-claimed size
|
||||||
- [ ] `Needs-Manual` rows include the failure reason
|
- [ ] `Needs-Manual` rows include the failure reason
|
||||||
|
|||||||
@@ -299,7 +299,7 @@ Must be executed in order — skipping or adding extra flags is FORBIDDEN:
|
|||||||
# 1. Split speaker notes into per-page note files
|
# 1. Split speaker notes into per-page note files
|
||||||
python3 scripts/total_md_split.py <project_path>
|
python3 scripts/total_md_split.py <project_path>
|
||||||
|
|
||||||
# 2. SVG post-processing (icon embedding, image crop/embed, text flattening, rounded rect to path)
|
# 2. SVG post-processing (icon embedding, image crop/embed/optimization, text flattening, rounded rect to path)
|
||||||
python3 scripts/finalize_svg.py <project_path>
|
python3 scripts/finalize_svg.py <project_path>
|
||||||
|
|
||||||
# 3. Export PPTX (embeds speaker notes by default)
|
# 3. Export PPTX (embeds speaker notes by default)
|
||||||
@@ -339,7 +339,9 @@ Full reference: [`animations.md`](animations.md).
|
|||||||
- NEVER force `-s output` for the legacy/preview pptx (PowerPoint's internal SVG parser drops icons and rounded corners). Default auto-split already gives native the high-fidelity source it needs without affecting legacy.
|
- NEVER force `-s output` for the legacy/preview pptx (PowerPoint's internal SVG parser drops icons and rounded corners). Default auto-split already gives native the high-fidelity source it needs without affecting legacy.
|
||||||
- NEVER use `--only` (it suppresses one of the two output files)
|
- NEVER use `--only` (it suppresses one of the two output files)
|
||||||
|
|
||||||
> Source-directory split: by default `svg_to_pptx.py` reads `svg_output/` for the native pptx (preserves icon `<use>`, image `preserveAspectRatio` → `srcRect`, rounded rect `rx/ry` → `prstGeom roundRect`) and `svg_final/` for the legacy/preview pptx (PowerPoint's internal SVG parser needs the flattened form). Pass `-s output` or `-s final` only when you specifically want both products to read from a single source.
|
> Source-directory split: by default `svg_to_pptx.py` reads `svg_output/` for the native pptx (preserves icon `<use>`, image `preserveAspectRatio` as native picture-crop metadata, rounded rect `rx/ry` → `prstGeom roundRect`) and `svg_final/` for the legacy/preview pptx (PowerPoint's internal SVG parser needs the flattened form). Pass `-s output` or `-s final` only when you specifically want both products to read from a single source.
|
||||||
|
|
||||||
|
**Default — raster size control**: `finalize_svg.py` optimizes raster images using a rendered-size budget of `2x` display pixels with a `2560px` maximum dimension; it may crop pixels for the flattened SVG snapshot. Native `svg_to_pptx.py` defaults to `--image-sizing cap`: it downscales only oversized full source images to `--image-max-dimension 2560`, keeps display cropping as editable PPT picture-crop metadata, and does not shrink a picture merely because its current SVG placement is small. Opaque PNG photos may become JPEG; transparent assets remain PNG. Use `finalize_svg.py --no-compress` / a higher `--max-dimension` only for diagnostic SVG snapshots, `svg_to_pptx.py --no-image-optimize` only when the native PPTX must retain original image bytes, and `svg_to_pptx.py --image-sizing display --image-scale 2` only for aggressive size reduction.
|
||||||
|
|
||||||
**Re-run rule**: Any change to `svg_output/` after post-processing requires re-running Steps 2-3. Step 1 only re-runs if `notes/total.md` changed.
|
**Re-run rule**: Any change to `svg_output/` after post-processing requires re-running Steps 2-3. Step 1 only re-runs if `notes/total.md` changed.
|
||||||
|
|
||||||
|
|||||||
@@ -350,7 +350,35 @@ The script renders PNGs into `images/`, trying `codecogs`, `quicklatex`, `mathpa
|
|||||||
>
|
>
|
||||||
> 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.
|
> 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.
|
> **Recommendation shape.** In `recommendations.json`, write `recommend.image_usage` as source ids, not prose. Use a single id for a single source (`"ai"`) and an array for mixed sources (`["ai", "provided"]`, `["web", "placeholder"]`). Put the strategy explanation in top-level `image_notes.value`: page roles, which supplied assets are authoritative, where AI may fill gaps, what kind of imagery to prefer/avoid, and whether placeholders are acceptable. `none` is exclusive and must not be combined with other ids.
|
||||||
|
|
||||||
|
> **Confirmed value wins.** The `image_usage` in `result.json` (or the chat reply) **overrides the recommendation here**. It may be a legacy single string or a Confirm UI multi-select array. Map every selected value to §VIII `Acquire Via` (`ai`→`ai`, `web`→`web`, `provided`→**`user`**, `placeholder`→`placeholder`, `none`→option A, no image rows). When it does not include `ai` (and no legacy prose plan includes AI), skip h.5 entirely and write no `ai` rows. If `image_notes` is present, honor it as the user's image intent while assigning per-page rows. See SKILL.md Step 4 for the full mapping.
|
||||||
|
|
||||||
|
> **Illustration roles at a glance (Reference).** Within the `image_usage` source boundary, illustrations can play several roles — pick by what the page needs; the **deck illustration motif** rule below threads them into one system. Mechanics live in the linked files.
|
||||||
|
>
|
||||||
|
> | Role | When | Mechanism | Source |
|
||||||
|
> |---|---|---|---|
|
||||||
|
> | Body spot | scattered decorative accent (the icon-counterpart) | `page_role: local`, sheet→slice `#63` ([image-generator §4.3](./image-generator.md)) | `slice` (from an `ai` sheet) / `provided` / `web` |
|
||||||
|
> | Hero anchor | cover or a single big statement | `page_role: hero_page` + §4.1 Primitive A or D | per `image_usage` |
|
||||||
|
> | Section divider | chapter identity, often recurring | `page_role: hero_page` + `#75` divider layout | same motif family |
|
||||||
|
> | Atmospheric background | mood wash behind editable SVG text | `page_role: hero_page` + §4.1 Primitive D | per `image_usage` |
|
||||||
|
> | Motif through-line | one family across the roles above, deck-wide (a designed system) | combination of the above | `ai`: generate one family; `provided` / `web`: only if the assets already form one family |
|
||||||
|
|
||||||
|
> **Spot illustrations follow the locked `visual_style`'s propensity — a default lean, not a confirmation field.** The user confirms image *source* via `image_usage`; illustrations are not a separate confirmation control, page-role map, or user-facing picker. Within that boundary, whether the deck leans into decorative spot illustrations is anchored by the locked `visual_style`'s **illustration propensity** (`core` / `supportive` / `sparse` — see that style's §6 and the [`visual-styles/_index.md`](./visual-styles/_index.md) `Illus.` column):
|
||||||
|
>
|
||||||
|
> - **`core`** (e.g. sketch-notes, memphis, paper-cut) — illustration is intrinsic; default to planning a coherent spot family.
|
||||||
|
> - **`supportive`** — use where it genuinely lifts rhythm or section identity, kept restrained.
|
||||||
|
> - **`sparse`** (e.g. swiss-minimal, photo-editorial, data-journalism) — the style's lead visual competes; default to none.
|
||||||
|
>
|
||||||
|
> **Precedence (high → low): `image_usage: none` → explicit user intent → visual_style propensity → none.** `image_usage: none` always wins and produces no illustration rows. Otherwise an **explicit user request** to use or skip illustrations (stated in chat — the canonical channel) overrides the propensity default *in either direction*: use them even on a `sparse` style, skip them even on a `core` style. Propensity is only the default when the user gives no steer. Record the decision and its reason in `image_notes` / `design_spec`.
|
||||||
|
>
|
||||||
|
> **Propensity sets *whether* and the lean — never the *source* or the *how*.** Source still comes from `image_usage`: a `core` style does **not** silently generate AI spots when the user did not select AI — with no AI source it draws only on a confirmed source (`provided` / `web`) or goes without. How heavily and where stays Strategist judgment, and the locked style still governs deployment (a `sparse` style used on explicit request stays very few, very light). Do not force a quota, and do not phrase it as "few pages." Suitable means the spot improves rhythm, warmth, continuity, or section identity without carrying facts or competing with charts, photos, tables, screenshots, or key text.
|
||||||
|
|
||||||
|
> **Deck illustration motif — deploy one family as a through-line, not isolated spots.** When the deck leans into illustration (a `core` propensity, or an explicit user request for a designed, illustrated feel), the strongest use is **one coherent motif family** — a single subject world in the deck-wide rendering + palette locked at h.5 — deployed across scales: a cover anchor (`page_role: hero_page`), recurring section dividers for chapter identity ([image-layout-patterns](./image-layout-patterns.md) `#75`), and small body spots (`local` sheet→slice, `#63`). They read as one designed system because they share a family, not just a palette. **Source still bounds it** (same precedence as above): the AI motif — `hero_page` generations plus the sheet→slice spots — is planned **only when the confirmed `image_usage` includes `ai`**. With `provided` / `web`, a through-line is possible only when the supplied or sourced assets already form one visual family to carry across pages; otherwise the deck goes without rather than silently generating AI. Reach for it only when the deck suits it — **not a quota**: not every section needs a divider, a deck with no clear sections or no room for decoration is not forced into a motif, and the through-line never competes with charts, photos, tables, or key text. When a motif is in play, the cover / divider pages surfaced by the **hero_page suggestion** below and the body spots should be drawn from the same family. **The family is not one giant sheet** — the slicer cuts a uniform grid, so cover / divider anchors are their own `hero_page` generations (mechanics: [image-generator.md](./image-generator.md) §4.1) sharing the spot sheet's (§4.3) rendering, palette, and subject world.
|
||||||
|
|
||||||
|
**AI generation path — one sheet, then slice.** When spot illustrations are AI-generated and the deck needs ≥3 same-family elements, write one `ai` Illustration Sheet row plus one `slice` row per used element. Step 5 generates the sheet, then slices transparent PNG elements for placement. This is just AI-generated imagery batched for consistency and efficiency; do not generate one image per spot.
|
||||||
|
|
||||||
|
**Plan illustration shape from placement, not from a square default.** Before writing the sheet row, group the planned spot illustrations by intended placement shape: compact object, tall side accent, wide banner/vignette, or another explicit shape family. Do not ask for generic "small illustrations" with no shape intent. If one deck needs incompatible shapes, write separate sheet intents instead of forcing every element into one implied square set; Image_Generator owns the exact sheet ratio and grid.
|
||||||
|
|
||||||
**When recommending C** — surface its three implementation modes so the user knows "no API key" is a supported state:
|
**When recommending C** — surface its three implementation modes so the user knows "no API key" is a supported state:
|
||||||
|
|
||||||
@@ -364,6 +392,8 @@ Selection is automatic in Step 5 (A → B → Manual). Detailed contract: [`imag
|
|||||||
|
|
||||||
Selections may be mixed at the row level — e.g. a deck can use C for hero illustrations while sourcing D for supporting team photos.
|
Selections may be mixed at the row level — e.g. a deck can use C for hero illustrations while sourcing D for supporting team photos.
|
||||||
|
|
||||||
|
> **Spot illustrations → one sheet, not N rows.** When the deck wants ≥3 small same-family spot illustrations as decorative accessories across pages (the illustration counterpart to icons), do not write one `ai` row per element. Write **one `ai` sheet row** (the sheet prompt intent — generated but never placed, kept out of `spec_lock.md images`) **plus one `slice` element row per used element** (each placed, each listed in `spec_lock.md images` so the Executor may reference it). The sheet row's `Reference` must name the intended cell shape family and placement purpose, such as "portrait side-accent spot set" or "landscape footer-vignette spot set"; the slice rows reference the parent sheet + cell. Step 5 / Image_Generator chooses the exact sheet ratio, grid, and slice command. Plan these sparingly, only where decoration genuinely lifts the page. Full resource contract + slice command: [`image-generator.md`](./image-generator.md) §4.3.
|
||||||
|
|
||||||
#### h.5 AI Image Strategy — lock rendering + palette (only when C is selected)
|
#### h.5 AI Image Strategy — lock rendering + palette (only when C is selected)
|
||||||
|
|
||||||
When the deck includes any `ai` rows, Strategist locks a **deck-wide rendering** and **deck-wide palette** here. These two values are written into `design_spec.md §III` and `spec_lock.md colors` / `images` sections, then consumed by Image_Generator. Every AI image in the deck shares them — this is what makes multiple AI images feel like one deck.
|
When the deck includes any `ai` rows, Strategist locks a **deck-wide rendering** and **deck-wide palette** here. These two values are written into `design_spec.md §III` and `spec_lock.md colors` / `images` sections, then consumed by Image_Generator. Every AI image in the deck shares them — this is what makes multiple AI images feel like one deck.
|
||||||
@@ -548,7 +578,7 @@ After the user picks a candidate, scan the outline and surface any pages where t
|
|||||||
| **Layout pattern** | **MANDATORY** — one or more `#<id> <name>` joined by ` + ` from `image-layout-patterns.md`. Combine a Primary id with optional Modifier ids when the page needs it (e.g. `#48 side-by-side comparison + #21 rounded rectangle crop + #29 two-stop scrim`). A single Primary is fine when the page calls for it. See the GATE earlier in this section. Empty cells or invented ids are invalid. |
|
| **Layout pattern** | **MANDATORY** — one or more `#<id> <name>` joined by ` + ` from `image-layout-patterns.md`. Combine a Primary id with optional Modifier ids when the page needs it (e.g. `#48 side-by-side comparison + #21 rounded rectangle crop + #29 two-stop scrim`). A single Primary is fine when the page calls for it. See the GATE earlier in this section. Empty cells or invented ids are invalid. |
|
||||||
| Purpose | e.g., `Cover background` |
|
| Purpose | e.g., `Cover background` |
|
||||||
| Type | Free-form category tag — `Background`, `Photography`, `Illustration`, `Diagram`, `Portrait`, `Latex Formula`, etc. Required for formula rows (`Latex Formula`). |
|
| Type | Free-form category tag — `Background`, `Photography`, `Illustration`, `Diagram`, `Portrait`, `Latex Formula`, etc. Required for formula rows (`Latex Formula`). |
|
||||||
| **Acquire Via** | `ai` / `web` / `user` / `formula` / `placeholder` — only `ai` and `web` drive Step 5 dispatch |
|
| **Acquire Via** | `ai` / `web` / `user` / `formula` / `placeholder` / `slice` — only `ai` and `web` drive Step 5 dispatch; `slice` is derived after its `ai` sheet generates (§4.3) |
|
||||||
| Status | Initial status must be `Pending`, `Existing`, `Rendered`, or `Placeholder`; see [`svg-image-embedding.md`](svg-image-embedding.md) for the full status enum |
|
| Status | Initial status must be `Pending`, `Existing`, `Rendered`, or `Placeholder`; see [`svg-image-embedding.md`](svg-image-embedding.md) for the full status enum |
|
||||||
| **Reference** | Free-form **intent description** (NOT a search query); feeds Image_Generator (ai) or Image_Searcher (web) |
|
| **Reference** | Free-form **intent description** (NOT a search query); feeds Image_Generator (ai) or Image_Searcher (web) |
|
||||||
| `text_policy` (optional, `ai` rows only) | `none` (no text in image) or `embedded` (text is part of the artwork). Leave blank when Image_Generator should decide per row. Long body / data / lists stay in SVG. |
|
| `text_policy` (optional, `ai` rows only) | `none` (no text in image) or `embedded` (text is part of the artwork). Leave blank when Image_Generator should decide per row. Long body / data / lists stay in SVG. |
|
||||||
|
|||||||
+7
-3
@@ -18,17 +18,19 @@ Defined in the Design Specification & Content Outline; each image carries an `Ac
|
|||||||
| product.png | 600x400 | Page 3 product photo | Photography | user | Existing | - |
|
| product.png | 600x400 | Page 3 product photo | Photography | user | Existing | - |
|
||||||
| formula_001.png | 736x168 | Page 3 block equation | Latex Formula | formula | Rendered | `E = mc^2` |
|
| formula_001.png | 736x168 | Page 3 block equation | Latex Formula | formula | Rendered | `E = mc^2` |
|
||||||
| chart.png | 600x400 | Page 5 placeholder | Illustration | placeholder | Placeholder | Team collaboration scene to be added later |
|
| chart.png | 600x400 | Page 5 placeholder | Illustration | placeholder | Placeholder | Team collaboration scene to be added later |
|
||||||
|
| spot_sheet.png | 1024x1024 | 2x2 spot illustration sheet, not placed | Illustration Sheet | ai | Pending | Four same-family spot illustrations on a clean grid |
|
||||||
|
| spot_team.png | TBD after slicing | Page 4 team spot illustration | Illustration | slice | Pending | From `spot_sheet.png` cell 1,1 |
|
||||||
```
|
```
|
||||||
|
|
||||||
### Image Status Enum
|
### Image Status Enum
|
||||||
|
|
||||||
| Status | Meaning | Executor Handling |
|
| Status | Meaning | Executor Handling |
|
||||||
|--------|---------|-------------------|
|
|--------|---------|-------------------|
|
||||||
| **Pending** | Acquisition needed (`Acquire Via: ai` or `web`); not yet attempted | Image Acquisition Phase (Step 5) consumes this; must not remain after Step 5 |
|
| **Pending** | Acquisition needed (`Acquire Via: ai` / `web`) or derivation needed (`Acquire Via: slice`); not yet attempted | Image Acquisition Phase (Step 5) consumes this; must not remain after Step 5 |
|
||||||
| **Generated** | AI-generated file exists at expected path | Reference from `../images/`; no on-slide credit needed |
|
| **Generated** | AI-generated file exists at expected path, or sliced element file exists at expected path | Reference from `../images/`; no on-slide credit needed. **Exception**: an `Illustration Sheet` row is only a slice source — it lives in §VIII but never in `spec_lock.md images`, so the Executor never places it |
|
||||||
| **Sourced** | Web-sourced file exists at expected path | Reference from `../images/`; check `image_sources.json` for `license_tier` — if `attribution-required`, render an inline credit element on the slide (see [executor-base.md §6](./executor-base.md) and [image-searcher.md §7](./image-searcher.md) for the visual spec) |
|
| **Sourced** | Web-sourced file exists at expected path | Reference from `../images/`; check `image_sources.json` for `license_tier` — if `attribution-required`, render an inline credit element on the slide (see [executor-base.md §6](./executor-base.md) and [image-searcher.md §7](./image-searcher.md) for the visual spec) |
|
||||||
| **Rendered** | Deterministic formula PNG exists at expected path (`Acquire Via: formula`) | Reference from `../images/`; use `preserveAspectRatio="xMidYMid meet"` and do not crop |
|
| **Rendered** | Deterministic formula PNG exists at expected path (`Acquire Via: formula`) | Reference from `../images/`; use `preserveAspectRatio="xMidYMid meet"` and do not crop |
|
||||||
| **Needs-Manual** | Acquisition attempted once + one retry, failed | Dashed placeholder unless user has manually supplied the file |
|
| **Needs-Manual** | Acquisition attempted once + one retry, failed; for `slice`, parent sheet is unavailable | Dashed placeholder unless user has manually supplied the file. For `slice` rows, place the parent sheet and rerun `slice_images.py`; do not hand-place individual element files |
|
||||||
| **Existing** | User already has image (`Acquire Via: user`) | Place in `images/`, reference with `<image>` |
|
| **Existing** | User already has image (`Acquire Via: user`) | Place in `images/`, reference with `<image>` |
|
||||||
| **Placeholder** | Intentionally not prepared yet (`Acquire Via: placeholder`) | Dashed border placeholder; replace later |
|
| **Placeholder** | Intentionally not prepared yet (`Acquire Via: placeholder`) | Dashed border placeholder; replace later |
|
||||||
|
|
||||||
@@ -41,11 +43,13 @@ Defined in the Design Specification & Content Outline; each image carries an `Ac
|
|||||||
2. Image Acquisition (Step 5):
|
2. Image Acquisition (Step 5):
|
||||||
- Pending + ai → Image_Generator runs image_gen.py → Generated
|
- Pending + ai → Image_Generator runs image_gen.py → Generated
|
||||||
- Pending + web → Image_Searcher runs image_search.py → Sourced
|
- Pending + web → Image_Searcher runs image_search.py → Sourced
|
||||||
|
- Pending + slice → after parent AI sheet is Generated, slice_images.py cuts element files → Generated
|
||||||
- formula / user / placeholder rows are skipped
|
- formula / user / placeholder rows are skipped
|
||||||
3. Executor generates SVGs (svg_output/)
|
3. Executor generates SVGs (svg_output/)
|
||||||
├── Existing / Generated → <image href="../images/xxx.png" .../>
|
├── Existing / Generated → <image href="../images/xxx.png" .../>
|
||||||
├── Sourced + license_tier=no-attribution → <image href=...> only
|
├── Sourced + license_tier=no-attribution → <image href=...> only
|
||||||
├── Sourced + license_tier=attribution-required → <image href=...> + small <text> credit element on the slide
|
├── Sourced + license_tier=attribution-required → <image href=...> + small <text> credit element on the slide
|
||||||
|
├── Sourced + license_tier=manual → <image href=...> only (user-supplied --from-url; rights/credit are user responsibility)
|
||||||
├── Rendered formula → <image href="../images/formula_001.png" preserveAspectRatio="xMidYMid meet" .../>
|
├── Rendered formula → <image href="../images/formula_001.png" preserveAspectRatio="xMidYMid meet" .../>
|
||||||
└── Placeholder / Needs-Manual without file → Dashed border + description text
|
└── Placeholder / Needs-Manual without file → Dashed border + description text
|
||||||
4. Preview: python3 -m http.server -d <project_path> 8000 → /svg_output/<filename>.svg
|
4. Preview: python3 -m http.server -d <project_path> 8000 → /svg_output/<filename>.svg
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
## Core Mission
|
## Core Mission
|
||||||
|
|
||||||
Generate reusable page templates for the **global template library** based on a finalized template brief.
|
Generate reusable page templates for the **global template library** based on a finalized template brief, and write a concise `design_spec.md` that captures the source-derived basic norms that make the template reusable.
|
||||||
|
|
||||||
> This is a standalone role: only triggered via the `/create-template` workflow. It is **not** the project-level template selection/customization step in the main PPT generation pipeline.
|
> This is a standalone role: only triggered via the `/create-template` workflow. It is **not** the project-level template selection/customization step in the main PPT generation pipeline.
|
||||||
|
|
||||||
@@ -12,7 +12,7 @@ Generate reusable page templates for the **global template library** based on a
|
|||||||
|
|
||||||
- **Trigger**: `/create-template` workflow
|
- **Trigger**: `/create-template` workflow
|
||||||
- **Output location**: `templates/<kind_dir>/<template_name>/` where `<kind_dir>` is `decks` (default — full-PPT replica with identity) or `layouts` (structure-only, no identity segment)
|
- **Output location**: `templates/<kind_dir>/<template_name>/` where `<kind_dir>` is `decks` (default — full-PPT replica with identity) or `layouts` (structure-only, no identity segment)
|
||||||
- **Input**: finalized template brief (template ID, display name, kind, applicable scenarios, tone, theme mode, canvas format, optional reference assets)
|
- **Input**: finalized template brief (template ID, display name, kind, applicable scenarios, tone, theme mode, canvas format, optional reference assets, accepted basic template norms)
|
||||||
|
|
||||||
When the workflow provides a PPTX reference source, the effective input package comes from the unified `pptx_template_import.py` preparation workspace and becomes:
|
When the workflow provides a PPTX reference source, the effective input package comes from the unified `pptx_template_import.py` preparation workspace and becomes:
|
||||||
|
|
||||||
@@ -52,7 +52,7 @@ The output page set is determined by **replication mode**, declared in the final
|
|||||||
|------|-------------|--------|
|
|------|-------------|--------|
|
||||||
| `standard` (default) | Most templates — clean, reusable, balanced coverage | `01_cover`, `02_chapter`, `03_content`, `04_ending`, optional `02_toc` |
|
| `standard` (default) | Most templates — clean, reusable, balanced coverage | `01_cover`, `02_chapter`, `03_content`, `04_ending`, optional `02_toc` |
|
||||||
| `fidelity` | User explicitly wants strict replication of a source PPTX, but still wants the AI to clean / cluster / cap variants | Standard roster + one variant per distinct layout cluster found in `manifest.json` |
|
| `fidelity` | User explicitly wants strict replication of a source PPTX, but still wants the AI to clean / cluster / cap variants | Standard roster + one variant per distinct layout cluster found in `manifest.json` |
|
||||||
| `mirror` | User wants every source slide preserved 1:1 as a reference page (someone else's polished deck used as a library template). Verbatim copy, no abstraction, no placeholders | One SVG per source slide, named `<NNN>_<page_type>.svg` by source order |
|
| `mirror` | Creation-time harvesting mode: every source slide is preserved 1:1 as a reference page for the template package. Verbatim copy, no abstraction, no placeholders | One SVG per source slide, named `<NNN>_<page_type>.svg` by source order |
|
||||||
|
|
||||||
### Standard mode
|
### Standard mode
|
||||||
|
|
||||||
@@ -99,9 +99,9 @@ When the brief sets `Replication mode: mirror`, the role does **not** abstract o
|
|||||||
- Output: `templates/<kind_dir>/<template_id>/<NNN>_<page_type>.svg`, where `<NNN>` is the zero-padded source slide index (3 digits) and `<page_type>` is derived from `manifest.json` `pageTypeCandidates` — `cover` / `toc` / `chapter` / `content` / `ending`. When the page-type heuristic is ambiguous, fall back to `content`. Preserve source slide order via the numeric prefix.
|
- Output: `templates/<kind_dir>/<template_id>/<NNN>_<page_type>.svg`, where `<NNN>` is the zero-padded source slide index (3 digits) and `<page_type>` is derived from `manifest.json` `pageTypeCandidates` — `cover` / `toc` / `chapter` / `content` / `ending`. When the page-type heuristic is ambiguous, fall back to `content`. Preserve source slide order via the numeric prefix.
|
||||||
- Modifications allowed: **only** rewriting `<image href="...">` paths to point at the local `assets/` copy, and renaming asset files to semantic names. Everything else — geometry, decoration, sprite-sheet wrappers, original example text, chart placeholders, embedded fonts — is copied as-is.
|
- Modifications allowed: **only** rewriting `<image href="...">` paths to point at the local `assets/` copy, and renaming asset files to semantic names. Everything else — geometry, decoration, sprite-sheet wrappers, original example text, chart placeholders, embedded fonts — is copied as-is.
|
||||||
- Modifications forbidden: inserting `{{TITLE}}` / `{{CONTENT_AREA}}` / any other placeholder; "cleaning up" decorative complexity; merging similar slides; dropping master/layout chrome (it's already baked into the flat SVG, leave it).
|
- Modifications forbidden: inserting `{{TITLE}}` / `{{CONTENT_AREA}}` / any other placeholder; "cleaning up" decorative complexity; merging similar slides; dropping master/layout chrome (it's already baked into the flat SVG, leave it).
|
||||||
- `design_spec.md` §V Page Roster lists every emitted file with **a one-line description** of what the page contains and what content slot it suits — Strategist selects mirror pages purely from these descriptions, since the SVG itself carries no placeholder contract.
|
- `design_spec.md` §V Page Roster lists every emitted file with **a one-line description** of what the page contains and what content slot it suits — Strategist selects pages from these descriptions, since the SVG itself carries no placeholder contract.
|
||||||
|
|
||||||
**Why mirror has no placeholders**: it is consumed as a **visual reference** rather than a templated form. Executor's mirror path (see [executor-base.md](executor-base.md) §1.1) copies the chosen mirror page into the project and edits text elements in place against the project content — no `{{}}` substitution happens. This keeps the library asset 100% verbatim and lets the user re-discover the source deck by browsing `templates/<kind_dir>/<template_id>/` directly.
|
**Mirror consumption boundary**: `mirror` applies only while creating the template package from the source deck. Once created, the package is consumed as an ordinary deck / layout template roster: downstream generation may select, repeat, skip, reorder, or adapt pages according to the new content. The `replication_mode: mirror` field must not force the generated deck to preserve the source page count, source order, or one-output-slide-per-template-slide mapping.
|
||||||
|
|
||||||
**What mirror is not**: a pixel-perfect re-rendering pipeline test. Charts, SmartArt, OLE objects, and EMF / WMF media that fail to round-trip in `pptx_template_import.py` will fail the same way in mirror. If the import workspace has missing media or unsupported objects, mirror inherits those gaps — the user should be told before generation begins.
|
**What mirror is not**: a pixel-perfect re-rendering pipeline test. Charts, SmartArt, OLE objects, and EMF / WMF media that fail to round-trip in `pptx_template_import.py` will fail the same way in mirror. If the import workspace has missing media or unsupported objects, mirror inherits those gaps — the user should be told before generation begins.
|
||||||
|
|
||||||
@@ -130,6 +130,13 @@ summary: <one-line tone & use case>
|
|||||||
keywords: [tag1, tag2, tag3]
|
keywords: [tag1, tag2, tag3]
|
||||||
primary_color: "#......"
|
primary_color: "#......"
|
||||||
canvas_format: ppt169
|
canvas_format: ppt169
|
||||||
|
canvas_width: 1280
|
||||||
|
canvas_height: 720
|
||||||
|
canvas_viewbox: "0 0 1280 720"
|
||||||
|
# Required when a PPTX/SVG source canvas is known; keep equal to canvas_* unless explicitly normalized.
|
||||||
|
source_canvas_width: 1280
|
||||||
|
source_canvas_height: 720
|
||||||
|
source_viewbox: "0 0 1280 720"
|
||||||
replication_mode: standard | fidelity | mirror
|
replication_mode: standard | fidelity | mirror
|
||||||
# Optional — only when this template overrides canonical placeholder vocabulary.
|
# Optional — only when this template overrides canonical placeholder vocabulary.
|
||||||
# Omit entirely for `mirror` mode (mirror has no placeholders).
|
# Omit entirely for `mirror` mode (mirror has no placeholders).
|
||||||
@@ -155,10 +162,11 @@ replication_mode: standard | fidelity | mirror
|
|||||||
|
|
||||||
## IV. Signature Design Elements
|
## IV. Signature Design Elements
|
||||||
- Decorative motifs that ARE this template — top bar, gradient underline, logo treatment, brand emblem placement
|
- Decorative motifs that ARE this template — top bar, gradient underline, logo treatment, brand emblem placement
|
||||||
|
- Source-derived layout grammar — grid / column rhythm, page chrome, image zones, mask / crop behavior, overlay treatment, and density rhythm that make the template recognizable
|
||||||
- Optional XML snippet for any reusable component unique to this template
|
- Optional XML snippet for any reusable component unique to this template
|
||||||
|
|
||||||
## V. Page Roster
|
## V. Page Roster
|
||||||
One row per emitted SVG describing what this template's version of cover / chapter / content / ending looks like (background treatment, decorative anchors, layout rhythm). For `fidelity` mode, also note the cluster source and visual differentiator. For `mirror` mode the roster is the **load-bearing artifact** — Strategist picks mirror pages purely from these descriptions, so each row must include enough detail to distinguish it from siblings (column count, hero element, content density, what kind of content slot it fits — "three-column KPI grid with large numbers, suits quarterly summary"). Roster entries must match the actual SVG files on disk.
|
One row per emitted SVG describing what this template's version of cover / chapter / content / ending looks like (background treatment, decorative anchors, layout rhythm, image behavior, content density, intended content slot). For `fidelity` mode, also note the cluster source and visual differentiator. For `mirror` mode the roster is the **load-bearing artifact** — Strategist picks pages from these descriptions, so each row must include enough detail to distinguish it from siblings (column count, hero element, content density, what kind of content slot it fits — "three-column KPI grid with large numbers, suits quarterly summary"). Roster entries must match the actual SVG files on disk.
|
||||||
|
|
||||||
## VI. Assets (omit when none)
|
## VI. Assets (omit when none)
|
||||||
Logos, cover backgrounds, brand textures bundled with the template package — file name, dimensions, intended usage.
|
Logos, cover backgrounds, brand textures bundled with the template package — file name, dimensions, intended usage.
|
||||||
@@ -186,10 +194,12 @@ When rewriting an existing template that contains the omitted sections, delete t
|
|||||||
### 2. Inherit Design Specification
|
### 2. Inherit Design Specification
|
||||||
|
|
||||||
Templates must strictly follow the finalized template brief and the generated `design_spec.md`:
|
Templates must strictly follow the finalized template brief and the generated `design_spec.md`:
|
||||||
- **Canvas dimensions**: viewBox matches the design spec
|
- **Canvas dimensions**: `canvas_format` is not enough; root SVG `width` / `height` / `viewBox` match `canvas_width`, `canvas_height`, and `canvas_viewbox` in the design spec
|
||||||
|
- **Source canvas**: when a PPTX/SVG reference is used, record `source_canvas_width`, `source_canvas_height`, and `source_viewbox`. If the output canvas differs from the source, normalize all geometry, typography, line heights, strokes, and image crop coordinates explicitly instead of relying on the shared aspect ratio.
|
||||||
- **Color scheme**: Uses primary, secondary, and accent colors from the spec
|
- **Color scheme**: Uses primary, secondary, and accent colors from the spec
|
||||||
- **Font plan**: Uses the per-role font families declared in the spec
|
- **Font plan**: Uses the per-role font families declared in the spec
|
||||||
- **Layout principles**: Margins and spacing conform to the spec
|
- **Layout principles**: Margins and spacing conform to the spec
|
||||||
|
- **Image system**: Image placement, crop / mask behavior, full-bleed zones, and overlay rules follow the source-derived norms in the spec
|
||||||
|
|
||||||
If PPTX import output exists:
|
If PPTX import output exists:
|
||||||
- Prefer imported theme colors and fonts over visually guessed values
|
- Prefer imported theme colors and fonts over visually guessed values
|
||||||
@@ -221,7 +231,7 @@ Do not:
|
|||||||
|
|
||||||
### 3. Placeholder Markers
|
### 3. Placeholder Markers
|
||||||
|
|
||||||
> **Mirror mode skips this section entirely.** Mirror SVGs are verbatim copies of the source flat slides — they carry no `{{}}` markers. Executor reads them as visual references and edits text in place (see [executor-base.md](executor-base.md) §1.1). The rest of this section applies to `standard` and `fidelity` only.
|
> **Mirror mode skips this section entirely.** Mirror SVGs are verbatim copies of the source flat slides — they carry no `{{}}` markers. Mirror is only a template-creation mode; downstream generation treats the finished files as normal template roster candidates. The rest of this section applies to `standard` and `fidelity` only.
|
||||||
|
|
||||||
Use clear placeholder markers for replaceable content:
|
Use clear placeholder markers for replaceable content:
|
||||||
|
|
||||||
|
|||||||
+30
-28
@@ -13,49 +13,51 @@ A **visual style** is how the deck **looks** — shape language, decoration dens
|
|||||||
Each style has its own file with: shape & decoration, typography character, color-usage discipline (no HEX), texture / elevation, and the paired image-rendering. **Read only the file for the style you lock** — never glob the directory. The catalog mirrors [`image-renderings`](../image-renderings/_index.md): each style's "Paired rendering" names the illustration family that shares its aesthetic.
|
Each style has its own file with: shape & decoration, typography character, color-usage discipline (no HEX), texture / elevation, and the paired image-rendering. **Read only the file for the style you lock** — never glob the directory. The catalog mirrors [`image-renderings`](../image-renderings/_index.md): each style's "Paired rendering" names the illustration family that shares its aesthetic.
|
||||||
|
|
||||||
> The **`visual_style` value is only ever a first-column `id`** (`swiss-minimal`, `editorial`, …). The "Paired rendering" column lists **§h image-rendering** names (`flat`, `minimalist-swiss`, `digital-dashboard`, …) — never lock one of those as the `visual_style`; they belong to confirmation h.
|
> The **`visual_style` value is only ever a first-column `id`** (`swiss-minimal`, `editorial`, …). The "Paired rendering" column lists **§h image-rendering** names (`flat`, `minimalist-swiss`, `digital-dashboard`, …) — never lock one of those as the `visual_style`; they belong to confirmation h.
|
||||||
|
>
|
||||||
|
> The **`Illus.`** column is each style's spot-illustration propensity — `core` (illustration is intrinsic to the look), `supportive` (use where it lifts, restrained), or `sparse` (the style's lead visual competes; default off). It sets the **default lean** only, for when the user gives no steer: an explicit user request to use / skip illustrations overrides it either way, and `image_usage: none` always writes no illustration rows. Full per-style rule in each file's §6.
|
||||||
|
|
||||||
### 1.1 Corporate / product
|
### 1.1 Corporate / product
|
||||||
|
|
||||||
| Visual style | Character | Best for | Paired rendering |
|
| Visual style | Character | Best for | Paired rendering | Illus. |
|
||||||
|---|---|---|---|
|
|---|---|---|---|---|
|
||||||
| [`swiss-minimal`](./swiss-minimal.md) | Grid-locked, sharp, aggressive whitespace, no decoration | High-end consulting, architecture, type-led | `minimalist-swiss` |
|
| [`swiss-minimal`](./swiss-minimal.md) | Grid-locked, sharp, aggressive whitespace, no decoration | High-end consulting, architecture, type-led | `minimalist-swiss` | sparse |
|
||||||
| [`soft-rounded`](./soft-rounded.md) | Rounded cards, gentle elevation, approachable | Product, SaaS, training, consumer | `flat` |
|
| [`soft-rounded`](./soft-rounded.md) | Rounded cards, gentle elevation, approachable | Product, SaaS, training, consumer | `flat` | supportive |
|
||||||
| [`glassmorphism`](./glassmorphism.md) | Translucent glass panels, gradient light, floating depth | Modern SaaS, fintech, product launches, AI demos | `glassmorphism` |
|
| [`glassmorphism`](./glassmorphism.md) | Translucent glass panels, gradient light, floating depth | Modern SaaS, fintech, product launches, AI demos | `glassmorphism` | sparse |
|
||||||
| [`dark-tech`](./dark-tech.md) | Dark canvas, glow accents, geometric precision | Tech, AI, data products, launches | `digital-dashboard` |
|
| [`dark-tech`](./dark-tech.md) | Dark canvas, glow accents, geometric precision | Tech, AI, data products, launches | `digital-dashboard` | sparse |
|
||||||
| [`blueprint`](./blueprint.md) | Schematic line work on dark paper, isometric, annotated | Technical briefings, architecture, engineering | `blueprint` |
|
| [`blueprint`](./blueprint.md) | Schematic line work on dark paper, isometric, annotated | Technical briefings, architecture, engineering | `blueprint` | supportive |
|
||||||
|
|
||||||
### 1.2 Editorial / publication
|
### 1.2 Editorial / publication
|
||||||
|
|
||||||
| Visual style | Character | Best for | Paired rendering |
|
| Visual style | Character | Best for | Paired rendering | Illus. |
|
||||||
|---|---|---|---|
|
|---|---|---|---|---|
|
||||||
| [`editorial`](./editorial.md) | Magazine hierarchy, rules & columns, serif/sans interplay | Finance, journalism, analysis, explainers | `editorial` |
|
| [`editorial`](./editorial.md) | Magazine hierarchy, rules & columns, serif/sans interplay | Finance, journalism, analysis, explainers | `editorial` | supportive |
|
||||||
| [`photo-editorial`](./photo-editorial.md) | Full-bleed photography dominates, text points & captions | Architecture, design, fashion, culture, photo-led | `corporate-photo` |
|
| [`photo-editorial`](./photo-editorial.md) | Full-bleed photography dominates, text points & captions | Architecture, design, fashion, culture, photo-led | `corporate-photo` | sparse |
|
||||||
| [`data-journalism`](./data-journalism.md) | Multi-column micro-charts, sidebars, source lines, dense | Finance, market reviews, research, data reports | `editorial` |
|
| [`data-journalism`](./data-journalism.md) | Multi-column micro-charts, sidebars, source lines, dense | Finance, market reviews, research, data reports | `editorial` | sparse |
|
||||||
| [`brutalist`](./brutalist.md) | Newsprint density, ruled boxes, raw structure, flat | Annual reviews, research digests, manifestos | `screen-print` / `editorial` |
|
| [`brutalist`](./brutalist.md) | Newsprint density, ruled boxes, raw structure, flat | Annual reviews, research digests, manifestos | `screen-print` / `editorial` | supportive |
|
||||||
|
|
||||||
### 1.3 Expressive / print
|
### 1.3 Expressive / print
|
||||||
|
|
||||||
| Visual style | Character | Best for | Paired rendering |
|
| Visual style | Character | Best for | Paired rendering | Illus. |
|
||||||
|---|---|---|---|
|
|---|---|---|---|---|
|
||||||
| [`memphis`](./memphis.md) | Clashing color blocks, geometric confetti, bold outlines | Festivals, consumer, youth, launch hype | `flat` |
|
| [`memphis`](./memphis.md) | Clashing color blocks, geometric confetti, bold outlines | Festivals, consumer, youth, launch hype | `flat` | core |
|
||||||
| [`zine`](./zine.md) | Riso misregistration, halftone, limited palette, print grit | Culture, design talks, indie brands | `screen-print` |
|
| [`zine`](./zine.md) | Riso misregistration, halftone, limited palette, print grit | Culture, design talks, indie brands | `screen-print` | core |
|
||||||
| [`vintage-poster`](./vintage-poster.md) | Mid-century flat blocks, halftone, retro-geometric warmth | Heritage, hospitality, cultural, anniversaries | `vintage-poster` |
|
| [`vintage-poster`](./vintage-poster.md) | Mid-century flat blocks, halftone, retro-geometric warmth | Heritage, hospitality, cultural, anniversaries | `vintage-poster` | core |
|
||||||
| [`paper-cut`](./paper-cut.md) | Layered cut-paper sheets, soft inter-layer shadow, tactile | Cultural / folk, children, festival, sustainability | `paper-cut` |
|
| [`paper-cut`](./paper-cut.md) | Layered cut-paper sheets, soft inter-layer shadow, tactile | Cultural / folk, children, festival, sustainability | `paper-cut` | core |
|
||||||
|
|
||||||
### 1.4 Hand-drawn / brush
|
### 1.4 Hand-drawn / brush
|
||||||
|
|
||||||
| Visual style | Character | Best for | Paired rendering |
|
| Visual style | Character | Best for | Paired rendering | Illus. |
|
||||||
|---|---|---|---|
|
|---|---|---|---|---|
|
||||||
| [`sketch-notes`](./sketch-notes.md) | Warm paper, doodle line work, soft pastel blocks | Education, training, onboarding, knowledge | `sketch-notes` |
|
| [`sketch-notes`](./sketch-notes.md) | Warm paper, doodle line work, soft pastel blocks | Education, training, onboarding, knowledge | `sketch-notes` | core |
|
||||||
| [`ink-notes`](./ink-notes.md) | Pale field, black hand-ink, sparse semantic accent | Methodology, before/after, manifestos | `ink-notes` |
|
| [`ink-notes`](./ink-notes.md) | Pale field, black hand-ink, sparse semantic accent | Methodology, before/after, manifestos | `ink-notes` | supportive |
|
||||||
| [`chalkboard`](./chalkboard.md) | Dark slate, chalk strokes, powdery pastel accents | Teaching, tutorials, classroom, academic | `chalkboard` |
|
| [`chalkboard`](./chalkboard.md) | Dark slate, chalk strokes, powdery pastel accents | Teaching, tutorials, classroom, academic | `chalkboard` | core |
|
||||||
| [`ink-wash`](./ink-wash.md) | Rice-paper whitespace, brush marks, seal accent, still | Cultural, philosophy, heritage, 新中式 | `ink-notes` / `watercolor` |
|
| [`ink-wash`](./ink-wash.md) | Rice-paper whitespace, brush marks, seal accent, still | Cultural, philosophy, heritage, 新中式 | `ink-notes` / `watercolor` | supportive |
|
||||||
|
|
||||||
### 1.5 Specialty
|
### 1.5 Specialty
|
||||||
|
|
||||||
| Visual style | Character | Best for | Paired rendering |
|
| Visual style | Character | Best for | Paired rendering | Illus. |
|
||||||
|---|---|---|---|
|
|---|---|---|---|---|
|
||||||
| [`pixel-art`](./pixel-art.md) | Strict pixel grid, blocky forms, limited palette, flat | Gaming, retro-tech, nostalgic, game-flavored | `pixel-art` |
|
| [`pixel-art`](./pixel-art.md) | Strict pixel grid, blocky forms, limited palette, flat | Gaming, retro-tech, nostalgic, game-flavored | `pixel-art` | core |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+4
@@ -31,3 +31,7 @@ Engineering schematic — thin line work on dark blueprint paper, isometric proj
|
|||||||
## 5. Paired image-rendering
|
## 5. Paired image-rendering
|
||||||
|
|
||||||
`blueprint` — lock it so AI imagery shares the schematic line-drawing aesthetic.
|
`blueprint` — lock it so AI imagery shares the schematic line-drawing aesthetic.
|
||||||
|
|
||||||
|
## 6. Illustration propensity
|
||||||
|
|
||||||
|
**supportive** — but note the natural illustration here is hand-drawn **SVG schematic line-work / annotated marks**, not raster spot sheets; reach for line diagrams over decorative cutouts. If raster spots are used at all, keep them schematic, sparse, and secondary to the SVG line work. With no user steer this is the default lean; an explicit user request wins either way, and `image_usage: none` writes no illustration rows.
|
||||||
|
|||||||
+4
@@ -32,3 +32,7 @@ Brutalist editorial newspaper. Wall-to-wall small type, irregular column widths,
|
|||||||
## 5. Paired image-rendering
|
## 5. Paired image-rendering
|
||||||
|
|
||||||
`screen-print` or `editorial` — halftone monochrome imagery that sits inside the newsprint aesthetic.
|
`screen-print` or `editorial` — halftone monochrome imagery that sits inside the newsprint aesthetic.
|
||||||
|
|
||||||
|
## 6. Illustration propensity
|
||||||
|
|
||||||
|
**supportive** — raw halftone cuts fit the newsprint density, but structure leads; use them sparingly, only where a page needs a visual jolt. With no user steer this is the default lean; an explicit user request wins either way, and `image_usage: none` writes no illustration rows.
|
||||||
|
|||||||
+4
@@ -30,3 +30,7 @@ Classroom chalkboard — a dark slate field, soft chalk-stroke line work, powder
|
|||||||
## 5. Paired image-rendering
|
## 5. Paired image-rendering
|
||||||
|
|
||||||
`chalkboard` — chalk-on-slate imagery with the same classroom feel.
|
`chalkboard` — chalk-on-slate imagery with the same classroom feel.
|
||||||
|
|
||||||
|
## 6. Illustration propensity
|
||||||
|
|
||||||
|
**core** — chalk-drawn marks and doodles are the classroom aesthetic; with no user steer, default to recommending a coherent spot-illustration family. Default *lean* only — heaviness and placement stay Strategist judgment, an explicit user request wins either way, and `image_usage: none` writes no illustration rows.
|
||||||
|
|||||||
+4
@@ -31,3 +31,7 @@ Dark canvas, luminous accents, geometric precision. For tech, AI, dev tools, dat
|
|||||||
## 5. Paired image-rendering
|
## 5. Paired image-rendering
|
||||||
|
|
||||||
`digital-dashboard` or `blueprint` — polished UI / technical-schematic look for AI images.
|
`digital-dashboard` or `blueprint` — polished UI / technical-schematic look for AI images.
|
||||||
|
|
||||||
|
## 6. Illustration propensity
|
||||||
|
|
||||||
|
**sparse** — glow accents and geometric precision lead, and raster spots tend to read as clip-art against the dark UI. With no user steer, default to none. If the user explicitly asks, prefer restrained geometric marks over illustrated characters. `image_usage: none` writes no illustration rows.
|
||||||
|
|||||||
+4
@@ -30,3 +30,7 @@ Bloomberg / Economist news-infographic — publication-grade information density
|
|||||||
## 5. Paired image-rendering
|
## 5. Paired image-rendering
|
||||||
|
|
||||||
`editorial` — magazine-style infographic imagery sharing the data-publication aesthetic.
|
`editorial` — magazine-style infographic imagery sharing the data-publication aesthetic.
|
||||||
|
|
||||||
|
## 6. Illustration propensity
|
||||||
|
|
||||||
|
**sparse** — charts, sidebars, and data density carry the page; decorative spots undercut the serious data-publication tone. With no user steer, default to none. If the user explicitly asks, keep them minimal and journalistic. `image_usage: none` writes no illustration rows.
|
||||||
|
|||||||
+4
@@ -32,3 +32,7 @@ Magazine-grade hierarchy. Columns, hairline rules, a serif / sans interplay, str
|
|||||||
## 5. Paired image-rendering
|
## 5. Paired image-rendering
|
||||||
|
|
||||||
`editorial` — magazine-style infographic look for AI images.
|
`editorial` — magazine-style infographic look for AI images.
|
||||||
|
|
||||||
|
## 6. Illustration propensity
|
||||||
|
|
||||||
|
**supportive** — editorial spot illustrations beside articles are classic here; use them where they lift a section or break text density, kept restrained. With no user steer this is the default lean; an explicit user request wins either way, and `image_usage: none` writes no illustration rows.
|
||||||
|
|||||||
+4
@@ -30,3 +30,7 @@ Frosted-glass SaaS — translucent layered panels, flowing gradient light, float
|
|||||||
## 5. Paired image-rendering
|
## 5. Paired image-rendering
|
||||||
|
|
||||||
`glassmorphism` — frosted translucent panels / soft-gradient imagery matching the glass surfaces.
|
`glassmorphism` — frosted translucent panels / soft-gradient imagery matching the glass surfaces.
|
||||||
|
|
||||||
|
## 6. Illustration propensity
|
||||||
|
|
||||||
|
**sparse** — translucent panels, gradient light, and floating depth carry the look; decorative spots compete with the glass. With no user steer, default to none. If the user explicitly asks, keep them minimal and let them sit behind / under the glass rather than on top. `image_usage: none` writes no illustration rows.
|
||||||
|
|||||||
+4
@@ -31,3 +31,7 @@ Whiteboard-ink minimalism — a pale field, confident black hand-ink line work,
|
|||||||
## 5. Paired image-rendering
|
## 5. Paired image-rendering
|
||||||
|
|
||||||
`ink-notes` — black-ink visual-note imagery on a clean field, matching the considered hand-drawn aesthetic.
|
`ink-notes` — black-ink visual-note imagery on a clean field, matching the considered hand-drawn aesthetic.
|
||||||
|
|
||||||
|
## 6. Illustration propensity
|
||||||
|
|
||||||
|
**supportive** — the style is already sparse and considered; a few hand-ink spots fit, but restraint is its temperament — use only where one genuinely earns its place. With no user steer this is the default lean; an explicit user request wins either way, and `image_usage: none` writes no illustration rows.
|
||||||
|
|||||||
+4
@@ -30,3 +30,7 @@ New-Chinese ink-wash — a rice-paper field, vast literati whitespace, restraine
|
|||||||
## 5. Paired image-rendering
|
## 5. Paired image-rendering
|
||||||
|
|
||||||
`ink-notes` or `watercolor` — mono-ink or soft-wash imagery that shares the literati restraint.
|
`ink-notes` or `watercolor` — mono-ink or soft-wash imagery that shares the literati restraint.
|
||||||
|
|
||||||
|
## 6. Illustration propensity
|
||||||
|
|
||||||
|
**supportive** — a single brush mark or seal can anchor a page, but stillness and whitespace lead; use very few, very quiet. With no user steer this is the default lean; an explicit user request wins either way, and `image_usage: none` writes no illustration rows.
|
||||||
|
|||||||
@@ -31,3 +31,7 @@ Memphis / Pop — clashing color blocks, geometric confetti, bold outlines, 80s-
|
|||||||
## 5. Paired image-rendering
|
## 5. Paired image-rendering
|
||||||
|
|
||||||
`flat` — clean vivid flat-color illustration that matches the pop-block energy.
|
`flat` — clean vivid flat-color illustration that matches the pop-block energy.
|
||||||
|
|
||||||
|
## 6. Illustration propensity
|
||||||
|
|
||||||
|
**core** — bold-outline spots, confetti, and geometric shapes *are* the Memphis language; with no user steer, default to recommending a coherent spot-illustration family. Default *lean* only — heaviness and placement stay Strategist judgment, an explicit user request wins either way, and `image_usage: none` writes no illustration rows.
|
||||||
|
|||||||
+4
@@ -30,3 +30,7 @@ Layered paper-craft — scissor-cut shapes stacked in tactile layers, soft shado
|
|||||||
## 5. Paired image-rendering
|
## 5. Paired image-rendering
|
||||||
|
|
||||||
`paper-cut` — layered cut-paper imagery sharing the tactile, hand-made depth.
|
`paper-cut` — layered cut-paper imagery sharing the tactile, hand-made depth.
|
||||||
|
|
||||||
|
## 6. Illustration propensity
|
||||||
|
|
||||||
|
**core** — layered cut-paper elements *are* the style, and the slicer's transparent cutouts match it natively; with no user steer, default to recommending a coherent spot-illustration family. Default *lean* only — heaviness and placement stay Strategist judgment, an explicit user request wins either way, and `image_usage: none` writes no illustration rows.
|
||||||
|
|||||||
+4
@@ -32,3 +32,7 @@ Photo-led editorial — large full-bleed photography dominates the page, text po
|
|||||||
## 5. Paired image-rendering
|
## 5. Paired image-rendering
|
||||||
|
|
||||||
`corporate-photo` — real editorial photography as the hero imagery the layout is built around.
|
`corporate-photo` — real editorial photography as the hero imagery the layout is built around.
|
||||||
|
|
||||||
|
## 6. Illustration propensity
|
||||||
|
|
||||||
|
**sparse** — photography *is* the visual; illustration competes with the hero image. With no user steer, default to none. If the user explicitly asks, keep them to tiny captional marks that never rival the photo. `image_usage: none` writes no illustration rows.
|
||||||
|
|||||||
+4
@@ -30,3 +30,7 @@
|
|||||||
## 5. Paired image-rendering
|
## 5. Paired image-rendering
|
||||||
|
|
||||||
`pixel-art` — 8-bit imagery on the same grid, sharing the retro-game aesthetic.
|
`pixel-art` — 8-bit imagery on the same grid, sharing the retro-game aesthetic.
|
||||||
|
|
||||||
|
## 6. Illustration propensity
|
||||||
|
|
||||||
|
**core** — pixel sprites and blocky icons *are* the style; with no user steer, default to recommending a coherent spot-illustration family. Default *lean* only — heaviness and placement stay Strategist judgment, an explicit user request wins either way, and `image_usage: none` writes no illustration rows.
|
||||||
|
|||||||
+4
@@ -30,3 +30,7 @@ Warm hand-drawn sketchnote — soft paper field, black ink doodle line work, gen
|
|||||||
## 5. Paired image-rendering
|
## 5. Paired image-rendering
|
||||||
|
|
||||||
`sketch-notes` — cream-paper hand-drawn imagery with soft pastel fills, sharing the friendly note aesthetic.
|
`sketch-notes` — cream-paper hand-drawn imagery with soft pastel fills, sharing the friendly note aesthetic.
|
||||||
|
|
||||||
|
## 6. Illustration propensity
|
||||||
|
|
||||||
|
**core** — doodle spots and hand-drawn marks are intrinsic here, not optional garnish; with no user steer, default to recommending a coherent spot-illustration family (the decoration this style is built on). This sets the default *lean* only: heaviness and placement stay Strategist judgment, an explicit user request wins either way, and `image_usage: none` writes no illustration rows.
|
||||||
|
|||||||
+4
@@ -31,3 +31,7 @@ Approachable and modern. Rounded cards, gentle elevation, friendly rhythm. For p
|
|||||||
## 5. Paired image-rendering
|
## 5. Paired image-rendering
|
||||||
|
|
||||||
`flat` — clean modern blocks for AI images. (For frosted-glass depth, see the dedicated [`glassmorphism`](./glassmorphism.md) style.)
|
`flat` — clean modern blocks for AI images. (For frosted-glass depth, see the dedicated [`glassmorphism`](./glassmorphism.md) style.)
|
||||||
|
|
||||||
|
## 6. Illustration propensity
|
||||||
|
|
||||||
|
**supportive** — friendly rounded spots suit this approachable style; use them where they lift rhythm or section warmth, kept restrained. With no user steer this is the default lean; an explicit user request wins either way, and `image_usage: none` writes no illustration rows.
|
||||||
|
|||||||
+4
@@ -32,3 +32,7 @@ Strict Swiss-grid discipline. Modular grid, sharp geometry, aggressive whitespac
|
|||||||
## 5. Paired image-rendering
|
## 5. Paired image-rendering
|
||||||
|
|
||||||
`minimalist-swiss` — lock it for AI images so illustrations share the grid-austere aesthetic.
|
`minimalist-swiss` — lock it for AI images so illustrations share the grid-austere aesthetic.
|
||||||
|
|
||||||
|
## 6. Illustration propensity
|
||||||
|
|
||||||
|
**sparse** — the whitespace and grid *are* the design; decorative spots compete with that restraint, so with no user steer, default to none. If the user explicitly asks, use them — but stay austere: very few, very light, never crowding the negative space. `image_usage: none` writes no illustration rows.
|
||||||
|
|||||||
+4
@@ -30,3 +30,7 @@ Mid-century print poster (1950s–1970s) — bold rounded-geometric shapes, limi
|
|||||||
## 5. Paired image-rendering
|
## 5. Paired image-rendering
|
||||||
|
|
||||||
`vintage-poster` — mid-century poster imagery with matching halftone and retro-geometric warmth.
|
`vintage-poster` — mid-century poster imagery with matching halftone and retro-geometric warmth.
|
||||||
|
|
||||||
|
## 6. Illustration propensity
|
||||||
|
|
||||||
|
**core** — mid-century spot motifs and halftone cuts are intrinsic to the poster look; with no user steer, default to recommending a coherent spot-illustration family. Default *lean* only — heaviness and placement stay Strategist judgment, an explicit user request wins either way, and `image_usage: none` writes no illustration rows.
|
||||||
|
|||||||
@@ -31,3 +31,7 @@ Risograph zine / DIY poster — misregistered color layers, halftone dots, a tig
|
|||||||
## 5. Paired image-rendering
|
## 5. Paired image-rendering
|
||||||
|
|
||||||
`screen-print` — duotone / spot-ink imagery that shares the riso print aesthetic.
|
`screen-print` — duotone / spot-ink imagery that shares the riso print aesthetic.
|
||||||
|
|
||||||
|
## 6. Illustration propensity
|
||||||
|
|
||||||
|
**core** — riso / halftone spot imagery is part of the zine's print grit; with no user steer, default to recommending a coherent spot-illustration family. Default *lean* only — heaviness and placement stay Strategist judgment, an explicit user request wins either way, and `image_usage: none` writes no illustration rows.
|
||||||
|
|||||||
@@ -109,6 +109,8 @@ python3 scripts/finalize_svg.py <project_path>
|
|||||||
python3 scripts/svg_to_pptx.py <project_path>
|
python3 scripts/svg_to_pptx.py <project_path>
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`finalize_svg.py` optimizes raster images by default using `2x` display pixels and max `2560px`. Native `svg_to_pptx.py` defaults to `--image-sizing cap`: only oversized full source images are reduced to max `2560px`, so later PowerPoint resizing keeps more image detail. Use `svg_to_pptx.py --image-sizing display --image-scale 2` only for aggressive size reduction, or `--no-image-optimize` when the native PPTX must embed original image bytes.
|
||||||
|
|
||||||
Image generation:
|
Image generation:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
+166
-46
@@ -51,7 +51,10 @@
|
|||||||
image_strategy_visual: "Visual",
|
image_strategy_visual: "Visual",
|
||||||
image_strategy_color: "Color",
|
image_strategy_color: "Color",
|
||||||
image_strategy_mood: "Mood",
|
image_strategy_mood: "Mood",
|
||||||
image_usage_custom_required: "Describe the custom image plan before confirming.",
|
image_usage_notes: "Additional image requirements",
|
||||||
|
image_usage_notes_placeholder: "e.g. realistic handwashing scenes; avoid cartoon germs; keep product photos untouched.",
|
||||||
|
image_usage_required: "Select at least one image usage option.",
|
||||||
|
image_usage_none_exclusive: "No images cannot be combined with other image options.",
|
||||||
font_heading: "Heading",
|
font_heading: "Heading",
|
||||||
font_body: "Body",
|
font_body: "Body",
|
||||||
font_body_size: "Body baseline size",
|
font_body_size: "Body baseline size",
|
||||||
@@ -77,8 +80,10 @@
|
|||||||
role_body_text: "body text",
|
role_body_text: "body text",
|
||||||
cjk: "CJK",
|
cjk: "CJK",
|
||||||
latin: "Latin",
|
latin: "Latin",
|
||||||
sample_cjk: "数字化转型战略",
|
sample_heading_cjk: "主题方案标题",
|
||||||
sample_latin: "Digital Transformation",
|
sample_heading_latin: "Presentation Title",
|
||||||
|
sample_body_cjk: "关键信息摘要",
|
||||||
|
sample_body_latin: "Key message summary",
|
||||||
style_preview_label: "Overall impression (color + typography)",
|
style_preview_label: "Overall impression (color + typography)",
|
||||||
style_preview_body: "· rough feel only, not the actual slide layout",
|
style_preview_body: "· rough feel only, not the actual slide layout",
|
||||||
mode_continuous_desc: "Generate the whole deck in one pass.",
|
mode_continuous_desc: "Generate the whole deck in one pass.",
|
||||||
@@ -131,7 +136,10 @@
|
|||||||
image_strategy_visual: "视觉",
|
image_strategy_visual: "视觉",
|
||||||
image_strategy_color: "色彩",
|
image_strategy_color: "色彩",
|
||||||
image_strategy_mood: "情绪",
|
image_strategy_mood: "情绪",
|
||||||
image_usage_custom_required: "请先写清楚自定义图片方案。",
|
image_usage_notes: "图片补充要求",
|
||||||
|
image_usage_notes_placeholder: "例如:优先真实洗手场景;不要卡通病菌;产品照片保持原样。",
|
||||||
|
image_usage_required: "请至少选择一种图片使用方式。",
|
||||||
|
image_usage_none_exclusive: "「不使用图片」不能和其它图片选项同时选择。",
|
||||||
font_heading: "标题",
|
font_heading: "标题",
|
||||||
font_body: "正文",
|
font_body: "正文",
|
||||||
font_body_size: "正文基准字号",
|
font_body_size: "正文基准字号",
|
||||||
@@ -157,8 +165,10 @@
|
|||||||
role_body_text: "正文文字",
|
role_body_text: "正文文字",
|
||||||
cjk: "中文",
|
cjk: "中文",
|
||||||
latin: "西文",
|
latin: "西文",
|
||||||
sample_cjk: "数字化转型战略",
|
sample_heading_cjk: "主题方案标题",
|
||||||
sample_latin: "Digital Transformation",
|
sample_heading_latin: "Presentation Title",
|
||||||
|
sample_body_cjk: "关键信息摘要",
|
||||||
|
sample_body_latin: "Key message summary",
|
||||||
style_preview_label: "整体形象(配色 + 字体)",
|
style_preview_label: "整体形象(配色 + 字体)",
|
||||||
style_preview_body: "· 仅大致形象,非实际版式",
|
style_preview_body: "· 仅大致形象,非实际版式",
|
||||||
mode_continuous_desc: "一次性连续生成整份演示文稿。",
|
mode_continuous_desc: "一次性连续生成整份演示文稿。",
|
||||||
@@ -277,11 +287,21 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function normalizeRecId(field, value) {
|
function normalizeRecId(field, value) {
|
||||||
|
if (Array.isArray(value)) return normalizeRecId(field, value[0]);
|
||||||
if (value == null || value === "") return value;
|
if (value == null || value === "") return value;
|
||||||
var aliases = REC_ALIASES[field] || {};
|
var aliases = REC_ALIASES[field] || {};
|
||||||
return aliases[value] || value;
|
return aliases[value] || value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeRecIds(field, value) {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value.map(function (item) { return normalizeRecId(field, item); })
|
||||||
|
.filter(function (item, idx, arr) { return item && arr.indexOf(item) === idx; });
|
||||||
|
}
|
||||||
|
var normalized = normalizeRecId(field, value);
|
||||||
|
return normalized ? [normalized] : [];
|
||||||
|
}
|
||||||
|
|
||||||
function legacyRecId(field) {
|
function legacyRecId(field) {
|
||||||
if (!REC) return null;
|
if (!REC) return null;
|
||||||
if (field === "canvas") return REC.canvas && REC.canvas.value;
|
if (field === "canvas") return REC.canvas && REC.canvas.value;
|
||||||
@@ -298,6 +318,10 @@
|
|||||||
var value = (REC && REC.recommend && REC.recommend[field]) || legacyRecId(field);
|
var value = (REC && REC.recommend && REC.recommend[field]) || legacyRecId(field);
|
||||||
return normalizeRecId(field, value || null);
|
return normalizeRecId(field, value || null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function recValue(field) {
|
||||||
|
return (REC && REC.recommend && REC.recommend[field]) || legacyRecId(field);
|
||||||
|
}
|
||||||
// Guaranteed recommendation: the AI's pick, or the first catalog option as a
|
// Guaranteed recommendation: the AI's pick, or the first catalog option as a
|
||||||
// fallback so an enumerable field ALWAYS shows a badged recommendation.
|
// fallback so an enumerable field ALWAYS shows a badged recommendation.
|
||||||
function recOrFirst(field, list) {
|
function recOrFirst(field, list) {
|
||||||
@@ -305,10 +329,9 @@
|
|||||||
if (r != null && r !== "") return r;
|
if (r != null && r !== "") return r;
|
||||||
return firstId(list);
|
return firstId(list);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Render an enumerable field: ALL options from the catalog, recommended one
|
// Render an enumerable field: ALL options from the catalog, recommended one
|
||||||
// badged, current selection from STATE, plus a trailing Custom box.
|
// badged, current selection from STATE, plus a trailing Custom box.
|
||||||
// `list` is either a flat array of {id,label,desc,dim} or a grouped array
|
// `list` is either a flat array of {id,label,desc,dim,viewbox} or a grouped array
|
||||||
// of {group, items:[...]}.
|
// of {group, items:[...]}.
|
||||||
function enumField(parent, list, recommendedId, getVal, setVal, opts2) {
|
function enumField(parent, list, recommendedId, getVal, setVal, opts2) {
|
||||||
list = list || [];
|
list = list || [];
|
||||||
@@ -346,12 +369,16 @@
|
|||||||
function deselect() { allChips.forEach(function (c) { c.classList.remove("selected"); }); }
|
function deselect() { allChips.forEach(function (c) { c.classList.remove("selected"); }); }
|
||||||
function makeChip(o) {
|
function makeChip(o) {
|
||||||
var label = optionLabel(o);
|
var label = optionLabel(o);
|
||||||
if (o.dim) label += " · " + o.dim;
|
|
||||||
var desc = optionDesc(o);
|
var desc = optionDesc(o);
|
||||||
if (desc) label += (LANG === "zh" ? ":" : " — ") + desc;
|
|
||||||
var spec = specById[o.id];
|
var spec = specById[o.id];
|
||||||
if (spec && spec.note) label += " · " + spec.note;
|
|
||||||
var chip = el("div", "chip");
|
var chip = el("div", "chip");
|
||||||
|
if (o.viewbox) {
|
||||||
|
label = label + (o.dim ? " · " + o.dim : "");
|
||||||
|
} else {
|
||||||
|
if (o.dim) label += " · " + o.dim;
|
||||||
|
if (desc) label += (LANG === "zh" ? ":" : " — ") + desc;
|
||||||
|
if (spec && spec.note) label += " · " + spec.note;
|
||||||
|
}
|
||||||
chip.appendChild(el("span", "chip-text", label));
|
chip.appendChild(el("span", "chip-text", label));
|
||||||
if (spec) {
|
if (spec) {
|
||||||
// spectrum pick: badge shows its temperament tag, not the generic ★
|
// spectrum pick: badge shows its temperament tag, not the generic ★
|
||||||
@@ -453,7 +480,17 @@
|
|||||||
function normTypography(c) {
|
function normTypography(c) {
|
||||||
c = c || {};
|
c = c || {};
|
||||||
if (c.heading && typeof c.heading === "object" && c.body && typeof c.body === "object") {
|
if (c.heading && typeof c.heading === "object" && c.body && typeof c.body === "object") {
|
||||||
return Object.assign({}, c, { body_size: typographyBodySize(c) });
|
return Object.assign({}, c, {
|
||||||
|
body_size: typographyBodySize(c),
|
||||||
|
heading: Object.assign({}, c.heading, {
|
||||||
|
sample_cjk: c.heading.sample_cjk || c.sample_heading || "",
|
||||||
|
sample_latin: c.heading.sample_latin || c.sample_heading_latin || ""
|
||||||
|
}),
|
||||||
|
body: Object.assign({}, c.body, {
|
||||||
|
sample_cjk: c.body.sample_cjk || c.sample_body || "",
|
||||||
|
sample_latin: c.body.sample_latin || c.sample_body_latin || ""
|
||||||
|
})
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
name: c.name || "",
|
name: c.name || "",
|
||||||
@@ -500,6 +537,7 @@
|
|||||||
|
|
||||||
function usesCustomImagePlanValue(value) {
|
function usesCustomImagePlanValue(value) {
|
||||||
var ids = (CAT.image_usage || []).map(function (item) { return item.id; });
|
var ids = (CAT.image_usage || []).map(function (item) { return item.id; });
|
||||||
|
if (Array.isArray(value)) return false;
|
||||||
return value && ids.indexOf(value) === -1;
|
return value && ids.indexOf(value) === -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -508,9 +546,30 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function needsGeneratedImagesForUsage(value) {
|
function needsGeneratedImagesForUsage(value) {
|
||||||
|
if (Array.isArray(value)) return value.indexOf("ai") >= 0;
|
||||||
return value === "ai" || (usesCustomImagePlanValue(value) && customImagePlanHasAiSignal());
|
return value === "ai" || (usesCustomImagePlanValue(value) && customImagePlanHasAiSignal());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function selectedImageUsageIds(value) {
|
||||||
|
var validIds = (CAT.image_usage || []).map(function (item) { return item.id; });
|
||||||
|
return normalizeRecIds("image_usage", value).filter(function (id) {
|
||||||
|
return validIds.indexOf(id) >= 0;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function imageUsageNotesRecommendation(rawUsage) {
|
||||||
|
var notes = (REC && REC.image_notes && REC.image_notes.value) ||
|
||||||
|
(REC && REC.image_notes) ||
|
||||||
|
(REC && REC.images && REC.images.notes) ||
|
||||||
|
"";
|
||||||
|
if (!notes && usesCustomImagePlanValue(rawUsage)) notes = rawUsage;
|
||||||
|
return typeof notes === "string" ? notes : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultImageUsageId() {
|
||||||
|
return firstId(CAT.image_usage);
|
||||||
|
}
|
||||||
|
|
||||||
function imageStrategySelectedIndex() {
|
function imageStrategySelectedIndex() {
|
||||||
var spec = imageStrategySpec();
|
var spec = imageStrategySpec();
|
||||||
var idx = spec.selected || 0;
|
var idx = spec.selected || 0;
|
||||||
@@ -841,10 +900,35 @@
|
|||||||
return primary + ", " + fallback;
|
return primary + ", " + fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
function fontSample(box, slot, css) {
|
function sampleCandidate(role, script) {
|
||||||
|
var sample = (REC && REC.sample_text) || (REC && REC.samples) || {};
|
||||||
|
var isHeading = role === "heading";
|
||||||
|
var isLatin = script === "latin";
|
||||||
|
var keys = isHeading
|
||||||
|
? (isLatin ? ["heading_latin", "sample_heading_latin", "title_latin", "title_en"] : ["heading_cjk", "sample_heading", "sample_heading_cjk", "title_zh", "title"])
|
||||||
|
: (isLatin ? ["body_latin", "sample_body_latin", "summary_latin", "summary_en"] : ["body_cjk", "sample_body", "sample_body_cjk", "summary_zh", "summary"]);
|
||||||
|
for (var i = 0; i < keys.length; i += 1) {
|
||||||
|
if (sample[keys[i]]) return sample[keys[i]];
|
||||||
|
if (REC && REC[keys[i]]) {
|
||||||
|
if (typeof REC[keys[i]] === "object" && REC[keys[i]].value) return REC[keys[i]].value;
|
||||||
|
if (typeof REC[keys[i]] === "string") return REC[keys[i]];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function sampleText(role, script, explicit) {
|
||||||
|
if (explicit) return explicit;
|
||||||
|
var fromRec = sampleCandidate(role, script);
|
||||||
|
if (fromRec) return fromRec;
|
||||||
|
if (role === "heading") return t(script === "latin" ? "sample_heading_latin" : "sample_heading_cjk");
|
||||||
|
return t(script === "latin" ? "sample_body_latin" : "sample_body_cjk");
|
||||||
|
}
|
||||||
|
|
||||||
|
function fontSample(box, slot, css, role) {
|
||||||
var line = el("div", "font-sample-line");
|
var line = el("div", "font-sample-line");
|
||||||
var cjk = el("span", "fs-cjk", slot.sample_cjk || t("sample_cjk"));
|
var cjk = el("span", "fs-cjk", sampleText(role, "cjk", slot.sample_cjk));
|
||||||
var lat = el("span", "fs-latin", slot.sample_latin || t("sample_latin"));
|
var lat = el("span", "fs-latin", sampleText(role, "latin", slot.sample_latin));
|
||||||
var cjkStack = previewFontStack(slot.cjk, css);
|
var cjkStack = previewFontStack(slot.cjk, css);
|
||||||
var latinStack = previewFontStack(slot.latin, css);
|
var latinStack = previewFontStack(slot.latin, css);
|
||||||
if (cjkStack) cjk.style.fontFamily = cjkStack;
|
if (cjkStack) cjk.style.fontFamily = cjkStack;
|
||||||
@@ -910,8 +994,8 @@
|
|||||||
if (c.body_size) meta += " · " + t("font_body_size") + ":" + c.body_size + "px";
|
if (c.body_size) meta += " · " + t("font_body_size") + ":" + c.body_size + "px";
|
||||||
top.appendChild(el("span", "font-card-meta", meta));
|
top.appendChild(el("span", "font-card-meta", meta));
|
||||||
card.appendChild(top);
|
card.appendChild(top);
|
||||||
var hbox = el("div", "font-sample-heading-box"); fontSample(hbox, head, head.css); card.appendChild(hbox);
|
var hbox = el("div", "font-sample-heading-box"); fontSample(hbox, head, head.css, "heading"); card.appendChild(hbox);
|
||||||
var bbox = el("div", "font-sample-body-box"); fontSample(bbox, body, body.css); card.appendChild(bbox);
|
var bbox = el("div", "font-sample-body-box"); fontSample(bbox, body, body.css, "body"); card.appendChild(bbox);
|
||||||
if (localized(c, "note")) card.appendChild(el("div", "color-note", localized(c, "note")));
|
if (localized(c, "note")) card.appendChild(el("div", "color-note", localized(c, "note")));
|
||||||
card.addEventListener("click", function () { selectFont(idx); });
|
card.addEventListener("click", function () { selectFont(idx); });
|
||||||
grid.appendChild(card);
|
grid.appendChild(card);
|
||||||
@@ -1106,16 +1190,16 @@
|
|||||||
var bodyLatStack = previewFontStack(body.latin, body.css);
|
var bodyLatStack = previewFontStack(body.latin, body.css);
|
||||||
|
|
||||||
card.style.background = bg;
|
card.style.background = bg;
|
||||||
titleCjk.textContent = head.sample_cjk || t("sample_cjk");
|
titleCjk.textContent = sampleText("heading", "cjk", head.sample_cjk);
|
||||||
titleLat.textContent = head.sample_latin || t("sample_latin");
|
titleLat.textContent = sampleText("heading", "latin", head.sample_latin);
|
||||||
title.style.color = pri;
|
title.style.color = pri;
|
||||||
title.style.fontSize = Math.round(bodyPx * 1.7) + "px";
|
title.style.fontSize = Math.round(bodyPx * 1.7) + "px";
|
||||||
titleCjk.style.fontFamily = headStack || "";
|
titleCjk.style.fontFamily = headStack || "";
|
||||||
titleLat.style.fontFamily = headLatStack || "";
|
titleLat.style.fontFamily = headLatStack || "";
|
||||||
// CJK and Latin previewed with their own stacks (mirrors the title
|
// CJK and Latin previewed with their own stacks (mirrors the title
|
||||||
// and the per-card font samples) so each script's font is visible.
|
// and the per-card font samples) so each script's font is visible.
|
||||||
bodyCjk.textContent = body.sample_cjk || t("sample_cjk");
|
bodyCjk.textContent = sampleText("body", "cjk", body.sample_cjk);
|
||||||
bodyLat.textContent = body.sample_latin || t("sample_latin");
|
bodyLat.textContent = sampleText("body", "latin", body.sample_latin);
|
||||||
bodyWrap.style.color = txt;
|
bodyWrap.style.color = txt;
|
||||||
bodyWrap.style.fontSize = bodyPx + "px";
|
bodyWrap.style.fontSize = bodyPx + "px";
|
||||||
bodyCjk.style.fontFamily = bodyStack || "";
|
bodyCjk.style.fontFamily = bodyStack || "";
|
||||||
@@ -1132,15 +1216,20 @@
|
|||||||
|
|
||||||
function renderImages(host) {
|
function renderImages(host) {
|
||||||
var sec = section(8, "sec_images");
|
var sec = section(8, "sec_images");
|
||||||
|
var usageChips = el("div", "chips");
|
||||||
|
var usageNote = el("div", "subfield");
|
||||||
|
usageNote.appendChild(el("div", "subfield-label", t("image_usage_notes")));
|
||||||
|
var usageNoteInput = el("textarea", "text-input image-usage-notes-input");
|
||||||
|
usageNoteInput.placeholder = t("image_usage_notes_placeholder");
|
||||||
|
usageNoteInput.value = STATE.image_notes || "";
|
||||||
|
usageNoteInput.addEventListener("input", function () { STATE.image_notes = usageNoteInput.value; });
|
||||||
|
usageNote.appendChild(usageNoteInput);
|
||||||
var sub = el("div", "subfield");
|
var sub = el("div", "subfield");
|
||||||
sub.appendChild(el("div", "subfield-label", t("image_ai_path")));
|
sub.appendChild(el("div", "subfield-label", t("image_ai_path")));
|
||||||
var strategySub = el("div", "subfield image-strategy-subfield");
|
var strategySub = el("div", "subfield image-strategy-subfield");
|
||||||
strategySub.appendChild(el("div", "subfield-label", t("image_strategy")));
|
strategySub.appendChild(el("div", "subfield-label", t("image_strategy")));
|
||||||
var strategyGrid = el("div", "font-grid");
|
var strategyGrid = el("div", "font-grid");
|
||||||
var strategyCands = imageStrategyCandidates();
|
var strategyCands = imageStrategyCandidates();
|
||||||
function usesCustomImagePlan() {
|
|
||||||
return usesCustomImagePlanValue(STATE.image_usage);
|
|
||||||
}
|
|
||||||
function needsGeneratedImages() {
|
function needsGeneratedImages() {
|
||||||
return needsGeneratedImagesForUsage(STATE.image_usage);
|
return needsGeneratedImagesForUsage(STATE.image_usage);
|
||||||
}
|
}
|
||||||
@@ -1182,27 +1271,51 @@
|
|||||||
});
|
});
|
||||||
if (!strategyCands.length) strategyGrid.appendChild(el("div", "toggle-desc", t("image_strategy_empty")));
|
if (!strategyCands.length) strategyGrid.appendChild(el("div", "toggle-desc", t("image_strategy_empty")));
|
||||||
strategySub.appendChild(strategyGrid);
|
strategySub.appendChild(strategyGrid);
|
||||||
enumField(sec, CAT.image_usage, recOrFirst("image_usage", CAT.image_usage),
|
var recommendedIds = selectedImageUsageIds(recValue("image_usage"));
|
||||||
function () { return STATE.image_usage; },
|
if (!recommendedIds.length) recommendedIds = [defaultImageUsageId()];
|
||||||
function (v) {
|
var usageChipById = {};
|
||||||
STATE.image_usage = v;
|
function refreshUsageChips() {
|
||||||
refreshAiControls();
|
Object.keys(usageChipById).forEach(function (id) {
|
||||||
},
|
usageChipById[id].classList.toggle("selected", STATE.image_usage.indexOf(id) >= 0);
|
||||||
{
|
|
||||||
allowCustom: true,
|
|
||||||
customSentinel: "custom",
|
|
||||||
customInvalidValues: ["custom"],
|
|
||||||
inputClass: "image-usage-custom-input",
|
|
||||||
placeholder: LANG === "zh"
|
|
||||||
? "例如:封面用 AI 生成,产品页用用户素材,行业页用网络来源"
|
|
||||||
: "e.g. AI cover + user product assets + web industry images"
|
|
||||||
});
|
});
|
||||||
|
var noImages = STATE.image_usage.indexOf("none") >= 0;
|
||||||
|
usageNote.style.display = noImages ? "none" : "block";
|
||||||
|
refreshAiControls();
|
||||||
|
}
|
||||||
|
function toggleImageUsage(id) {
|
||||||
|
var cur = STATE.image_usage.slice();
|
||||||
|
if (id === "none") {
|
||||||
|
cur = cur.indexOf("none") >= 0 ? [] : ["none"];
|
||||||
|
} else {
|
||||||
|
cur = cur.filter(function (item) { return item !== "none"; });
|
||||||
|
if (cur.indexOf(id) >= 0) cur = cur.filter(function (item) { return item !== id; });
|
||||||
|
else cur.push(id);
|
||||||
|
}
|
||||||
|
STATE.image_usage = cur;
|
||||||
|
refreshUsageChips();
|
||||||
|
}
|
||||||
|
(CAT.image_usage || []).forEach(function (o) {
|
||||||
|
var label = optionLabel(o);
|
||||||
|
var desc = optionDesc(o);
|
||||||
|
if (desc) label += (LANG === "zh" ? ":" : " — ") + desc;
|
||||||
|
var chip = el("div", "chip");
|
||||||
|
chip.appendChild(el("span", "chip-text", label));
|
||||||
|
if (recommendedIds.indexOf(o.id) >= 0) {
|
||||||
|
chip.classList.add("recommended");
|
||||||
|
chip.appendChild(el("span", "rec-badge", "★ " + t("recommended")));
|
||||||
|
}
|
||||||
|
chip.addEventListener("click", function () { toggleImageUsage(o.id); });
|
||||||
|
usageChipById[o.id] = chip;
|
||||||
|
usageChips.appendChild(chip);
|
||||||
|
});
|
||||||
|
sec.appendChild(usageChips);
|
||||||
|
sec.appendChild(usageNote);
|
||||||
enumField(sub, CAT.image_ai_path, recOrFirst("image_ai_path", CAT.image_ai_path),
|
enumField(sub, CAT.image_ai_path, recOrFirst("image_ai_path", CAT.image_ai_path),
|
||||||
function () { return STATE.image_ai_path; }, function (v) { STATE.image_ai_path = v; });
|
function () { return STATE.image_ai_path; }, function (v) { STATE.image_ai_path = v; });
|
||||||
sec.appendChild(sub);
|
sec.appendChild(sub);
|
||||||
sec.appendChild(strategySub);
|
sec.appendChild(strategySub);
|
||||||
if (strategyCands.length) selectImageStrategy(imageStrategySelectedIndex());
|
if (strategyCands.length) selectImageStrategy(imageStrategySelectedIndex());
|
||||||
refreshAiControls();
|
refreshUsageChips();
|
||||||
host.appendChild(sec);
|
host.appendChild(sec);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1331,7 +1444,12 @@
|
|||||||
};
|
};
|
||||||
STATE.formula_policy = pick("formula_policy", CAT.formula_policy);
|
STATE.formula_policy = pick("formula_policy", CAT.formula_policy);
|
||||||
|
|
||||||
STATE.image_usage = pick("image_usage", CAT.image_usage);
|
var rawImageUsage = recValue("image_usage");
|
||||||
|
STATE.image_usage = selectedImageUsageIds(rawImageUsage);
|
||||||
|
if (!STATE.image_usage.length) {
|
||||||
|
STATE.image_usage = [defaultImageUsageId()];
|
||||||
|
}
|
||||||
|
STATE.image_notes = imageUsageNotesRecommendation(rawImageUsage);
|
||||||
STATE.image_ai_path = pick("image_ai_path", CAT.image_ai_path);
|
STATE.image_ai_path = pick("image_ai_path", CAT.image_ai_path);
|
||||||
|
|
||||||
STATE.generation_mode = pick("generation_mode", CAT.generation_mode);
|
STATE.generation_mode = pick("generation_mode", CAT.generation_mode);
|
||||||
@@ -1429,14 +1547,16 @@
|
|||||||
var payload = JSON.parse(JSON.stringify(STATE));
|
var payload = JSON.parse(JSON.stringify(STATE));
|
||||||
normalizeTypographyForSubmit(payload);
|
normalizeTypographyForSubmit(payload);
|
||||||
payload.stage = "final";
|
payload.stage = "final";
|
||||||
var customImagePlan = usesCustomImagePlanValue(payload.image_usage);
|
payload.image_usage = selectedImageUsageIds(payload.image_usage);
|
||||||
if (payload.image_usage === "custom" || (customImagePlan && !String(payload.image_usage).trim())) {
|
if (!payload.image_usage.length) {
|
||||||
document.getElementById("confirm-status").textContent = t("image_usage_custom_required");
|
document.getElementById("confirm-status").textContent = t("image_usage_required");
|
||||||
var customImageInput = document.querySelector(".image-usage-custom-input");
|
|
||||||
if (customImageInput) customImageInput.focus();
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (customImagePlan) payload.image_usage = String(payload.image_usage).trim();
|
if (payload.image_usage.indexOf("none") >= 0 && payload.image_usage.length > 1) {
|
||||||
|
document.getElementById("confirm-status").textContent = t("image_usage_none_exclusive");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!String(payload.image_notes || "").trim()) delete payload.image_notes;
|
||||||
if (!needsGeneratedImagesForUsage(payload.image_usage)) {
|
if (!needsGeneratedImagesForUsage(payload.image_usage)) {
|
||||||
delete payload.image_ai_path;
|
delete payload.image_ai_path;
|
||||||
delete payload.image_strategy;
|
delete payload.image_strategy;
|
||||||
|
|||||||
+28
-20
@@ -4,72 +4,80 @@
|
|||||||
{
|
{
|
||||||
"id": "ppt169",
|
"id": "ppt169",
|
||||||
"label": "PPT 16:9",
|
"label": "PPT 16:9",
|
||||||
"label_zh": "宽屏演示 16:9",
|
"label_zh": "PPT 16:9",
|
||||||
"label_en": "PPT 16:9",
|
"label_en": "PPT 16:9",
|
||||||
"dim": "1280×720",
|
"dim": "1280×720",
|
||||||
|
"viewbox": "0 0 1280 720",
|
||||||
"use_zh": "现代投影 / 在线演示",
|
"use_zh": "现代投影 / 在线演示",
|
||||||
"use_en": "Modern projectors / online"
|
"use_en": "Modern projectors / online"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "ppt43",
|
"id": "ppt43",
|
||||||
"label": "PPT 4:3",
|
"label": "PPT 4:3",
|
||||||
"label_zh": "传统演示 4:3",
|
"label_zh": "PPT 4:3",
|
||||||
"label_en": "PPT 4:3",
|
"label_en": "PPT 4:3",
|
||||||
"dim": "1024×768",
|
"dim": "1024×768",
|
||||||
|
"viewbox": "0 0 1024 768",
|
||||||
"use_zh": "传统投影",
|
"use_zh": "传统投影",
|
||||||
"use_en": "Traditional projectors"
|
"use_en": "Traditional projectors"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "wechat",
|
"id": "wechat",
|
||||||
"label": "WeChat Article Header",
|
"label": "公众号头图 2.35:1",
|
||||||
"label_zh": "微信公众号头图",
|
"label_zh": "公众号头图 2.35:1",
|
||||||
"label_en": "WeChat Article Header",
|
"label_en": "WeChat Header 2.35:1",
|
||||||
"dim": "900×383",
|
"dim": "900×383",
|
||||||
|
"viewbox": "0 0 900 383",
|
||||||
"use_zh": "微信公众号文章封面",
|
"use_zh": "微信公众号文章封面",
|
||||||
"use_en": "WeChat article cover"
|
"use_en": "WeChat article cover"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "xiaohongshu",
|
"id": "xiaohongshu",
|
||||||
"label": "Xiaohongshu (RED)",
|
"label": "小红书 3:4",
|
||||||
"label_zh": "小红书图文",
|
"label_zh": "小红书 3:4",
|
||||||
"label_en": "Xiaohongshu (RED)",
|
"label_en": "RED 3:4",
|
||||||
"dim": "1242×1660",
|
"dim": "1242×1660",
|
||||||
|
"viewbox": "0 0 1242 1660",
|
||||||
"use_zh": "知识分享 / 种草封面",
|
"use_zh": "知识分享 / 种草封面",
|
||||||
"use_en": "Knowledge sharing"
|
"use_en": "Knowledge sharing"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "moments",
|
"id": "moments",
|
||||||
"label": "WeChat Moments / IG",
|
"label": "社交方图 1:1",
|
||||||
"label_zh": "社交方图",
|
"label_zh": "社交方图 1:1",
|
||||||
"label_en": "WeChat Moments / IG",
|
"label_en": "Social Square 1:1",
|
||||||
"dim": "1080×1080",
|
"dim": "1080×1080",
|
||||||
|
"viewbox": "0 0 1080 1080",
|
||||||
"use_zh": "社交媒体方图",
|
"use_zh": "社交媒体方图",
|
||||||
"use_en": "Social square"
|
"use_en": "Social square"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "story",
|
"id": "story",
|
||||||
"label": "Story / TikTok",
|
"label": "竖屏 9:16",
|
||||||
"label_zh": "竖屏短视频",
|
"label_zh": "竖屏 9:16",
|
||||||
"label_en": "Story / TikTok",
|
"label_en": "Vertical 9:16",
|
||||||
"dim": "1080×1920",
|
"dim": "1080×1920",
|
||||||
|
"viewbox": "0 0 1080 1920",
|
||||||
"use_zh": "短视频 / 竖屏封面",
|
"use_zh": "短视频 / 竖屏封面",
|
||||||
"use_en": "Short-video / story cover"
|
"use_en": "Short-video / story cover"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "banner",
|
"id": "banner",
|
||||||
"label": "Landscape Banner",
|
"label": "横幅 16:9",
|
||||||
"label_zh": "横幅",
|
"label_zh": "横幅 16:9",
|
||||||
"label_en": "Landscape Banner",
|
"label_en": "Banner 16:9",
|
||||||
"dim": "1920×1080",
|
"dim": "1920×1080",
|
||||||
|
"viewbox": "0 0 1920 1080",
|
||||||
"use_zh": "网页横幅 / 大屏",
|
"use_zh": "网页横幅 / 大屏",
|
||||||
"use_en": "Web banner / big screen"
|
"use_en": "Web banner / big screen"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "a4",
|
"id": "a4",
|
||||||
"label": "A4 Print",
|
"label": "A4 1:1.41",
|
||||||
"label_zh": "A4 打印",
|
"label_zh": "A4 1:1.41",
|
||||||
"label_en": "A4 Print",
|
"label_en": "A4 1:1.41",
|
||||||
"dim": "1240×1754",
|
"dim": "1240×1754",
|
||||||
|
"viewbox": "0 0 1240 1754",
|
||||||
"use_zh": "打印 / 文档导出",
|
"use_zh": "打印 / 文档导出",
|
||||||
"use_en": "Print / PDF export"
|
"use_en": "Print / PDF export"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -175,6 +175,7 @@ body {
|
|||||||
.font-sample-body { font-size: 14px; color: #333; margin-top: 4px; }
|
.font-sample-body { font-size: 14px; color: #333; margin-top: 4px; }
|
||||||
.custom-typography-input { margin-top: 8px; min-height: 58px; resize: vertical; }
|
.custom-typography-input { margin-top: 8px; min-height: 58px; resize: vertical; }
|
||||||
.custom-color-input { margin-top: 8px; min-height: 58px; resize: vertical; }
|
.custom-color-input { margin-top: 8px; min-height: 58px; resize: vertical; }
|
||||||
|
.image-usage-notes-input { min-height: 64px; resize: vertical; }
|
||||||
.font-size-row { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
|
.font-size-row { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
|
||||||
.font-size-input { width: 96px; }
|
.font-size-input { width: 96px; }
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Confirm UI — Eight Confirmations Page
|
# Confirm UI — Eight Confirmations Page
|
||||||
|
|
||||||
> The interactive, visual surface for SKILL.md Step 4 (the Eight Confirmations). Enumerable fields list **all** options from a catalog with the AI's recommendation badged; generative fields (color, typography, generated-image style) show **≥3** AI candidates (creative recommendations always offer real choice — same rule as the h.5 image strategy; fewer only on the honest-shortfall exception, with a stated reason). Fields whose universe is open (canvas, mode, visual style, icons, image usage) also get a **Custom** box; fully closed fields (AI source when applicable, formula policy, generation mode, refine spec) do not. The AI writes its recommendation to `recommendations.json`; the user's final choices are written back to `result.json` for the AI to read. On confirm the page saves the result and shuts the server down (auto-close). The chat path is always a valid fallback — if the browser cannot open (remote / headless / web host), the AI presents the same confirmations in chat.
|
> The interactive, visual surface for SKILL.md Step 4 (the Eight Confirmations). Enumerable fields list **all** options from a catalog with the AI's recommendation badged; generative fields (color, typography, generated-image style) show **≥3** AI candidates (creative recommendations always offer real choice — same rule as the h.5 image strategy; fewer only on the honest-shortfall exception, with a stated reason). Fields whose universe is open (canvas, mode, visual style, icons) also get a **Custom** box; image usage is a multi-select source list plus a free-text `image_notes` box. Fully closed fields (AI source when applicable, formula policy, generation mode, refine spec) do not. The AI writes its recommendation to `recommendations.json`; the user's final choices are written back to `result.json` for the AI to read. On confirm the page saves the result and shuts the server down (auto-close). The chat path is always a valid fallback — if the browser cannot open (remote / headless / web host), the AI presents the same confirmations in chat.
|
||||||
|
|
||||||
## `confirm_ui/server.py`
|
## `confirm_ui/server.py`
|
||||||
|
|
||||||
@@ -31,13 +31,14 @@ pip install flask
|
|||||||
|
|
||||||
## Two kinds of field
|
## Two kinds of field
|
||||||
|
|
||||||
- **Enumerable + custom** — canvas / mode / visual_style / icons / image usage. The page lists common options from `static/catalogs.json`, badges the AI's recommendation, and still offers a Custom box for edge cases (custom canvas size, bespoke narrative mode, mixed image plan, self-provided icon system, etc.). `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.
|
- **Enumerable + custom** — canvas / mode / visual_style / icons. 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, 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.
|
- **Image usage multi-select** — image sources are selected as one or more catalog ids: `ai` = AI-generated, `web` = Web-sourced, `provided` = User-provided, `placeholder` = Placeholder, `none` = No images. `none` is exclusive. Recommendation and result values may be a legacy single string, but new files should use an array. When several sources are recommended, write the source ids to `recommend.image_usage` and write the actual usage strategy to `image_notes`, not a custom prose value.
|
||||||
|
- **Closed enumerable** — formula policy / generation mode / refine spec, plus AI source only when image usage includes `ai`. These have no Custom box; out-of-catalog values snap back to the recommended option. Use pipeline vocabulary: icon ids are actual library ids such as `tabler-outline`, or `emoji` for system emoji.
|
||||||
- **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).
|
- **Generative (open)** — color, typography, generated-image style. No finite catalog; the AI authors **≥3 candidates** the page renders as cards (never a single option — creative fields must offer real choice; fewer than 3 only on the honest-shortfall exception). `page_count`, `audience`, and `content_divergence` are free inputs (`content_divergence` is a free-text intent shown under audience in §c, not a fixed-option field).
|
||||||
|
|
||||||
**Custom box** appears only on fields whose universe is genuinely open — `canvas`, `mode`, `visual_style`, `icons`, and `image_usage`. Fully closed sets — `image_ai_path`, `formula_policy`, `generation_mode`, `refine_spec` — have **no** Custom box; an out-of-catalog value there is snapped back to the recommended option.
|
**Custom box** appears only on fields whose universe is genuinely open — `canvas`, `mode`, `visual_style`, and `icons`. Image usage uses a multi-select source list plus `image_notes` instead of a Custom box. 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.
|
||||||
|
|
||||||
`image_ai_path` is conditional: the page shows it and writes it to `result.json` only when `image_usage` is `ai` or a custom image plan that may include AI. Web-sourced / User-provided / Placeholder / No images paths do not carry an AI backend choice.
|
`image_ai_path` is conditional: the page shows it and writes it to `result.json` only when `image_usage` includes `ai`. Web-sourced / User-provided / Placeholder / No images paths do not carry an AI backend choice.
|
||||||
|
|
||||||
## Catalogs — `static/catalogs.json` (the finite option universe)
|
## Catalogs — `static/catalogs.json` (the finite option universe)
|
||||||
|
|
||||||
@@ -54,7 +55,7 @@ The page runs as a **two-tier wizard in one browser session**. `recommendations.
|
|||||||
| `tier` | Page renders | Button | On submit |
|
| `tier` | Page renders | Button | On submit |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| `1` | anchors — canvas, audience + `content_divergence` + `delivery_purpose` *(PPT only — omitted on non-PPT canvases, not written to the result)* (all in the §c key-info area), mode + visual_style | **Next** | writes `result.json` `{ stage: "tier1", status: "tier1-confirmed", <anchors> }`; the page does **not** close — it shows a "deriving…" state and polls `GET /api/recommendations` |
|
| `1` | anchors — canvas, audience + `content_divergence` + `delivery_purpose` *(PPT only — omitted on non-PPT canvases, not written to the result)* (all in the §c key-info area), mode + visual_style | **Next** | writes `result.json` `{ stage: "tier1", status: "tier1-confirmed", <anchors> }`; the page does **not** close — it shows a "deriving…" state and polls `GET /api/recommendations` |
|
||||||
| `2` | realization — page count, color, typography, icons, formula, image usage + strategy, generation mode, refine spec | **Confirm** | writes `result.json` `{ stage: "final", status: "confirmed", <all fields> }`, then shuts the page down |
|
| `2` | realization — page count, color, typography, icons, formula, image usage + generated-image style, generation mode, refine spec | **Confirm** | writes `result.json` `{ stage: "final", status: "confirmed", <all fields> }`, then shuts the page down |
|
||||||
| *(absent)* | legacy single-pass — every section on one page | **Confirm** | single final write (`status: "confirmed"`) — backward-compatible |
|
| *(absent)* | legacy single-pass — every section on one page | **Confirm** | single final write (`status: "confirmed"`) — backward-compatible |
|
||||||
|
|
||||||
The AI launches Tier 1 (`--daemon --wait`), reads the tier-1 result, **re-derives** the realization candidates from the user's actual anchors, overwrites `recommendations.json` with `"tier": 2` (realization fields only — it need not echo the anchors), and re-attaches with `--wait-only`. The page preserves the user's Tier 1 selections across the transition (single JS session). `GET /api/recommendations` is served `no-store` so the poll sees the tier-2 overwrite, and when `tier == 2` the server **folds the confirmed anchors from `result.json` back into the payload** (`recommend.canvas` / `mode` / `visual_style` / `delivery_purpose`, plus `audience` / `content_divergence` values) — so a **page refresh / reopen on Tier 2 re-initializes the anchors from the user's choices** instead of catalog defaults, even though those sections are not rendered on the Tier-2 page.
|
The AI launches Tier 1 (`--daemon --wait`), reads the tier-1 result, **re-derives** the realization candidates from the user's actual anchors, overwrites `recommendations.json` with `"tier": 2` (realization fields only — it need not echo the anchors), and re-attaches with `--wait-only`. The page preserves the user's Tier 1 selections across the transition (single JS session). `GET /api/recommendations` is served `no-store` so the poll sees the tier-2 overwrite, and when `tier == 2` the server **folds the confirmed anchors from `result.json` back into the payload** (`recommend.canvas` / `mode` / `visual_style` / `delivery_purpose`, plus `audience` / `content_divergence` values) — so a **page refresh / reopen on Tier 2 re-initializes the anchors from the user's choices** instead of catalog defaults, even though those sections are not rendered on the Tier-2 page.
|
||||||
@@ -69,7 +70,8 @@ The AI launches Tier 1 (`--daemon --wait`), reads the tier-1 result, **re-derive
|
|||||||
"mode": "pyramid",
|
"mode": "pyramid",
|
||||||
"visual_style": "swiss-minimal",
|
"visual_style": "swiss-minimal",
|
||||||
"icons": "tabler-outline",
|
"icons": "tabler-outline",
|
||||||
"image_usage": "web",
|
"image_usage": ["ai", "provided"],
|
||||||
|
"image_ai_path": "auto",
|
||||||
"formula_policy": "mixed",
|
"formula_policy": "mixed",
|
||||||
"generation_mode": "continuous",
|
"generation_mode": "continuous",
|
||||||
"delivery_purpose": "balanced"
|
"delivery_purpose": "balanced"
|
||||||
@@ -77,6 +79,7 @@ The AI launches Tier 1 (`--daemon --wait`), reads the tier-1 result, **re-derive
|
|||||||
"page_count": { "value": "12-15" },
|
"page_count": { "value": "12-15" },
|
||||||
"audience": { "value": "..." },
|
"audience": { "value": "..." },
|
||||||
"content_divergence": { "value": "" },
|
"content_divergence": { "value": "" },
|
||||||
|
"image_notes": { "value": "封面和章节页用 AI 主视觉;产品页优先用户素材,缺口页可用占位符。" },
|
||||||
"color": {
|
"color": {
|
||||||
"selected": 0,
|
"selected": 0,
|
||||||
"candidates": [
|
"candidates": [
|
||||||
@@ -95,8 +98,10 @@ The AI launches Tier 1 (`--daemon --wait`), reads the tier-1 result, **re-derive
|
|||||||
"selected": 0,
|
"selected": 0,
|
||||||
"candidates": [
|
"candidates": [
|
||||||
{ "name": "...", "note": "...",
|
{ "name": "...", "note": "...",
|
||||||
"heading": { "cjk": "思源黑体", "latin": "Inter", "css": "'Source Han Sans SC','Inter',sans-serif", "sample_cjk": "标题示例", "sample_latin": "Heading Sample" },
|
"sample_heading": "主题标题示例", "sample_heading_latin": "Topic Title",
|
||||||
"body": { "cjk": "思源黑体", "latin": "Inter", "css": "...", "sample_cjk": "正文示例", "sample_latin": "Body sample" },
|
"sample_body": "关键信息摘要", "sample_body_latin": "Key message summary",
|
||||||
|
"heading": { "cjk": "思源黑体", "latin": "Inter", "css": "'Source Han Sans SC','Inter',sans-serif" },
|
||||||
|
"body": { "cjk": "思源黑体", "latin": "Inter", "css": "..." },
|
||||||
"body_size": 20 }
|
"body_size": 20 }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -124,16 +129,17 @@ The AI launches Tier 1 (`--daemon --wait`), reads the tier-1 result, **re-derive
|
|||||||
|
|
||||||
> Each `candidates` array above shows **one** entry for brevity — the creative fields (`color`, `typography`, `image_strategy`) must each carry **≥3** in a real file (see the rule above); `selected` indexes the recommended default.
|
> Each `candidates` array above shows **one** entry for brevity — the creative fields (`color`, `typography`, `image_strategy`) must each carry **≥3** in a real file (see the rule above); `selected` indexes the recommended default.
|
||||||
|
|
||||||
- `recommend.*` names the recommended `id` for each enumerable field (must match a `catalogs.json` id, or be a free string for a recommended custom value). The page badges and pre-selects it. **Guarantee**: if a `recommend.*` is omitted, the page falls back to the first catalog option so every enumerable field always shows one badged recommendation — but the AI should still set them for a meaningful default. Legacy aliases are accepted for old files (`line` → `tabler-outline`, `filled` → `tabler-filled`, `monochrome` → `chunk-filled`, `search` → `web`, `default` → `auto`, `builtin` → `host-native`), but new files should write canonical ids. For `recommend.image_usage`, do not write bare `"custom"`; if several image sources are mixed, write the concrete prose plan directly, such as `"封面用 AI 生成,产品页用用户素材,行业页用网络来源"` / `"AI cover + user product assets + web industry images"`.
|
- `recommend.*` names the recommended `id` for each enumerable field (must match a `catalogs.json` id, or be a free string for a recommended custom value). The page badges and pre-selects it. **Guarantee**: if a `recommend.*` is omitted, the page falls back to the first catalog option so every enumerable field always shows one badged recommendation — but the AI should still set them for a meaningful default. Legacy aliases are accepted for old files (`line` → `tabler-outline`, `filled` → `tabler-filled`, `monochrome` → `chunk-filled`, `search` → `web`, `default` → `auto`, `builtin` → `host-native`), but new files should write canonical ids.
|
||||||
- When `recommend.image_usage` is `ai` or a custom plan that includes AI, also set `recommend.image_ai_path` to one of `auto` / `api` / `host-native` / `manual`; the page presents these as explicit choices.
|
- `recommend.image_usage` should be an array of source ids when more than one source applies, e.g. `["ai", "provided"]`. A single string is still accepted for backward compatibility. Do not write bare `"custom"` and do not encode a mixed-source plan as prose here; write the prose to top-level `image_notes.value`.
|
||||||
- For a custom image plan, the page treats "may include AI" as true only when the recommendation includes `recommend.image_ai_path` or `image_strategy.candidates`; a custom plan without those signals is handled as non-AI and omits AI controls / fields.
|
- `image_notes` is the initial strategy note shown under the image source chips. Use it for page-role guidance and constraints: which source applies where, what to avoid, which user assets are authoritative, how realistic / abstract the imagery should be, and what can remain as placeholders. It is intent guidance, not a separate finite option.
|
||||||
|
- When `recommend.image_usage` includes `ai`, also set `recommend.image_ai_path` to one of `auto` / `api` / `host-native` / `manual`; the page presents these as explicit choices.
|
||||||
- **Color candidates carry the user-facing core `palette`**: `background`, `secondary_bg`, `primary`, `accent`, `secondary_accent`, and `body_text`. The page renders labelled swatches and offers per-role override inputs for precise single-role edits, plus a **Custom color card with a free-text box** (parallel to the custom typography box) — the user can describe the palette in words or paste HEX values instead of filling each role; this writes `color: { "name": "custom", "custom": "<text>" }` to `result.json` for the AI to interpret. Legacy `text` is accepted as an alias for `body_text`, but new files should write `body_text`. Strategist derives secondary text, borders, state colors, and visual-style neutral tiers later when writing `design_spec.md` / `spec_lock.md`; those are not user-facing confirmation choices.
|
- **Color candidates carry the user-facing core `palette`**: `background`, `secondary_bg`, `primary`, `accent`, `secondary_accent`, and `body_text`. The page renders labelled swatches and offers per-role override inputs for precise single-role edits, plus a **Custom color card with a free-text box** (parallel to the custom typography box) — the user can describe the palette in words or paste HEX values instead of filling each role; this writes `color: { "name": "custom", "custom": "<text>" }` to `result.json` for the AI to interpret. Legacy `text` is accepted as an alias for `body_text`, but new files should write `body_text`. Strategist derives secondary text, borders, state colors, and visual-style neutral tiers later when writing `design_spec.md` / `spec_lock.md`; those are not user-facing confirmation choices.
|
||||||
- **Candidate display text may be bilingual**: color / typography candidates can provide `name_zh` / `name_en` and `note_zh` / `note_en`; the page falls back to legacy `name` / `note`.
|
- **Candidate display text may be bilingual**: color / typography candidates can provide `name_zh` / `name_en` and `note_zh` / `note_en`; the page falls back to legacy `name` / `note`.
|
||||||
- **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** (the system's only unit, every canvas). The initial value comes from the candidate's `body_size`, sized for the recommended delivery purpose: `text` ~20 · `balanced` ~24 · `presentation` ~32. On submit the page writes `typography.body_size` as px directly — no pt conversion, no `body_size_pt` provenance. The page exposes `body_size` as an editable numeric field whose hint shows the recommended size — **one fixed px per delivery purpose on PPT (`text` 20 · `balanced` 24 · `presentation` 32), not a range** (non-PPT ≈2.5–3.3% of height in px). The user may still edit it; an out-of-range flag only warns if the value strays far (e.g. a unit mistake). **Inputs are independent — the hint updates with canvas / delivery purpose but never rewrites a value the user can see.** It also offers a custom typography text box so the user is not limited to the proposed candidates.
|
- **Typography candidates split CJK and Latin** for both `heading` and `body`; `css` is the fallback preview `font-family` stack. The page previews CJK sample text with `cjk + css` and Latin sample text with `latin + css`, so the two script choices are visible independently. Each candidate should include topic-matched `sample_heading`, `sample_heading_latin`, `sample_body`, and `sample_body_latin`; do not reuse unrelated fixed examples such as a digital-transformation headline for a travel, education, product, or brand deck. Each candidate should also include `body_size` — the body baseline in **px** (the system's only unit, every canvas). The initial value comes from the candidate's `body_size`, sized for the recommended delivery purpose: `text` ~20 · `balanced` ~24 · `presentation` ~32. On submit the page writes `typography.body_size` as px directly — no pt conversion, no `body_size_pt` provenance. The page exposes `body_size` as an editable numeric field whose hint shows the recommended size — **one fixed px per delivery purpose on PPT (`text` 20 · `balanced` 24 · `presentation` 32), not a range** (non-PPT ≈2.5–3.3% of height in px). The user may still edit it; an out-of-range flag only warns if the value strays far (e.g. a unit mistake). **Inputs are independent — the hint updates with canvas / delivery purpose but never rewrites a value the user can see.** It also offers a custom typography text box so the user is not limited to the proposed candidates.
|
||||||
- **Per-role size override** (parallel to color's per-role HEX override): besides `body_size`, the page exposes **independent** editable inputs for `title` / `subtitle` / `annotation`. Each role is pre-filled **once** with a starting value — the candidate's `typography.sizes[role]` if provided, otherwise a one-time ramp suggestion (`body × ` mid-band ratio) — and then holds its own value. **There is no cross-field cascade**: changing `body_size`, `delivery_purpose`, or canvas updates only the recommended-value hint, never the role values; a re-render preserves exactly what the user sees. The final values are written to `result.json` as `typography.sizes: { "title", "subtitle", "annotation" }` in **px** — every canvas, no pt and no `sizes_pt` provenance. Seeding `sizes` in a candidate is optional — omit it and each role gets its one-time ramp suggestion.
|
- **Per-role size override** (parallel to color's per-role HEX override): besides `body_size`, the page exposes **independent** editable inputs for `title` / `subtitle` / `annotation`. Each role is pre-filled **once** with a starting value — the candidate's `typography.sizes[role]` if provided, otherwise a one-time ramp suggestion (`body × ` mid-band ratio) — and then holds its own value. **There is no cross-field cascade**: changing `body_size`, `delivery_purpose`, or canvas updates only the recommended-value hint, never the role values; a re-render preserves exactly what the user sees. The final values are written to `result.json` as `typography.sizes: { "title", "subtitle", "annotation" }` in **px** — every canvas, no pt and no `sizes_pt` provenance. Seeding `sizes` in a candidate is optional — omit it and each role gets its one-time ramp suggestion.
|
||||||
- **`delivery_purpose`** (enumerable, PPT only) is the primary driver of the body baseline: `text` (read-close), `balanced` (business, the default), `presentation`. It is surfaced in the **§c key-information area** (beside audience, Tier 1) as a consumption-mode choice; the Tier-2 typography section then reads the confirmed value for the recommended body px — the pick itself does not rewrite the body field (inputs are independent). `recommend.delivery_purpose` pre-selects one (default `balanced`); the user's pick writes back to `result.json.delivery_purpose` as a plain id. Strategist uses it to set the px body baseline — **one fixed px per purpose (`text` 20 · `balanced` 24 · `presentation` 32), not a range** (see [strategist.md §g](../../references/strategist.md)). Non-PPT canvases omit it.
|
- **`delivery_purpose`** (enumerable, PPT only) is the primary driver of the body baseline: `text` (read-close), `balanced` (business, the default), `presentation`. It is surfaced in the **§c key-information area** (beside audience, Tier 1) as a consumption-mode choice; the Tier-2 typography section then reads the confirmed value for the recommended body px — the pick itself does not rewrite the body field (inputs are independent). `recommend.delivery_purpose` pre-selects one (default `balanced`); the user's pick writes back to `result.json.delivery_purpose` as a plain id. Strategist uses it to set the px body baseline — **one fixed px per purpose (`text` 20 · `balanced` 24 · `presentation` 32), not a range** (see [strategist.md §g](../../references/strategist.md)). Non-PPT canvases omit it.
|
||||||
- **Combined style preview** — a compact live "overall impression" strip sits just above the color section and is **sticky**: it pins under the topbar so it stays visible while the user scrolls through the color / icon / typography sections, keeping the picking controls and their combined effect on screen together. It applies the currently selected color palette **and** typography (heading sample in `primary` over `background`, body sample in `body_text`, an `accent` bar, a `secondary_bg` chip) and repaints on every color / HEX-override / font / `body_size` change. It does not replace the per-candidate swatches or font samples (those stay for picking); it is deliberately an abstract style chip, **not** a slide-layout preview — page layout preview remains the live-preview server's job (Step 6). No schema field; it derives entirely from the existing color + typography selections.
|
- **Combined style preview** — a compact live "overall impression" strip sits just above the color section and is **sticky**: it pins under the topbar so it stays visible while the user scrolls through the color / icon / typography sections, keeping the picking controls and their combined effect on screen together. It applies the currently selected color palette **and** typography (heading sample in `primary` over `background`, body sample in `body_text`, an `accent` bar, a `secondary_bg` chip) and repaints on every color / HEX-override / font / `body_size` change. It does not replace the per-candidate swatches or font samples (those stay for picking); it is deliberately an abstract style chip, **not** a slide-layout preview — page layout preview remains the live-preview server's job (Step 6). No schema field; it derives entirely from the existing color + typography selections.
|
||||||
- **Generated image style candidates** live in `image_strategy.candidates` and are shown only when `image_usage` is `ai` or a custom image plan may include generated images. Each candidate records `rendering`, `palette`, and short `visual` / `color` / `mood` lines from Strategist h.5. The chosen value is written to `result.json.image_strategy`; it is omitted when generated images are not part of the plan.
|
- **Generated image style candidates** live in `image_strategy.candidates` and are shown only when `image_usage` includes `ai`. 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.
|
- **`visual_style_spectrum`** (optional) lets the AI surface the deck's aesthetic as a **personality spectrum** instead of one badged style. Each entry is `{ "id", "tag_zh"/"tag_en", "note_zh"/"note_en" }` where `id` is a real `visual_styles` catalog id; the page badges those chips with their temperament `tag` (replacing the single ★) and appends the `note` (a real-world analogy) inline. The full grouped style list and Custom box stay visible below, and `recommend.visual_style` is still the pre-selected default (it should equal the spectrum's safe pick). Author **≥3** spanning safe / shifted / bold (mirrors h.5; honest-shortfall exception applies — fewer only when the constraints genuinely cannot yield 3). The user's pick still writes back to `result.json.visual_style` as a plain id; the spectrum is presentation-only. Omit the field to fall back to the single-recommendation badge.
|
||||||
- `recommend.generation_mode` and `refine_spec` mirror the two mandatory notes in SKILL.md Step 4. Confirmed `generation_mode: "split"` / `refine_spec: true` are explicit user choices, equivalent to opting in through chat.
|
- `recommend.generation_mode` and `refine_spec` mirror the two mandatory notes in SKILL.md Step 4. Confirmed `generation_mode: "split"` / `refine_spec: true` are explicit user choices, equivalent to opting in through chat.
|
||||||
- `content_divergence` is a **free-text** field shown right under the audience box in §c — the user states in their own words how closely to follow the source vs how freely to reshape it (e.g. "stick closely to the document" / "freely restructure and expand within the source"). It is **not** a fixed-option field; blank means a balanced default. Whatever the level, facts stay sourced — reshaping develops what is in the source, never imports facts from outside it. The Strategist consumes the prose when authoring the §IX outline and records it in `design_spec.md §I`; it is **not** written to `spec_lock.md` (the Executor never reads it). It carries no page-count coupling and no source-signal recommendation — it is purely the user's stated intent. Beautify / template-fill keep content verbatim and do not surface this field.
|
- `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.
|
||||||
@@ -154,7 +160,9 @@ The AI launches Tier 1 (`--daemon --wait`), reads the tier-1 result, **re-derive
|
|||||||
"typography": { "name": "...", "heading": { "cjk": "...", "latin": "...", "css": "..." }, "body": { "cjk": "...", "latin": "...", "css": "..." }, "body_size": 24, "body_size_unit": "px", "sizes": { "title": 42, "subtitle": 32, "annotation": 18 } },
|
"typography": { "name": "...", "heading": { "cjk": "...", "latin": "...", "css": "..." }, "body": { "cjk": "...", "latin": "...", "css": "..." }, "body_size": 24, "body_size_unit": "px", "sizes": { "title": 42, "subtitle": 32, "annotation": 18 } },
|
||||||
"delivery_purpose": "balanced",
|
"delivery_purpose": "balanced",
|
||||||
"formula_policy": "mixed",
|
"formula_policy": "mixed",
|
||||||
"image_usage": "web",
|
"image_usage": ["ai", "provided"],
|
||||||
|
"image_notes": "封面和章节页用 AI 主视觉;产品页优先用户素材,缺口页可用占位符。",
|
||||||
|
"image_ai_path": "auto",
|
||||||
"image_strategy": { "name": "方案 A", "rendering": "vector-illustration", "palette": "cool-corporate", "visual": "...", "color": "...", "mood": "..." },
|
"image_strategy": { "name": "方案 A", "rendering": "vector-illustration", "palette": "cool-corporate", "visual": "...", "color": "...", "mood": "..." },
|
||||||
"generation_mode": "continuous",
|
"generation_mode": "continuous",
|
||||||
"refine_spec": false,
|
"refine_spec": false,
|
||||||
@@ -166,8 +174,8 @@ The AI launches Tier 1 (`--daemon --wait`), reads the tier-1 result, **re-derive
|
|||||||
|
|
||||||
The shape above is the **final** (Tier 2) result, carrying all Tier 1 + Tier 2 fields. The intermediate **Tier 1** write carries only the anchor fields plus `"stage": "tier1"`, `"status": "tier1-confirmed"`; the AI reads it to re-derive Tier 2 and never treats it as the final confirmation. A legacy single-pass write has no `stage` (or `stage: "final"`) and `status: "confirmed"`.
|
The shape above is the **final** (Tier 2) result, carrying all Tier 1 + Tier 2 fields. The intermediate **Tier 1** write carries only the anchor fields plus `"stage": "tier1"`, `"status": "tier1-confirmed"`; the AI reads it to re-derive Tier 2 and never treats it as the final confirmation. A legacy single-pass write has no `stage` (or `stage: "final"`) and `status: "confirmed"`.
|
||||||
|
|
||||||
- Any option field may instead hold a **free-text custom string** (the user picked **Custom**); `color` / `typography` custom entries set `name: "custom"`. Image usage custom values must be concrete prose plans, not the literal string `"custom"`. The AI interprets custom text against the canonical references.
|
- Any option field may instead hold a **free-text custom string** (the user picked **Custom**); `color` / `typography` custom entries set `name: "custom"`. Image usage is not a custom string in new results: it is a source-id array, with free-text strategy captured in `image_notes`.
|
||||||
- `image_ai_path` and `image_strategy` are omitted from `result.json` unless `image_usage` is `ai` or a custom image plan that may include generated images. Both are honored downstream as confirmed choices — and the page is only a convenience surface over the **canonical chat channel**: the same choices made in chat are honored identically when no `result.json` exists. `image_ai_path` drives the Step 5 generation path (`image-generator.md` §7 — `host-native` forces the host tool even when `IMAGE_BACKEND` is set); the chosen `image_strategy` candidate is locked verbatim by Strategist h.5 (no re-pick).
|
- `image_ai_path` and `image_strategy` are omitted from `result.json` unless `image_usage` includes `ai`. Both are honored downstream as confirmed choices — and the page is only a convenience surface over the **canonical chat channel**: the same choices made in chat are honored identically when no `result.json` exists. `image_ai_path` drives the Step 5 generation path (`image-generator.md` §7 — `host-native` forces the host tool even when `IMAGE_BACKEND` is set); the chosen `image_strategy` candidate is locked verbatim by Strategist h.5 (no re-pick).
|
||||||
- After the user clicks the **final Confirm** (Tier 2, or single-pass), the page saves `result.json` and shuts the server down (auto-close). A Tier-1 **Next** instead keeps the page open (it polls for the re-derived Tier 2). In the default flow, the first `--daemon --wait` returns on the tier-1 result and the second `--wait-only` returns on the final result; the AI reads each immediately — no extra chat confirmation is required. Chat confirmation remains the fallback when the page cannot be used. Either way, Step 4 ends with a `--shutdown` cleanup so a never-confirmed page cannot keep holding port 5050 ahead of the Step 6 live preview.
|
- After the user clicks the **final Confirm** (Tier 2, or single-pass), the page saves `result.json` and shuts the server down (auto-close). A Tier-1 **Next** instead keeps the page open (it polls for the re-derived Tier 2). In the default flow, the first `--daemon --wait` returns on the tier-1 result and the second `--wait-only` returns on the final result; the AI reads each immediately — no extra chat confirmation is required. Chat confirmation remains the fallback when the page cannot be used. Either way, Step 4 ends with a `--shutdown` cleanup so a never-confirmed page cannot keep holding port 5050 ahead of the Step 6 live preview.
|
||||||
|
|
||||||
## Scope
|
## Scope
|
||||||
|
|||||||
@@ -40,6 +40,12 @@ Output files land directly under `project/images/`. Formula filenames should use
|
|||||||
|
|
||||||
Unified image generation entry point.
|
Unified image generation entry point.
|
||||||
|
|
||||||
|
This script is the **Path A** API/proxy executor for generated images. In the
|
||||||
|
PPT pipeline, always check the confirmed `image_ai_path` before running manifest
|
||||||
|
mode: `host-native` uses the host's image tool directly and must not run
|
||||||
|
`image_gen.py --manifest`; use `image_gen.py --render-md` only for its
|
||||||
|
read-only Markdown sidecar.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python3 scripts/image_gen.py "A modern futuristic workspace"
|
python3 scripts/image_gen.py "A modern futuristic workspace"
|
||||||
python3 scripts/image_gen.py "Abstract tech background" --aspect_ratio 16:9 --image_size 4K
|
python3 scripts/image_gen.py "Abstract tech background" --aspect_ratio 16:9 --image_size 4K
|
||||||
@@ -209,6 +215,15 @@ python3 scripts/image_search.py "abstract gradient" \
|
|||||||
-o projects/demo/images
|
-o projects/demo/images
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Suitability & manual replacement (a web top hit is metadata-relevant, not guaranteed visually right):
|
||||||
|
|
||||||
|
- By default only the best match is downloaded, plus a downscaled review copy at `images/.review/<stem>.jpg` (the placed asset stays full-resolution).
|
||||||
|
- For exact subjects (landmarks, people, companies, products), use `--require-terms` or batch `required_terms` so visually plausible but wrong metadata is rejected before ranking. Example: `--require-terms Chongqing --require-terms "Jiefangbei|Liberation Monument"`. Keep proper-name / geography anchors; do not broaden to generic terms like `canyon`, `stone pillar`, or `ancient town` just to improve coverage.
|
||||||
|
- `--save-candidates` (with `--max-candidates`, default 4) keeps an opt-in escalation pool under `candidates/<stem>/`; review it, then `--promote candidate_03.jpg --filename <name>.jpg`.
|
||||||
|
- `--from-url <url> --filename <name>.jpg` downloads a user-chosen image URL and replaces the target (recorded `license_tier: manual`) — the model-agnostic manual path; works even without a multimodal model.
|
||||||
|
|
||||||
|
Full review / escalation flow: [`image-searcher.md`](../../references/image-searcher.md) §5.
|
||||||
|
|
||||||
Output:
|
Output:
|
||||||
|
|
||||||
- Image saved to the specified output directory (auto-converts webp → jpg via Pillow when the filename extension demands)
|
- Image saved to the specified output directory (auto-converts webp → jpg via Pillow when the filename extension demands)
|
||||||
|
|||||||
@@ -118,8 +118,9 @@ def finalize_project(
|
|||||||
options: dict[str, bool],
|
options: dict[str, bool],
|
||||||
dry_run: bool = False,
|
dry_run: bool = False,
|
||||||
quiet: bool = False,
|
quiet: bool = False,
|
||||||
compress: bool = False,
|
compress: bool = True,
|
||||||
max_dimension: int | None = None,
|
max_dimension: int | None = 2560,
|
||||||
|
image_scale: float = 2.0,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""
|
"""
|
||||||
Finalize SVG files in the project
|
Finalize SVG files in the project
|
||||||
@@ -131,6 +132,7 @@ def finalize_project(
|
|||||||
quiet: Quiet mode, reduce output
|
quiet: Quiet mode, reduce output
|
||||||
compress: Compress images before embedding
|
compress: Compress images before embedding
|
||||||
max_dimension: Downscale images exceeding this dimension
|
max_dimension: Downscale images exceeding this dimension
|
||||||
|
image_scale: Target image pixels per SVG display pixel
|
||||||
"""
|
"""
|
||||||
svg_output = project_dir / 'svg_output'
|
svg_output = project_dir / 'svg_output'
|
||||||
svg_final = project_dir / 'svg_final'
|
svg_final = project_dir / 'svg_final'
|
||||||
@@ -203,6 +205,7 @@ def finalize_project(
|
|||||||
verbose=False,
|
verbose=False,
|
||||||
compress=compress,
|
compress=compress,
|
||||||
max_dimension=max_dimension,
|
max_dimension=max_dimension,
|
||||||
|
image_scale=image_scale,
|
||||||
)
|
)
|
||||||
img_count += count
|
img_count += count
|
||||||
img_errors += errs
|
img_errors += errs
|
||||||
@@ -304,10 +307,14 @@ Aliases (still accepted):
|
|||||||
help='Preview only, do not execute')
|
help='Preview only, do not execute')
|
||||||
parser.add_argument('--quiet', '-q', action='store_true',
|
parser.add_argument('--quiet', '-q', action='store_true',
|
||||||
help='Quiet mode, reduce output')
|
help='Quiet mode, reduce output')
|
||||||
parser.add_argument('--compress', action='store_true',
|
parser.add_argument('--compress', dest='compress', action='store_true', default=True,
|
||||||
help='Compress images before embedding (JPEG quality=85, PNG optimize)')
|
help='Compress images before embedding (default)')
|
||||||
parser.add_argument('--max-dimension', type=int, default=None,
|
parser.add_argument('--no-compress', dest='compress', action='store_false',
|
||||||
help='Downscale images exceeding this dimension on either axis (e.g., 2560)')
|
help='Disable image compression before embedding')
|
||||||
|
parser.add_argument('--max-dimension', type=int, default=2560,
|
||||||
|
help='Downscale images exceeding this dimension on either axis (default: 2560)')
|
||||||
|
parser.add_argument('--image-scale', type=float, default=2.0,
|
||||||
|
help='Target image pixels per SVG display pixel (default: 2.0)')
|
||||||
|
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
@@ -337,9 +344,17 @@ Aliases (still accepted):
|
|||||||
'fix_rounded': True,
|
'fix_rounded': True,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if args.max_dimension < 1:
|
||||||
|
safe_print("[ERROR] --max-dimension must be >= 1")
|
||||||
|
sys.exit(1)
|
||||||
|
if args.image_scale < 1:
|
||||||
|
safe_print("[ERROR] --image-scale must be >= 1")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
success = finalize_project(args.project_dir, options, args.dry_run, args.quiet,
|
success = finalize_project(args.project_dir, options, args.dry_run, args.quiet,
|
||||||
compress=args.compress,
|
compress=args.compress,
|
||||||
max_dimension=args.max_dimension)
|
max_dimension=args.max_dimension,
|
||||||
|
image_scale=args.image_scale)
|
||||||
sys.exit(0 if success else 1)
|
sys.exit(0 if success else 1)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -343,6 +343,50 @@ def _resolve_backend() -> tuple[object, str]:
|
|||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
def _confirmed_image_ai_path_for_manifest(manifest_path: str) -> str | None:
|
||||||
|
"""Return confirmed image_ai_path for a project manifest, if present."""
|
||||||
|
path = Path(manifest_path).resolve()
|
||||||
|
if path.parent.name != "images":
|
||||||
|
return None
|
||||||
|
result_file = path.parent.parent / "confirm_ui" / "result.json"
|
||||||
|
if not result_file.exists():
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
data = json.loads(result_file.read_text(encoding="utf-8"))
|
||||||
|
except (OSError, json.JSONDecodeError):
|
||||||
|
return None
|
||||||
|
value = data.get("image_ai_path")
|
||||||
|
if not isinstance(value, str):
|
||||||
|
return None
|
||||||
|
return value.strip().lower().replace("_", "-")
|
||||||
|
|
||||||
|
|
||||||
|
def _guard_confirmed_non_api_path(manifest_path: str) -> None:
|
||||||
|
"""Prevent accidental Path A execution after host-native/manual was confirmed."""
|
||||||
|
image_ai_path = _confirmed_image_ai_path_for_manifest(manifest_path)
|
||||||
|
if image_ai_path not in {"host-native", "manual"}:
|
||||||
|
return
|
||||||
|
if image_ai_path == "host-native":
|
||||||
|
print(
|
||||||
|
"Error: confirmed image_ai_path is 'host-native'.\n"
|
||||||
|
"\n"
|
||||||
|
"Do NOT run image_gen.py --manifest for this project. That command is Path A\n"
|
||||||
|
"and may use the configured API/proxy backend. Use the host's native image\n"
|
||||||
|
"generation tool with prompts from images/image_prompts.json, save outputs to\n"
|
||||||
|
"images/<filename>, update each item status to Generated, then run:\n"
|
||||||
|
" python3 scripts/image_gen.py --render-md images/image_prompts.json\n"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
print(
|
||||||
|
"Error: confirmed image_ai_path is 'manual'.\n"
|
||||||
|
"\n"
|
||||||
|
"Do NOT run image_gen.py --manifest for this project. Render the Markdown\n"
|
||||||
|
"sidecar and hand images/image_prompts.md to the user for external generation:\n"
|
||||||
|
" python3 scripts/image_gen.py --render-md images/image_prompts.json\n"
|
||||||
|
)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
DEFAULT_MANIFEST_CONCURRENCY = 3
|
DEFAULT_MANIFEST_CONCURRENCY = 3
|
||||||
|
|
||||||
STATUS_PENDING = "Pending"
|
STATUS_PENDING = "Pending"
|
||||||
@@ -708,6 +752,9 @@ def main() -> None:
|
|||||||
print(f"Rendered Markdown sidecar: {md_path}")
|
print(f"Rendered Markdown sidecar: {md_path}")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
if args.manifest:
|
||||||
|
_guard_confirmed_non_api_path(args.manifest)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
_load_image_env_file()
|
_load_image_env_file()
|
||||||
_validate_runtime_config()
|
_validate_runtime_config()
|
||||||
|
|||||||
@@ -114,6 +114,96 @@ SEARCH_VALID_STATUSES = {
|
|||||||
SEARCH_RETRYABLE_STATUSES = {SEARCH_STATUS_PENDING, SEARCH_STATUS_FAILED}
|
SEARCH_RETRYABLE_STATUSES = {SEARCH_STATUS_PENDING, SEARCH_STATUS_FAILED}
|
||||||
SEARCH_REQUIRED_ITEM_FIELDS = ("filename", "query", "status")
|
SEARCH_REQUIRED_ITEM_FIELDS = ("filename", "query", "status")
|
||||||
|
|
||||||
|
_WEAK_REQUIRED_TERM_PARTS = frozenset({
|
||||||
|
"ancient town",
|
||||||
|
"bridge",
|
||||||
|
"canyon",
|
||||||
|
"cave",
|
||||||
|
"city",
|
||||||
|
"floating bridge",
|
||||||
|
"forest",
|
||||||
|
"gate",
|
||||||
|
"grand canyon",
|
||||||
|
"ground fissure",
|
||||||
|
"lake",
|
||||||
|
"monastery",
|
||||||
|
"monument",
|
||||||
|
"river",
|
||||||
|
"shrine",
|
||||||
|
"square",
|
||||||
|
"station",
|
||||||
|
"stone forest",
|
||||||
|
"stone pillar",
|
||||||
|
"stream",
|
||||||
|
"temple",
|
||||||
|
"valley",
|
||||||
|
"village",
|
||||||
|
"古城",
|
||||||
|
"古镇",
|
||||||
|
"地缝",
|
||||||
|
"大峡谷",
|
||||||
|
"寺",
|
||||||
|
"峡谷",
|
||||||
|
"广场",
|
||||||
|
"桥",
|
||||||
|
"洞",
|
||||||
|
"溪",
|
||||||
|
"石林",
|
||||||
|
"石柱",
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_required_terms(raw: object) -> tuple[str, ...]:
|
||||||
|
"""Parse entity-safety terms from CLI / batch JSON.
|
||||||
|
|
||||||
|
Multiple groups are ANDed. Alternatives inside one group are separated by
|
||||||
|
``|``; comma splitting is accepted as CLI convenience. Examples:
|
||||||
|
``["Chongqing", "Jiefangbei|Liberation Monument"]``.
|
||||||
|
"""
|
||||||
|
if raw is None:
|
||||||
|
return ()
|
||||||
|
values: list[str]
|
||||||
|
if isinstance(raw, str):
|
||||||
|
values = [raw]
|
||||||
|
elif isinstance(raw, (list, tuple)):
|
||||||
|
values = []
|
||||||
|
for item in raw:
|
||||||
|
if not isinstance(item, str):
|
||||||
|
raise ValueError("required_terms items must be strings")
|
||||||
|
values.append(item)
|
||||||
|
else:
|
||||||
|
raise ValueError("required_terms must be a string or list of strings")
|
||||||
|
|
||||||
|
terms: list[str] = []
|
||||||
|
for value in values:
|
||||||
|
for part in value.split(","):
|
||||||
|
part = part.strip()
|
||||||
|
if part:
|
||||||
|
terms.append(part)
|
||||||
|
return tuple(terms)
|
||||||
|
|
||||||
|
|
||||||
|
def _warn_weak_required_terms(required_terms: tuple[str, ...]) -> None:
|
||||||
|
"""Warn when required_terms contain generic category words.
|
||||||
|
|
||||||
|
These terms are useful in the query but dangerous as identity gates:
|
||||||
|
broadening a small Chinese attraction from its proper name to "canyon" /
|
||||||
|
"stone pillar" raises coverage while admitting wrong entities.
|
||||||
|
"""
|
||||||
|
weak: list[str] = []
|
||||||
|
for group in required_terms:
|
||||||
|
for part in group.split("|"):
|
||||||
|
normalized = part.strip().lower()
|
||||||
|
if normalized in _WEAK_REQUIRED_TERM_PARTS:
|
||||||
|
weak.append(part.strip())
|
||||||
|
if weak:
|
||||||
|
print(
|
||||||
|
" warning: required_terms contains generic category term(s) "
|
||||||
|
f"{weak}; keep proper-name / geography anchors too, and prefer "
|
||||||
|
"Needs-Manual or --from-url over loosening identity gates.",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# .env loading
|
# .env loading
|
||||||
@@ -199,12 +289,38 @@ def _validate_downloaded_quality(path: Path) -> bool:
|
|||||||
return True # unreadable image; let downstream handle it
|
return True # unreadable image; let downstream handle it
|
||||||
|
|
||||||
|
|
||||||
|
def _write_review_copy(
|
||||||
|
src: Path, dest_dir: Path, name: str, max_side: int = 1024
|
||||||
|
) -> Optional[Path]:
|
||||||
|
"""Write a downscaled JPEG review copy of ``src`` into ``dest_dir``.
|
||||||
|
|
||||||
|
The placed / promoted asset is always the full-resolution original; this
|
||||||
|
bounded copy exists only so the agent can Read a sanely-sized image to
|
||||||
|
confirm suitability regardless of how large the source is. Best-effort —
|
||||||
|
returns None (non-fatal) if Pillow or the source is unavailable.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from PIL import Image # type: ignore
|
||||||
|
except ImportError:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
dest_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
review_path = dest_dir / f"{Path(name).stem}.jpg"
|
||||||
|
with Image.open(src) as im:
|
||||||
|
im = im.convert("RGB")
|
||||||
|
im.thumbnail((max_side, max_side))
|
||||||
|
im.save(review_path, "JPEG", quality=85)
|
||||||
|
return review_path
|
||||||
|
except (OSError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _save_candidates_pool(
|
def _save_candidates_pool(
|
||||||
ranked: list[tuple[float, str, AssetCandidate]],
|
ranked: list[tuple[float, str, AssetCandidate]],
|
||||||
output_dir: Path,
|
output_dir: Path,
|
||||||
stem: str,
|
stem: str,
|
||||||
selected_filename: str,
|
selected_filename: str,
|
||||||
max_candidates: int = 8,
|
max_candidates: int = 4,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Download top-N candidates into ``candidates/<stem>/`` and write
|
"""Download top-N candidates into ``candidates/<stem>/`` and write
|
||||||
a ``candidates.json`` manifest for manual review."""
|
a ``candidates.json`` manifest for manual review."""
|
||||||
@@ -232,10 +348,12 @@ def _save_candidates_pool(
|
|||||||
continue
|
continue
|
||||||
idx += 1
|
idx += 1
|
||||||
actual_dim = _measure_actual_image(cand_path)
|
actual_dim = _measure_actual_image(cand_path)
|
||||||
|
review_path = _write_review_copy(cand_path, cand_dir / "review", cand_filename)
|
||||||
pool.append({
|
pool.append({
|
||||||
"rank": idx,
|
"rank": idx,
|
||||||
"score": round(score, 2),
|
"score": round(score, 2),
|
||||||
"filename": cand_filename,
|
"filename": cand_filename,
|
||||||
|
"review": f"review/{review_path.name}" if review_path else None,
|
||||||
"provider": provider_name,
|
"provider": provider_name,
|
||||||
"title": candidate.title,
|
"title": candidate.title,
|
||||||
"author": candidate.author,
|
"author": candidate.author,
|
||||||
@@ -245,6 +363,7 @@ def _save_candidates_pool(
|
|||||||
"license_url": candidate.license_url,
|
"license_url": candidate.license_url,
|
||||||
"license_tier": candidate.license_tier,
|
"license_tier": candidate.license_tier,
|
||||||
"attribution_required": candidate.license_tier == "attribution-required",
|
"attribution_required": candidate.license_tier == "attribution-required",
|
||||||
|
"attribution_text": build_attribution_text(selected_filename, candidate),
|
||||||
"width": actual_dim[0] if actual_dim else candidate.width,
|
"width": actual_dim[0] if actual_dim else candidate.width,
|
||||||
"height": actual_dim[1] if actual_dim else candidate.height,
|
"height": actual_dim[1] if actual_dim else candidate.height,
|
||||||
})
|
})
|
||||||
@@ -270,13 +389,15 @@ def search_and_download(
|
|||||||
*,
|
*,
|
||||||
output_path: Path,
|
output_path: Path,
|
||||||
strict_no_attribution: bool,
|
strict_no_attribution: bool,
|
||||||
save_candidates: bool = True,
|
save_candidates: bool = False,
|
||||||
max_candidates: int = 8,
|
max_candidates: int = 4,
|
||||||
) -> tuple[Optional[AssetCandidate], Optional[str], Optional[str]]:
|
) -> tuple[Optional[AssetCandidate], Optional[str], Optional[str]]:
|
||||||
"""Find a candidate AND successfully download it.
|
"""Find a candidate AND successfully download it.
|
||||||
|
|
||||||
When ``save_candidates`` is True (default), the top-N candidates are
|
By default only the best match is downloaded. When ``save_candidates``
|
||||||
also saved to ``candidates/<stem>/`` for manual review.
|
is True (opt-in), the top-N candidates are also saved to
|
||||||
|
``candidates/<stem>/`` so the agent can review and ``--promote`` a
|
||||||
|
better fit when the best match does not pass visual confirmation.
|
||||||
|
|
||||||
Returns ``(candidate, provider_name, stage)`` for the successfully
|
Returns ``(candidate, provider_name, stage)`` for the successfully
|
||||||
downloaded image, or ``(None, None, None)`` if every combination
|
downloaded image, or ``(None, None, None)`` if every combination
|
||||||
@@ -301,8 +422,11 @@ def search_and_download(
|
|||||||
item for item in provider_ranked if item[0] != float("-inf")
|
item for item in provider_ranked if item[0] != float("-inf")
|
||||||
]
|
]
|
||||||
if not provider_ranked:
|
if not provider_ranked:
|
||||||
|
reason = "query"
|
||||||
|
if request.required_terms:
|
||||||
|
reason += f" / required_terms={list(request.required_terms)}"
|
||||||
print(
|
print(
|
||||||
f" no candidate matched the query; trying next provider/stage",
|
f" no candidate matched {reason}; trying next provider/stage",
|
||||||
file=sys.stderr,
|
file=sys.stderr,
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
@@ -332,6 +456,11 @@ def search_and_download(
|
|||||||
if not _validate_downloaded_quality(output_path):
|
if not _validate_downloaded_quality(output_path):
|
||||||
output_path.unlink(missing_ok=True)
|
output_path.unlink(missing_ok=True)
|
||||||
continue
|
continue
|
||||||
|
review = _write_review_copy(
|
||||||
|
output_path, output_path.parent / ".review", output_path.name
|
||||||
|
)
|
||||||
|
if review is not None:
|
||||||
|
print(f" review copy: {review}", file=sys.stderr)
|
||||||
return candidate, provider_name, stage
|
return candidate, provider_name, stage
|
||||||
except (requests.RequestException, OSError, RuntimeError, ValueError) as exc:
|
except (requests.RequestException, OSError, RuntimeError, ValueError) as exc:
|
||||||
print(
|
print(
|
||||||
@@ -416,6 +545,11 @@ def _candidate_to_manifest_item(
|
|||||||
"attribution_text": build_attribution_text(args.filename, candidate),
|
"attribution_text": build_attribution_text(args.filename, candidate),
|
||||||
"status": "sourced",
|
"status": "sourced",
|
||||||
}
|
}
|
||||||
|
required_terms = _parse_required_terms(
|
||||||
|
getattr(args, "required_terms", None) or getattr(args, "require_terms", None)
|
||||||
|
)
|
||||||
|
if required_terms:
|
||||||
|
item["required_terms"] = list(required_terms)
|
||||||
|
|
||||||
# Only carry upstream-claimed dimensions when they differ — this flags
|
# Only carry upstream-claimed dimensions when they differ — this flags
|
||||||
# cases where the provider returned a preview rather than the original.
|
# cases where the provider returned a preview rather than the original.
|
||||||
@@ -520,6 +654,9 @@ def promote_candidate(
|
|||||||
|
|
||||||
shutil.copy2(str(src_path), str(dst_path))
|
shutil.copy2(str(src_path), str(dst_path))
|
||||||
print(f" promoted: {candidate_filename} → {target_filename}", file=sys.stderr)
|
print(f" promoted: {candidate_filename} → {target_filename}", file=sys.stderr)
|
||||||
|
review = _write_review_copy(dst_path, output_dir / ".review", target_filename)
|
||||||
|
if review is not None:
|
||||||
|
print(f" review copy: {review}", file=sys.stderr)
|
||||||
|
|
||||||
# Update candidates.json
|
# Update candidates.json
|
||||||
meta["selected"] = candidate_filename
|
meta["selected"] = candidate_filename
|
||||||
@@ -546,6 +683,23 @@ def promote_candidate(
|
|||||||
item["license_url"] = entry.get("license_url", "")
|
item["license_url"] = entry.get("license_url", "")
|
||||||
item["license_tier"] = entry["license_tier"]
|
item["license_tier"] = entry["license_tier"]
|
||||||
item["attribution_required"] = entry.get("attribution_required", False)
|
item["attribution_required"] = entry.get("attribution_required", False)
|
||||||
|
# Recompute the credit from the promoted candidate — never carry the
|
||||||
|
# replaced image's attribution_text (wrong author/title/source).
|
||||||
|
item["attribution_text"] = build_attribution_text(
|
||||||
|
target_filename,
|
||||||
|
AssetCandidate(
|
||||||
|
provider=entry.get("provider", ""),
|
||||||
|
title=entry.get("title", ""),
|
||||||
|
source_page_url=entry.get("source_page_url", ""),
|
||||||
|
license_name=entry.get("license_name", ""),
|
||||||
|
license_url=entry.get("license_url", ""),
|
||||||
|
license_tier=entry.get("license_tier", ""),
|
||||||
|
width=w,
|
||||||
|
height=h,
|
||||||
|
download_url=entry.get("download_url", ""),
|
||||||
|
author=entry.get("author", ""),
|
||||||
|
),
|
||||||
|
)
|
||||||
item["width"] = w
|
item["width"] = w
|
||||||
item["height"] = h
|
item["height"] = h
|
||||||
item.pop("metadata_dimensions", None)
|
item.pop("metadata_dimensions", None)
|
||||||
@@ -560,6 +714,87 @@ def promote_candidate(
|
|||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Manual URL replacement (model-agnostic)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_url_replace(
|
||||||
|
output_dir: Path,
|
||||||
|
target_filename: str,
|
||||||
|
url: str,
|
||||||
|
manifest_path: Optional[Path] = None,
|
||||||
|
*,
|
||||||
|
slide: str = "",
|
||||||
|
purpose: str = "",
|
||||||
|
search_query: str = "",
|
||||||
|
orientation: str = "",
|
||||||
|
required_terms: tuple[str, ...] = (),
|
||||||
|
) -> int:
|
||||||
|
"""Download a user-supplied image URL into the target and record it.
|
||||||
|
|
||||||
|
The model-agnostic manual path: when an automated best match is not
|
||||||
|
suitable (or the running model cannot see images at all), a human finds a
|
||||||
|
good image, passes its URL, and it replaces the target. License is unknown
|
||||||
|
for an arbitrary URL, so the manifest marks it ``manual`` and notes that
|
||||||
|
verifying usage rights is the user's responsibility.
|
||||||
|
"""
|
||||||
|
output_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
dst_path = output_dir / target_filename
|
||||||
|
try:
|
||||||
|
download_image(url, str(dst_path), headers={"User-Agent": USER_AGENT})
|
||||||
|
except (requests.RequestException, OSError, RuntimeError, ValueError) as exc:
|
||||||
|
print(f"Error: failed to download {url}: {exc}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
if not dst_path.exists():
|
||||||
|
print(f"Error: download produced no file at {dst_path}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
print(f" fetched: {url} -> {target_filename}", file=sys.stderr)
|
||||||
|
review = _write_review_copy(dst_path, output_dir / ".review", target_filename)
|
||||||
|
if review is not None:
|
||||||
|
print(f" review copy: {review}", file=sys.stderr)
|
||||||
|
|
||||||
|
actual_dim = _measure_actual_image(dst_path)
|
||||||
|
mpath = manifest_path or default_manifest_path(str(output_dir))
|
||||||
|
# Inherit page context (which slide / purpose / query this image serves)
|
||||||
|
# from the entry being replaced; override only source / license / size /
|
||||||
|
# status so the audit trail survives a manual swap.
|
||||||
|
prior = next(
|
||||||
|
(i for i in _read_existing_manifest(mpath).get("items", [])
|
||||||
|
if i.get("filename") == target_filename),
|
||||||
|
{},
|
||||||
|
)
|
||||||
|
item = {
|
||||||
|
"filename": target_filename,
|
||||||
|
"slide": prior.get("slide") or slide,
|
||||||
|
"purpose": prior.get("purpose") or purpose,
|
||||||
|
"search_query": prior.get("search_query") or search_query,
|
||||||
|
"orientation": prior.get("orientation") or orientation,
|
||||||
|
"provider": "manual",
|
||||||
|
"title": "",
|
||||||
|
"author": "",
|
||||||
|
"source_page_url": url,
|
||||||
|
"download_url": url,
|
||||||
|
"license_name": "unverified — user-supplied URL",
|
||||||
|
"license_url": "",
|
||||||
|
"license_tier": "manual",
|
||||||
|
"attribution_required": False,
|
||||||
|
"width": actual_dim[0] if actual_dim else 0,
|
||||||
|
"height": actual_dim[1] if actual_dim else 0,
|
||||||
|
"attribution_text": "",
|
||||||
|
"status": "manual",
|
||||||
|
"note": "Manually supplied image URL; verifying usage rights is the user's responsibility.",
|
||||||
|
}
|
||||||
|
inherited_required_terms = _parse_required_terms(prior.get("required_terms"))
|
||||||
|
final_required_terms = inherited_required_terms or required_terms
|
||||||
|
if final_required_terms:
|
||||||
|
item["required_terms"] = list(final_required_terms)
|
||||||
|
written = write_sources_manifest(mpath, item)
|
||||||
|
print(f" manifest updated: {written}", file=sys.stderr)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Batch mode (`--batch image_queries.json`)
|
# Batch mode (`--batch image_queries.json`)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -607,6 +842,11 @@ def load_search_manifest(path: str) -> dict:
|
|||||||
f"{prefix} status '{item['status']}' is invalid. "
|
f"{prefix} status '{item['status']}' is invalid. "
|
||||||
f"Valid: {sorted(SEARCH_VALID_STATUSES)}"
|
f"Valid: {sorted(SEARCH_VALID_STATUSES)}"
|
||||||
)
|
)
|
||||||
|
if "required_terms" in item:
|
||||||
|
try:
|
||||||
|
_parse_required_terms(item["required_terms"])
|
||||||
|
except ValueError as exc:
|
||||||
|
raise ValueError(f"{prefix} {exc}") from exc
|
||||||
fname = item["filename"]
|
fname = item["filename"]
|
||||||
if fname in seen_filenames:
|
if fname in seen_filenames:
|
||||||
raise ValueError(f"{prefix} duplicate filename '{fname}'")
|
raise ValueError(f"{prefix} duplicate filename '{fname}'")
|
||||||
@@ -662,6 +902,8 @@ def _search_one_item(
|
|||||||
"""
|
"""
|
||||||
orientation = item.get("orientation", "any") or "any"
|
orientation = item.get("orientation", "any") or "any"
|
||||||
strict = bool(item.get("strict_no_attribution", default_strict))
|
strict = bool(item.get("strict_no_attribution", default_strict))
|
||||||
|
required_terms = _parse_required_terms(item.get("required_terms"))
|
||||||
|
_warn_weak_required_terms(required_terms)
|
||||||
request = ImageSearchRequest(
|
request = ImageSearchRequest(
|
||||||
query=item["query"],
|
query=item["query"],
|
||||||
purpose=item.get("purpose", ""),
|
purpose=item.get("purpose", ""),
|
||||||
@@ -670,6 +912,7 @@ def _search_one_item(
|
|||||||
slide=item.get("slide", ""),
|
slide=item.get("slide", ""),
|
||||||
min_width=int(item.get("min_width", default_min_width)),
|
min_width=int(item.get("min_width", default_min_width)),
|
||||||
min_height=int(item.get("min_height", default_min_height)),
|
min_height=int(item.get("min_height", default_min_height)),
|
||||||
|
required_terms=required_terms,
|
||||||
)
|
)
|
||||||
|
|
||||||
pinned = item.get("provider") or default_provider
|
pinned = item.get("provider") or default_provider
|
||||||
@@ -694,6 +937,7 @@ def _search_one_item(
|
|||||||
purpose=item.get("purpose", ""),
|
purpose=item.get("purpose", ""),
|
||||||
query=item["query"],
|
query=item["query"],
|
||||||
orientation=orientation,
|
orientation=orientation,
|
||||||
|
required_terms=request.required_terms,
|
||||||
)
|
)
|
||||||
manifest_item = _candidate_to_manifest_item(
|
manifest_item = _candidate_to_manifest_item(
|
||||||
candidate,
|
candidate,
|
||||||
@@ -819,7 +1063,8 @@ def build_parser() -> argparse.ArgumentParser:
|
|||||||
default=None,
|
default=None,
|
||||||
help=(
|
help=(
|
||||||
"Local filename for the chosen image (e.g. cover_bg.jpg). "
|
"Local filename for the chosen image (e.g. cover_bg.jpg). "
|
||||||
"Required for single-query and --promote modes; ignored in --batch."
|
"Required for single-query, --promote, and --from-url modes; "
|
||||||
|
"ignored in --batch."
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
@@ -873,6 +1118,18 @@ def build_parser() -> argparse.ArgumentParser:
|
|||||||
default=800,
|
default=800,
|
||||||
help="Minimum acceptable image height in pixels (default: 800).",
|
help="Minimum acceptable image height in pixels (default: 800).",
|
||||||
)
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--require-terms",
|
||||||
|
action="append",
|
||||||
|
default=None,
|
||||||
|
metavar="TERM[,TERM...]",
|
||||||
|
help=(
|
||||||
|
"Entity-safety gate: require each metadata term group before a "
|
||||||
|
"candidate can be accepted. Repeatable; comma separates groups; "
|
||||||
|
"'A|B' means aliases within one group. Example: "
|
||||||
|
"--require-terms Chongqing --require-terms 'Jiefangbei|Liberation Monument'."
|
||||||
|
),
|
||||||
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--manifest",
|
"--manifest",
|
||||||
default=None,
|
default=None,
|
||||||
@@ -900,15 +1157,20 @@ def build_parser() -> argparse.ArgumentParser:
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--no-candidates",
|
"--save-candidates",
|
||||||
action="store_true",
|
action="store_true",
|
||||||
help="Disable candidate pool saving (only download the best match).",
|
help=(
|
||||||
|
"Opt-in: also save a small candidate pool to candidates/<stem>/ "
|
||||||
|
"(with downscaled review copies) so a better fit can be promoted "
|
||||||
|
"when the best match fails visual confirmation. Default: only the "
|
||||||
|
"best match is downloaded."
|
||||||
|
),
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--max-candidates",
|
"--max-candidates",
|
||||||
type=int,
|
type=int,
|
||||||
default=8,
|
default=4,
|
||||||
help="Max number of candidates to save (default: 8).",
|
help="Max candidates to save when --save-candidates is set (default: 4).",
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--promote",
|
"--promote",
|
||||||
@@ -919,6 +1181,16 @@ def build_parser() -> argparse.ArgumentParser:
|
|||||||
"Example: --promote candidate_03.jpg --filename 05_wulong.jpg -o images/"
|
"Example: --promote candidate_03.jpg --filename 05_wulong.jpg -o images/"
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--from-url",
|
||||||
|
default=None,
|
||||||
|
metavar="URL",
|
||||||
|
help=(
|
||||||
|
"Manual replacement: download a user-supplied image URL into "
|
||||||
|
"--filename and record it (license marked 'manual'). Works without "
|
||||||
|
"a multimodal model. Example: --from-url https://… --filename team.jpg -o images/"
|
||||||
|
),
|
||||||
|
)
|
||||||
return parser
|
return parser
|
||||||
|
|
||||||
|
|
||||||
@@ -953,6 +1225,22 @@ def main(argv: Optional[list[str]] = None) -> int:
|
|||||||
manifest_path=Path(args.manifest) if args.manifest else None,
|
manifest_path=Path(args.manifest) if args.manifest else None,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# --- Manual URL replacement ---
|
||||||
|
if args.from_url:
|
||||||
|
if not args.filename:
|
||||||
|
parser.error("--filename is required with --from-url")
|
||||||
|
return fetch_url_replace(
|
||||||
|
output_dir,
|
||||||
|
args.filename,
|
||||||
|
args.from_url,
|
||||||
|
manifest_path=Path(args.manifest) if args.manifest else None,
|
||||||
|
slide=args.slide,
|
||||||
|
purpose=args.purpose,
|
||||||
|
search_query=args.query or "",
|
||||||
|
orientation="" if args.orientation == "any" else args.orientation,
|
||||||
|
required_terms=_parse_required_terms(args.require_terms),
|
||||||
|
)
|
||||||
|
|
||||||
# --- Batch mode ---
|
# --- Batch mode ---
|
||||||
if args.batch:
|
if args.batch:
|
||||||
if not os.path.isfile(args.batch):
|
if not os.path.isfile(args.batch):
|
||||||
@@ -978,7 +1266,7 @@ def main(argv: Optional[list[str]] = None) -> int:
|
|||||||
output_dir=batch_output_dir,
|
output_dir=batch_output_dir,
|
||||||
sources_manifest_path=sources_manifest_path,
|
sources_manifest_path=sources_manifest_path,
|
||||||
concurrency=_resolve_search_concurrency(args.concurrency),
|
concurrency=_resolve_search_concurrency(args.concurrency),
|
||||||
save_candidates=not args.no_candidates,
|
save_candidates=args.save_candidates,
|
||||||
max_candidates=args.max_candidates,
|
max_candidates=args.max_candidates,
|
||||||
default_provider=args.provider,
|
default_provider=args.provider,
|
||||||
default_strict=args.strict_no_attribution,
|
default_strict=args.strict_no_attribution,
|
||||||
@@ -995,7 +1283,7 @@ def main(argv: Optional[list[str]] = None) -> int:
|
|||||||
|
|
||||||
# --- Single-query search mode ---
|
# --- Single-query search mode ---
|
||||||
if not args.query:
|
if not args.query:
|
||||||
parser.error("query is required unless --batch or --promote is used")
|
parser.error("query is required unless --batch, --promote, or --from-url is used")
|
||||||
if not args.filename:
|
if not args.filename:
|
||||||
parser.error("--filename is required in single-query mode")
|
parser.error("--filename is required in single-query mode")
|
||||||
|
|
||||||
@@ -1007,7 +1295,9 @@ def main(argv: Optional[list[str]] = None) -> int:
|
|||||||
slide=args.slide,
|
slide=args.slide,
|
||||||
min_width=args.min_width,
|
min_width=args.min_width,
|
||||||
min_height=args.min_height,
|
min_height=args.min_height,
|
||||||
|
required_terms=_parse_required_terms(args.require_terms),
|
||||||
)
|
)
|
||||||
|
_warn_weak_required_terms(request.required_terms)
|
||||||
|
|
||||||
providers = [args.provider] if args.provider else _default_provider_chain()
|
providers = [args.provider] if args.provider else _default_provider_chain()
|
||||||
|
|
||||||
@@ -1020,7 +1310,7 @@ def main(argv: Optional[list[str]] = None) -> int:
|
|||||||
request,
|
request,
|
||||||
output_path=output_path,
|
output_path=output_path,
|
||||||
strict_no_attribution=args.strict_no_attribution,
|
strict_no_attribution=args.strict_no_attribution,
|
||||||
save_candidates=not args.no_candidates,
|
save_candidates=args.save_candidates,
|
||||||
max_candidates=args.max_candidates,
|
max_candidates=args.max_candidates,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
+86
-6
@@ -208,6 +208,7 @@ class ImageSearchRequest:
|
|||||||
min_height: int = 0
|
min_height: int = 0
|
||||||
filename: str = ""
|
filename: str = ""
|
||||||
slide: str = ""
|
slide: str = ""
|
||||||
|
required_terms: tuple[str, ...] = ()
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -265,6 +266,7 @@ _SOFT_NOISE_WORDS = frozenset({
|
|||||||
})
|
})
|
||||||
|
|
||||||
_TOKEN_STRIP_CHARS = ".,;:!?\"'()[]{},。;:!?、"
|
_TOKEN_STRIP_CHARS = ".,;:!?\"'()[]{},。;:!?、"
|
||||||
|
_MATCH_SEPARATOR_RE = re.compile(r"""[\s\-_./:;,'"()[\]{}]+""")
|
||||||
|
|
||||||
|
|
||||||
def simplify_query(query: str, max_words: int = 4) -> str:
|
def simplify_query(query: str, max_words: int = 4) -> str:
|
||||||
@@ -357,7 +359,65 @@ def _query_tokens(query: str) -> list[str]:
|
|||||||
|
|
||||||
def _candidate_text(candidate: AssetCandidate) -> str:
|
def _candidate_text(candidate: AssetCandidate) -> str:
|
||||||
"""Concatenate the candidate's matchable metadata fields for scoring."""
|
"""Concatenate the candidate's matchable metadata fields for scoring."""
|
||||||
return " ".join(filter(None, (candidate.title, candidate.author))).lower()
|
return " ".join(
|
||||||
|
filter(
|
||||||
|
None,
|
||||||
|
(
|
||||||
|
candidate.title,
|
||||||
|
candidate.author,
|
||||||
|
candidate.source_page_url,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
).lower()
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_match_text(text: str) -> str:
|
||||||
|
"""Normalize metadata / required terms for conservative substring matching."""
|
||||||
|
lowered = (text or "").lower()
|
||||||
|
return _MATCH_SEPARATOR_RE.sub(" ", lowered).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _term_group_alternatives(term_group: str) -> list[str]:
|
||||||
|
"""Split one required term group into alternatives.
|
||||||
|
|
||||||
|
``"Jiefangbei|Liberation Monument"`` means either alternative satisfies
|
||||||
|
that required group. Different list items are ANDed by
|
||||||
|
``missing_required_terms``.
|
||||||
|
"""
|
||||||
|
return [
|
||||||
|
_normalize_match_text(part)
|
||||||
|
for part in str(term_group or "").split("|")
|
||||||
|
if _normalize_match_text(part)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def missing_required_terms(
|
||||||
|
candidate: AssetCandidate,
|
||||||
|
required_terms: tuple[str, ...] | list[str] | None,
|
||||||
|
) -> list[str]:
|
||||||
|
"""Return required term groups not present in candidate metadata.
|
||||||
|
|
||||||
|
This is an entity-safety gate, not a fuzzy visual classifier. Use it for
|
||||||
|
exact subjects such as landmarks, people, companies, or products where a
|
||||||
|
visually nice but wrong image is worse than no image.
|
||||||
|
"""
|
||||||
|
if not required_terms:
|
||||||
|
return []
|
||||||
|
|
||||||
|
text = _normalize_match_text(_candidate_text(candidate))
|
||||||
|
compact_text = text.replace(" ", "")
|
||||||
|
missing: list[str] = []
|
||||||
|
for group in required_terms:
|
||||||
|
alternatives = _term_group_alternatives(group)
|
||||||
|
if not alternatives:
|
||||||
|
continue
|
||||||
|
matched = any(
|
||||||
|
alt in text or alt.replace(" ", "") in compact_text
|
||||||
|
for alt in alternatives
|
||||||
|
)
|
||||||
|
if not matched:
|
||||||
|
missing.append(str(group))
|
||||||
|
return missing
|
||||||
|
|
||||||
|
|
||||||
def compute_relevance(candidate: AssetCandidate, query: str) -> float:
|
def compute_relevance(candidate: AssetCandidate, query: str) -> float:
|
||||||
@@ -387,18 +447,34 @@ def score_candidate(candidate: AssetCandidate, request: ImageSearchRequest) -> f
|
|||||||
if not candidate.license_tier:
|
if not candidate.license_tier:
|
||||||
return float("-inf")
|
return float("-inf")
|
||||||
|
|
||||||
|
required_misses = missing_required_terms(candidate, request.required_terms)
|
||||||
|
if required_misses:
|
||||||
|
return float("-inf")
|
||||||
|
|
||||||
relevance = compute_relevance(candidate, request.query)
|
relevance = compute_relevance(candidate, request.query)
|
||||||
if relevance == 0.0:
|
if relevance == 0.0 and not request.required_terms:
|
||||||
return float("-inf")
|
return float("-inf")
|
||||||
|
|
||||||
score = relevance * 10000.0
|
score = relevance * 10000.0
|
||||||
|
title_text = _normalize_match_text(candidate.title)
|
||||||
|
compact_title = title_text.replace(" ", "")
|
||||||
|
for group in request.required_terms or ():
|
||||||
|
alternatives = _term_group_alternatives(group)
|
||||||
|
if any(
|
||||||
|
alt in title_text or alt.replace(" ", "") in compact_title
|
||||||
|
for alt in alternatives
|
||||||
|
):
|
||||||
|
score += 1500.0
|
||||||
|
|
||||||
# Penalize infrastructure/transit metadata if the user didn't explicitly ask for it.
|
# Penalize infrastructure/transit metadata if the user didn't explicitly ask for it.
|
||||||
# This prevents high-res subway station photos from outranking actual tourist landmarks.
|
# This prevents high-res subway station photos from outranking actual tourist landmarks.
|
||||||
text = _candidate_text(candidate)
|
text = _candidate_text(candidate)
|
||||||
query_lower = request.query.lower()
|
query_lower = request.query.lower()
|
||||||
infra_terms = ["station", "subway", "metro", "rail", "transit", "airport", "bus", "地铁", "站", "轨道"]
|
infra_terms = [
|
||||||
|
"station", "subway", "metro", "rail", "transit", "airport", "bus",
|
||||||
|
"地铁", "站", "轨道",
|
||||||
|
]
|
||||||
|
|
||||||
if not any(t in query_lower for t in infra_terms):
|
if not any(t in query_lower for t in infra_terms):
|
||||||
if any(t in text for t in infra_terms):
|
if any(t in text for t in infra_terms):
|
||||||
score -= 5000.0
|
score -= 5000.0
|
||||||
@@ -416,9 +492,13 @@ def score_candidate(candidate: AssetCandidate, request: ImageSearchRequest) -> f
|
|||||||
if request.min_height and candidate.height < request.min_height:
|
if request.min_height and candidate.height < request.min_height:
|
||||||
score -= 500.0
|
score -= 500.0
|
||||||
|
|
||||||
# Larger images score higher, capped to avoid runaway dominance.
|
if candidate.license_tier == LICENSE_TIER_NO_ATTRIBUTION:
|
||||||
|
score += 250.0
|
||||||
|
|
||||||
|
# Larger images score higher, but only as a tie-breaker; entity accuracy
|
||||||
|
# and metadata relevance must dominate pixel count.
|
||||||
pixel_score = max(candidate.width, 0) * max(candidate.height, 0) / 1000.0
|
pixel_score = max(candidate.width, 0) * max(candidate.height, 0) / 1000.0
|
||||||
score += min(pixel_score, 5000.0)
|
score += min(pixel_score, 1500.0)
|
||||||
return score
|
return score
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,332 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
PPT Master - Illustration Sheet Slicer
|
||||||
|
|
||||||
|
Slice one AI-generated "illustration sheet" (a single image whose prompt laid
|
||||||
|
out several illustration elements in a grid) into N individual element files in
|
||||||
|
the project's `images/` folder. This is the cheap-and-consistent path for spot
|
||||||
|
illustrations: generate one multi-element sheet with `image_gen.py` (one call,
|
||||||
|
one coherent style/palette), then cut the cells out here so each element is a
|
||||||
|
normal image the Executor places like any other.
|
||||||
|
|
||||||
|
Two optional cleanups address the realities of cropping a raster sheet:
|
||||||
|
--trim tight-crop each cell to its content bounding box, so imprecise AI
|
||||||
|
placement inside a cell does not leave lopsided margins.
|
||||||
|
--alpha knock the (flat) sheet background out to transparency, so an element
|
||||||
|
can sit on a differently-colored slide without a visible box.
|
||||||
|
Both need a background color; it is auto-sampled from each cell's border unless
|
||||||
|
you pass --bg.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python3 scripts/slice_images.py <sheet_image> --grid RxC [options]
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
python3 scripts/slice_images.py projects/demo/images/illus_sheet.png --grid 2x3
|
||||||
|
python3 scripts/slice_images.py projects/demo/images/illus_sheet.png --grid 2x3 \
|
||||||
|
--names team,product,customer,growth,risk,vision --trim --alpha
|
||||||
|
python3 scripts/slice_images.py projects/demo/images/illus_sheet.png --grid 1x4 \
|
||||||
|
--prefix spot_ --bg "#F8F9FA" --alpha
|
||||||
|
|
||||||
|
Dependencies:
|
||||||
|
Pillow
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from statistics import median
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from console_encoding import configure_utf8_stdio
|
||||||
|
|
||||||
|
configure_utf8_stdio()
|
||||||
|
|
||||||
|
from PIL import Image, ImageChops, ImageFilter
|
||||||
|
|
||||||
|
_GRID_RE = re.compile(r"^\s*(\d+)\s*[xX×]\s*(\d+)\s*$")
|
||||||
|
_BG_SAMPLE_BORDER = 2
|
||||||
|
_DEFAULT_FEATHER = 4
|
||||||
|
|
||||||
|
|
||||||
|
def _log(msg: str) -> None:
|
||||||
|
"""Print progress to stderr (stdout carries the created file paths)."""
|
||||||
|
print(msg, file=sys.stderr)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_grid(spec: str) -> tuple[int, int]:
|
||||||
|
"""Parse a 'RxC' grid spec into (rows, cols)."""
|
||||||
|
m = _GRID_RE.match(spec)
|
||||||
|
if not m:
|
||||||
|
raise ValueError(f"--grid must look like '2x3' (rows x cols), got {spec!r}")
|
||||||
|
rows, cols = int(m.group(1)), int(m.group(2))
|
||||||
|
if rows < 1 or cols < 1:
|
||||||
|
raise ValueError(f"--grid rows and cols must be >= 1, got {rows}x{cols}")
|
||||||
|
return rows, cols
|
||||||
|
|
||||||
|
|
||||||
|
def parse_hex(value: str) -> tuple[int, int, int]:
|
||||||
|
"""Parse '#RRGGBB' / 'RRGGBB' into an (r, g, b) tuple."""
|
||||||
|
h = value.strip().lstrip("#")
|
||||||
|
if len(h) != 6 or any(c not in "0123456789abcdefABCDEF" for c in h):
|
||||||
|
raise ValueError(f"--bg must be a 6-digit hex color, got {value!r}")
|
||||||
|
return int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16)
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_basename(name: str) -> str:
|
||||||
|
"""Reject path components in an output name — this tool writes bare files only."""
|
||||||
|
base = name.strip()
|
||||||
|
if (not base or base in {".", ".."} or ".." in base
|
||||||
|
or "/" in base or "\\" in base or Path(base).is_absolute()):
|
||||||
|
raise ValueError(f"unsafe output name {name!r}: must be a bare filename, no path parts")
|
||||||
|
return base
|
||||||
|
|
||||||
|
|
||||||
|
def _sample_bg(cell: Image.Image) -> tuple[int, int, int]:
|
||||||
|
"""Estimate the flat background color from a cell's border ring."""
|
||||||
|
rgb = cell.convert("RGB")
|
||||||
|
w, h = rgb.size
|
||||||
|
border = max(1, min(_BG_SAMPLE_BORDER, w, h))
|
||||||
|
px = rgb.load()
|
||||||
|
pixels = []
|
||||||
|
|
||||||
|
for y in range(border):
|
||||||
|
for x in range(w):
|
||||||
|
pixels.append(px[x, y])
|
||||||
|
|
||||||
|
bottom_start = max(border, h - border)
|
||||||
|
for y in range(bottom_start, h):
|
||||||
|
for x in range(w):
|
||||||
|
pixels.append(px[x, y])
|
||||||
|
|
||||||
|
right_start = max(border, w - border)
|
||||||
|
for y in range(border, bottom_start):
|
||||||
|
for x in range(border):
|
||||||
|
pixels.append(px[x, y])
|
||||||
|
for x in range(right_start, w):
|
||||||
|
pixels.append(px[x, y])
|
||||||
|
|
||||||
|
return tuple(round(median(channel)) for channel in zip(*pixels)) # type: ignore[return-value]
|
||||||
|
|
||||||
|
|
||||||
|
def _max_channel_difference(cell: Image.Image, bg: tuple[int, int, int]) -> Image.Image:
|
||||||
|
"""Return the maximum absolute RGB channel difference from the background."""
|
||||||
|
diff = ImageChops.difference(cell.convert("RGB"), Image.new("RGB", cell.size, bg))
|
||||||
|
red, green, blue = diff.split()
|
||||||
|
return ImageChops.lighter(ImageChops.lighter(red, green), blue)
|
||||||
|
|
||||||
|
|
||||||
|
def _soft_mask_from_diff(diff: Image.Image, tolerance: int) -> Image.Image:
|
||||||
|
"""Build a feathered alpha mask around the tolerance threshold."""
|
||||||
|
low = max(0, tolerance - _DEFAULT_FEATHER)
|
||||||
|
high = min(255, tolerance + _DEFAULT_FEATHER)
|
||||||
|
if high <= low:
|
||||||
|
return diff.point(lambda p: 255 if p > tolerance else 0)
|
||||||
|
|
||||||
|
span = high - low
|
||||||
|
lut = []
|
||||||
|
for value in range(256):
|
||||||
|
if value <= low:
|
||||||
|
lut.append(0)
|
||||||
|
elif value >= high:
|
||||||
|
lut.append(255)
|
||||||
|
else:
|
||||||
|
lut.append(round((value - low) * 255 / span))
|
||||||
|
return diff.point(lut)
|
||||||
|
|
||||||
|
|
||||||
|
def _content_masks(
|
||||||
|
cell: Image.Image,
|
||||||
|
bg: tuple[int, int, int],
|
||||||
|
tolerance: int,
|
||||||
|
) -> tuple[Image.Image, Image.Image]:
|
||||||
|
"""Build binary trim and soft alpha masks from the same color distance."""
|
||||||
|
diff = _max_channel_difference(cell, bg)
|
||||||
|
trim_mask = diff.point(lambda p: 255 if p > tolerance else 0)
|
||||||
|
alpha_mask = _soft_mask_from_diff(diff, tolerance)
|
||||||
|
alpha_mask = alpha_mask.filter(ImageFilter.MinFilter(3))
|
||||||
|
return trim_mask, alpha_mask
|
||||||
|
|
||||||
|
|
||||||
|
def slice_sheet(
|
||||||
|
sheet_path: Path,
|
||||||
|
rows: int,
|
||||||
|
cols: int,
|
||||||
|
output_dir: Path,
|
||||||
|
*,
|
||||||
|
names: Optional[list[str]] = None,
|
||||||
|
prefix: Optional[str] = None,
|
||||||
|
inset: float = 0.0,
|
||||||
|
trim: bool = False,
|
||||||
|
alpha: bool = False,
|
||||||
|
bg: Optional[tuple[int, int, int]] = None,
|
||||||
|
tolerance: int = 18,
|
||||||
|
) -> list[Path]:
|
||||||
|
"""Slice `sheet_path` into rows*cols element PNGs under `output_dir`.
|
||||||
|
|
||||||
|
Returns the list of written file paths (row-major order). When `names` is
|
||||||
|
given it must hold exactly rows*cols entries — a mismatch is an error so an
|
||||||
|
automated run never silently drops cells. Each name must be a bare filename.
|
||||||
|
"""
|
||||||
|
total_cells = rows * cols
|
||||||
|
if names is not None and len(names) != total_cells:
|
||||||
|
raise ValueError(
|
||||||
|
f"--names has {len(names)} entries but the {rows}x{cols} grid has "
|
||||||
|
f"{total_cells} cells; provide exactly one name per cell"
|
||||||
|
)
|
||||||
|
safe_names = [_safe_basename(n) for n in names] if names else None
|
||||||
|
if alpha and safe_names:
|
||||||
|
for name in safe_names:
|
||||||
|
suffix = Path(name).suffix.lower()
|
||||||
|
if suffix and suffix != ".png":
|
||||||
|
raise ValueError(f"--alpha requires .png output names, got {name!r}")
|
||||||
|
|
||||||
|
sheet = Image.open(sheet_path).convert("RGBA")
|
||||||
|
sw, sh = sheet.size
|
||||||
|
output_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
stem = sheet_path.stem
|
||||||
|
name_prefix = _safe_basename(prefix) if prefix else f"{stem}_"
|
||||||
|
written: list[Path] = []
|
||||||
|
|
||||||
|
idx = 0
|
||||||
|
for r in range(rows):
|
||||||
|
for c in range(cols):
|
||||||
|
# Integer cell box via per-index rounding to avoid drift.
|
||||||
|
x0, x1 = round(c * sw / cols), round((c + 1) * sw / cols)
|
||||||
|
y0, y1 = round(r * sh / rows), round((r + 1) * sh / rows)
|
||||||
|
if inset > 0:
|
||||||
|
dx = round((x1 - x0) * inset)
|
||||||
|
dy = round((y1 - y0) * inset)
|
||||||
|
x0, x1, y0, y1 = x0 + dx, x1 - dx, y0 + dy, y1 - dy
|
||||||
|
cell = sheet.crop((x0, y0, x1, y1))
|
||||||
|
|
||||||
|
trim_mask: Optional[Image.Image] = None
|
||||||
|
alpha_mask: Optional[Image.Image] = None
|
||||||
|
bbox = None
|
||||||
|
if trim or alpha:
|
||||||
|
cell_bg = bg if bg is not None else _sample_bg(cell)
|
||||||
|
trim_mask, alpha_mask = _content_masks(cell, cell_bg, tolerance)
|
||||||
|
bbox = trim_mask.getbbox()
|
||||||
|
if bbox is None:
|
||||||
|
raise ValueError(f"cell ({r},{c}) is all background; no element was sliced")
|
||||||
|
|
||||||
|
if trim and trim_mask is not None and alpha_mask is not None and bbox is not None:
|
||||||
|
cell = cell.crop(bbox)
|
||||||
|
alpha_mask = alpha_mask.crop(bbox)
|
||||||
|
|
||||||
|
if alpha and alpha_mask is not None:
|
||||||
|
cell.putalpha(alpha_mask)
|
||||||
|
|
||||||
|
if safe_names:
|
||||||
|
out_name = safe_names[idx]
|
||||||
|
if not Path(out_name).suffix:
|
||||||
|
out_name += ".png"
|
||||||
|
else:
|
||||||
|
out_name = f"{name_prefix}{idx + 1:02d}.png"
|
||||||
|
out_path = output_dir / out_name
|
||||||
|
cell.save(out_path)
|
||||||
|
written.append(out_path)
|
||||||
|
_log(f"[OK] cell ({r},{c}) -> {out_path.name} ({cell.width}x{cell.height})")
|
||||||
|
idx += 1
|
||||||
|
|
||||||
|
if len(written) != total_cells:
|
||||||
|
raise ValueError(f"sliced {len(written)} elements but expected {total_cells}")
|
||||||
|
return written
|
||||||
|
|
||||||
|
|
||||||
|
def build_parser() -> argparse.ArgumentParser:
|
||||||
|
"""Build the command-line parser."""
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Slice an AI illustration sheet into individual element images.",
|
||||||
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||||
|
epilog="""Examples:
|
||||||
|
python3 scripts/slice_images.py projects/demo/images/illus_sheet.png --grid 2x3
|
||||||
|
python3 scripts/slice_images.py projects/demo/images/illus_sheet.png --grid 2x3 \\
|
||||||
|
--names team,product,customer,growth,risk,vision --trim --alpha
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
parser.add_argument("sheet", help="Path to the generated illustration sheet image")
|
||||||
|
parser.add_argument("--grid", required=True, help="Grid as 'RxC' (rows x cols), e.g. 2x3")
|
||||||
|
parser.add_argument(
|
||||||
|
"-o", "--output", default=None,
|
||||||
|
help="Output directory (default: the sheet's own directory)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--names", default=None,
|
||||||
|
help="Comma-separated element names, row-major (extension optional). "
|
||||||
|
"Must provide exactly rows*cols bare filenames.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--prefix", default=None,
|
||||||
|
help="Filename prefix when --names is absent (default: '<sheet-stem>_')",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--inset", type=float, default=0.0,
|
||||||
|
help="Trim each cell inward by this fraction on every side (0-0.49) to drop gutters",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--trim", action="store_true",
|
||||||
|
help="Tight-crop each cell to its content bounding box",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--alpha", action="store_true",
|
||||||
|
help="Make the (flat) background transparent in each element",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--bg", default=None,
|
||||||
|
help="Background hex color for --trim/--alpha (default: auto-sample cell border)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--tolerance", type=int, default=18,
|
||||||
|
help="Maximum per-channel color distance treated as background for --trim/--alpha "
|
||||||
|
"(default: 18)",
|
||||||
|
)
|
||||||
|
return parser
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: Optional[list[str]] = None) -> int:
|
||||||
|
"""Run the CLI entry point."""
|
||||||
|
parser = build_parser()
|
||||||
|
args = parser.parse_args(argv)
|
||||||
|
|
||||||
|
sheet_path = Path(args.sheet)
|
||||||
|
if not sheet_path.exists():
|
||||||
|
print(f"[ERROR] Sheet not found: {sheet_path}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
try:
|
||||||
|
rows, cols = parse_grid(args.grid)
|
||||||
|
bg = parse_hex(args.bg) if args.bg else None
|
||||||
|
except ValueError as exc:
|
||||||
|
print(f"[ERROR] {exc}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
if not 0.0 <= args.inset < 0.5:
|
||||||
|
print("[ERROR] --inset must be in [0, 0.5)", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
if not 0 <= args.tolerance <= 255:
|
||||||
|
print("[ERROR] --tolerance must be in [0, 255]", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
names = [n.strip() for n in args.names.split(",") if n.strip()] if args.names else None
|
||||||
|
output_dir = Path(args.output) if args.output else sheet_path.parent
|
||||||
|
|
||||||
|
try:
|
||||||
|
written = slice_sheet(
|
||||||
|
sheet_path, rows, cols, output_dir,
|
||||||
|
names=names, prefix=args.prefix, inset=args.inset,
|
||||||
|
trim=args.trim, alpha=args.alpha, bg=bg, tolerance=args.tolerance,
|
||||||
|
)
|
||||||
|
except (OSError, ValueError) as exc:
|
||||||
|
print(f"[ERROR] Slicing failed: {exc}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
_log(f"\n[DONE] Wrote {len(written)} element(s) to {output_dir}")
|
||||||
|
for p in written:
|
||||||
|
print(p)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -866,6 +866,51 @@ def _legacy_live_lock(project_path: Path) -> Optional[dict]:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _shutdown_existing(project_path: Path) -> int:
|
||||||
|
"""Stop a live-preview server for this project (idempotent)."""
|
||||||
|
lock_file = _lock_file(project_path)
|
||||||
|
existing = _read_lock(lock_file)
|
||||||
|
legacy_lock_file = project_path / LEGACY_LOCK_FILE_NAME
|
||||||
|
if not existing:
|
||||||
|
existing = _read_lock(legacy_lock_file)
|
||||||
|
lock_file = legacy_lock_file
|
||||||
|
if not existing:
|
||||||
|
logger.info('no live preview server running — nothing to stop')
|
||||||
|
return 0
|
||||||
|
|
||||||
|
pid = int(existing.get('pid', 0) or 0)
|
||||||
|
port = existing.get('port')
|
||||||
|
if not _process_alive(pid):
|
||||||
|
_release_lock(lock_file)
|
||||||
|
logger.info('live preview already stopped; cleared stale lock')
|
||||||
|
return 0
|
||||||
|
|
||||||
|
if port:
|
||||||
|
try:
|
||||||
|
req = urllib.request.Request(
|
||||||
|
f'http://127.0.0.1:{port}/api/shutdown',
|
||||||
|
data=b'{"reason": "cli-shutdown"}',
|
||||||
|
headers={'Content-Type': 'application/json'},
|
||||||
|
method='POST',
|
||||||
|
)
|
||||||
|
urllib.request.urlopen(req, timeout=3)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
for _ in range(20):
|
||||||
|
if not _process_alive(pid):
|
||||||
|
break
|
||||||
|
time.sleep(0.1)
|
||||||
|
if _process_alive(pid):
|
||||||
|
try:
|
||||||
|
os.kill(pid, signal.SIGTERM)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
_release_lock(lock_file)
|
||||||
|
logger.info('live preview server stopped (pid=%s)', pid)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
def _wait_for_ready(url: str, proc: subprocess.Popen, timeout: int = 15) -> bool:
|
def _wait_for_ready(url: str, proc: subprocess.Popen, timeout: int = 15) -> bool:
|
||||||
"""Wait until the server responds or the child exits."""
|
"""Wait until the server responds or the child exits."""
|
||||||
deadline = time.time() + timeout
|
deadline = time.time() + timeout
|
||||||
@@ -920,6 +965,11 @@ def build_parser() -> argparse.ArgumentParser:
|
|||||||
default=None,
|
default=None,
|
||||||
help='Idle timeout in seconds (default: 900; live mode default: 7200; 0 = disabled)',
|
help='Idle timeout in seconds (default: 900; live mode default: 7200; 0 = disabled)',
|
||||||
)
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'--shutdown',
|
||||||
|
action='store_true',
|
||||||
|
help='Stop a live-preview server left running for this project, then exit (idempotent).',
|
||||||
|
)
|
||||||
return parser
|
return parser
|
||||||
|
|
||||||
|
|
||||||
@@ -934,6 +984,9 @@ def main(argv: Optional[list[str]] = None) -> int:
|
|||||||
)
|
)
|
||||||
|
|
||||||
project_path = Path(args.project_dir).resolve()
|
project_path = Path(args.project_dir).resolve()
|
||||||
|
if args.shutdown:
|
||||||
|
return _shutdown_existing(project_path)
|
||||||
|
|
||||||
svg_output = project_path / 'svg_output'
|
svg_output = project_path / 'svg_output'
|
||||||
if not svg_output.exists():
|
if not svg_output.exists():
|
||||||
if args.live:
|
if args.live:
|
||||||
|
|||||||
+350
-16
@@ -11,6 +11,7 @@
|
|||||||
page_title: "PPT Master - Live Preview",
|
page_title: "PPT Master - Live Preview",
|
||||||
panel_slides: "Slides",
|
panel_slides: "Slides",
|
||||||
panel_annotations: "Annotations",
|
panel_annotations: "Annotations",
|
||||||
|
panel_edit_annotate: "Edit / Annotate",
|
||||||
placeholder_select_slide: "Select a slide on the left to begin",
|
placeholder_select_slide: "Select a slide on the left to begin",
|
||||||
label_selected_element: "Selected element",
|
label_selected_element: "Selected element",
|
||||||
empty_selected_element: "Click an element on the slide to select it",
|
empty_selected_element: "Click an element on the slide to select it",
|
||||||
@@ -22,6 +23,14 @@
|
|||||||
section_text_style: "Text",
|
section_text_style: "Text",
|
||||||
section_raw_attrs: "Raw attributes",
|
section_raw_attrs: "Raw attributes",
|
||||||
label_edit_instruction: "Edit instruction",
|
label_edit_instruction: "Edit instruction",
|
||||||
|
pending_none: "No pending changes",
|
||||||
|
pending_summary: "{edits} direct edit(s), {annotations} annotation page(s) pending",
|
||||||
|
pending_pages: "Pages: {pages}",
|
||||||
|
quick_align: "Align / move",
|
||||||
|
quick_resize: "Resize",
|
||||||
|
quick_replace_image: "Replace image",
|
||||||
|
quick_copy: "Revise copy",
|
||||||
|
quick_relayout: "Relayout area",
|
||||||
placeholder_annotation: "Describe how the AI should modify this element...",
|
placeholder_annotation: "Describe how the AI should modify this element...",
|
||||||
placeholder_annotation_multi: "Describe how to modify all {count} elements...",
|
placeholder_annotation_multi: "Describe how to modify all {count} elements...",
|
||||||
btn_add_annotation: "Add annotation",
|
btn_add_annotation: "Add annotation",
|
||||||
@@ -54,10 +63,15 @@
|
|||||||
err_empty_svg: "Slide loaded but the canvas is empty. The SVG may be malformed or missing a root <svg> element.",
|
err_empty_svg: "Slide loaded but the canvas is empty. The SVG may be malformed or missing a root <svg> element.",
|
||||||
warn_icon_inline: "{count} icon(s) failed to render: {names}",
|
warn_icon_inline: "{count} icon(s) failed to render: {names}",
|
||||||
warn_svg_no_dims: "SVG is missing width/height attributes. Please ask the AI to strictly follow shared-standards.md §4 and include width & height in the SVG root element.",
|
warn_svg_no_dims: "SVG is missing width/height attributes. Please ask the AI to strictly follow shared-standards.md §4 and include width & height in the SVG root element.",
|
||||||
|
warn_matrix_transform: "This geometry edit is stored as a transform matrix. Preview is exact; PPTX export depends on matrix-aware conversion.",
|
||||||
|
modal_matrix_transform_note: "\n\nNote: at least one staged geometry edit uses a transform matrix. Re-export with the current PPTX exporter so the matrix is applied.",
|
||||||
slide_error_tooltip: "Failed to parse this slide: ",
|
slide_error_tooltip: "Failed to parse this slide: ",
|
||||||
reload_banner: "This slide was updated on disk. Click to reload.",
|
reload_banner: "This slide was updated on disk. Click to reload.",
|
||||||
modal_confirm_submit: "Apply staged attribute edits and annotations to disk?\n\nThe preview service will keep running. Click Exit preview when you want to stop it.",
|
modal_confirm_submit: "Apply staged direct edits and AI annotations to disk?\n\nThe preview service will keep running. Click Exit preview when you want to stop it.",
|
||||||
modal_success_submit: "Changes saved to svg_output.\n\nReturn to the chat to re-export the PPTX or apply AI-needed annotations. The preview service is still running.",
|
modal_success_submit: "Changes saved to svg_output.\n\nThe preview service is still running.",
|
||||||
|
modal_success_direct_only: "Changes saved to svg_output.\n\nDirect edits are now in the SVG source. Return to the chat and ask to re-export when you want a refreshed PPTX. The preview service is still running.",
|
||||||
|
modal_success_annotations_only: "Annotations saved to svg_output.\n\nReturn to the chat and ask to apply annotations when you want AI to interpret them. The preview service is still running.",
|
||||||
|
modal_success_mixed: "Direct edits and annotations saved to svg_output.\n\nReturn to the chat to apply AI-needed annotations, then re-export the PPTX when ready. The preview service is still running.",
|
||||||
modal_confirm_exit: "Exit preview and stop the local server?\n\nUnapplied edits and annotations will be discarded.",
|
modal_confirm_exit: "Exit preview and stop the local server?\n\nUnapplied edits and annotations will be discarded.",
|
||||||
modal_success_exit: "Preview stopped.\n\nYou can close this tab and return to the chat.",
|
modal_success_exit: "Preview stopped.\n\nYou can close this tab and return to the chat.",
|
||||||
modal_stopping: "Stopping preview server...",
|
modal_stopping: "Stopping preview server...",
|
||||||
@@ -73,6 +87,7 @@
|
|||||||
page_title: "PPT Master - 实时预览",
|
page_title: "PPT Master - 实时预览",
|
||||||
panel_slides: "幻灯片",
|
panel_slides: "幻灯片",
|
||||||
panel_annotations: "标注",
|
panel_annotations: "标注",
|
||||||
|
panel_edit_annotate: "编辑 / 标注",
|
||||||
placeholder_select_slide: "在左侧选择一张幻灯片开始",
|
placeholder_select_slide: "在左侧选择一张幻灯片开始",
|
||||||
label_selected_element: "已选元素",
|
label_selected_element: "已选元素",
|
||||||
empty_selected_element: "点击幻灯片中的元素进行选择",
|
empty_selected_element: "点击幻灯片中的元素进行选择",
|
||||||
@@ -84,6 +99,14 @@
|
|||||||
section_text_style: "文本",
|
section_text_style: "文本",
|
||||||
section_raw_attrs: "原始属性",
|
section_raw_attrs: "原始属性",
|
||||||
label_edit_instruction: "修改说明",
|
label_edit_instruction: "修改说明",
|
||||||
|
pending_none: "没有待应用修改",
|
||||||
|
pending_summary: "{edits} 条直接修改、{annotations} 个页面有 AI 标注待应用",
|
||||||
|
pending_pages: "页面:{pages}",
|
||||||
|
quick_align: "对齐/移动",
|
||||||
|
quick_resize: "调整大小",
|
||||||
|
quick_replace_image: "换图",
|
||||||
|
quick_copy: "改文案",
|
||||||
|
quick_relayout: "重排此区域",
|
||||||
placeholder_annotation: "描述希望 AI 如何修改该元素……",
|
placeholder_annotation: "描述希望 AI 如何修改该元素……",
|
||||||
placeholder_annotation_multi: "描述希望如何修改所选 {count} 个元素……",
|
placeholder_annotation_multi: "描述希望如何修改所选 {count} 个元素……",
|
||||||
btn_add_annotation: "添加标注",
|
btn_add_annotation: "添加标注",
|
||||||
@@ -116,10 +139,15 @@
|
|||||||
err_empty_svg: "幻灯片已加载但画布为空。SVG 可能损坏或缺少根 <svg> 元素。",
|
err_empty_svg: "幻灯片已加载但画布为空。SVG 可能损坏或缺少根 <svg> 元素。",
|
||||||
warn_icon_inline: "{count} 个图标渲染失败:{names}",
|
warn_icon_inline: "{count} 个图标渲染失败:{names}",
|
||||||
warn_svg_no_dims: "SVG 缺少 width/height 属性,预览可能异常。请让 AI 严格遵守 shared-standards.md §4 规范,在 SVG 根元素中补全 width 和 height。",
|
warn_svg_no_dims: "SVG 缺少 width/height 属性,预览可能异常。请让 AI 严格遵守 shared-standards.md §4 规范,在 SVG 根元素中补全 width 和 height。",
|
||||||
|
warn_matrix_transform: "本次几何修改会以 transform matrix 保存。预览是准确的;PPTX 导出需要使用支持 matrix 的当前导出器。",
|
||||||
|
modal_matrix_transform_note: "\n\n提示:至少有一条暂存几何修改使用了 transform matrix。请用当前 PPTX 导出器重新导出,确保 matrix 被应用。",
|
||||||
slide_error_tooltip: "该幻灯片解析失败:",
|
slide_error_tooltip: "该幻灯片解析失败:",
|
||||||
reload_banner: "当前页已在磁盘上更新,点此重新加载。",
|
reload_banner: "当前页已在磁盘上更新,点此重新加载。",
|
||||||
modal_confirm_submit: "确认将暂存属性修改和标注写入磁盘?\n\n预览服务会继续运行。需要关闭时请点击退出预览。",
|
modal_confirm_submit: "确认将暂存的直接修改和 AI 标注写入磁盘?\n\n预览服务会继续运行。需要关闭时请点击退出预览。",
|
||||||
modal_success_submit: "修改已保存到 svg_output。\n\n请回到对话窗口重新导出 PPTX,或让 AI 应用需要判断的标注。预览服务仍在运行。",
|
modal_success_submit: "修改已保存到 svg_output。\n\n预览服务仍在运行。",
|
||||||
|
modal_success_direct_only: "修改已保存到 svg_output。\n\n直接修改已经写入 SVG 源文件;需要刷新 PPTX 时,请回到对话窗口要求重新导出。预览服务仍在运行。",
|
||||||
|
modal_success_annotations_only: "标注已保存到 svg_output。\n\n需要 AI 理解并执行这些标注时,请回到对话窗口要求应用标注。预览服务仍在运行。",
|
||||||
|
modal_success_mixed: "直接修改和标注已保存到 svg_output。\n\n请回到对话窗口先应用需要 AI 判断的标注,确认后再重新导出 PPTX。预览服务仍在运行。",
|
||||||
modal_confirm_exit: "退出预览并停止本地服务?\n\n未应用的属性修改和标注将被丢弃。",
|
modal_confirm_exit: "退出预览并停止本地服务?\n\n未应用的属性修改和标注将被丢弃。",
|
||||||
modal_success_exit: "预览已停止。\n\n可以关闭本标签页并回到对话窗口。",
|
modal_success_exit: "预览已停止。\n\n可以关闭本标签页并回到对话窗口。",
|
||||||
modal_stopping: "正在停止预览服务……",
|
modal_stopping: "正在停止预览服务……",
|
||||||
@@ -184,6 +212,8 @@
|
|||||||
updateSelectionPanel();
|
updateSelectionPanel();
|
||||||
updateAnnotationList();
|
updateAnnotationList();
|
||||||
updateUndoButton();
|
updateUndoButton();
|
||||||
|
updatePendingStatus();
|
||||||
|
initAnnotationQuickActions();
|
||||||
loadSlides();
|
loadSlides();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -193,6 +223,7 @@
|
|||||||
var svgContent = document.getElementById("svg-content");
|
var svgContent = document.getElementById("svg-content");
|
||||||
var selectedElementEl = document.getElementById("selected-element");
|
var selectedElementEl = document.getElementById("selected-element");
|
||||||
var annotationInput = document.getElementById("annotation-input");
|
var annotationInput = document.getElementById("annotation-input");
|
||||||
|
var annotationQuickActions = document.getElementById("annotation-quick-actions");
|
||||||
var annotationText = document.getElementById("annotation-text");
|
var annotationText = document.getElementById("annotation-text");
|
||||||
var btnAddAnnotation = document.getElementById("btn-add-annotation");
|
var btnAddAnnotation = document.getElementById("btn-add-annotation");
|
||||||
var annotationsEl = document.getElementById("annotations");
|
var annotationsEl = document.getElementById("annotations");
|
||||||
@@ -204,6 +235,7 @@
|
|||||||
var modalConfirm = document.getElementById("modal-confirm");
|
var modalConfirm = document.getElementById("modal-confirm");
|
||||||
var modalCancel = document.getElementById("modal-cancel");
|
var modalCancel = document.getElementById("modal-cancel");
|
||||||
var elementPropsEl = document.getElementById("element-props");
|
var elementPropsEl = document.getElementById("element-props");
|
||||||
|
var pendingStatusEl = document.getElementById("pending-status");
|
||||||
|
|
||||||
var navFirstBtn = document.getElementById("nav-first");
|
var navFirstBtn = document.getElementById("nav-first");
|
||||||
var navPrevBtn = document.getElementById("nav-prev");
|
var navPrevBtn = document.getElementById("nav-prev");
|
||||||
@@ -224,7 +256,98 @@
|
|||||||
var reloadBannerEl = null; // singleton banner element shown when currentSlide mtime drifts
|
var reloadBannerEl = null; // singleton banner element shown when currentSlide mtime drifts
|
||||||
var editStackCount = {}; // {name: staged edit count} — mirrors backend PENDING_EDITS
|
var editStackCount = {}; // {name: staged edit count} — mirrors backend PENDING_EDITS
|
||||||
var savedHintShown = false; // show the "staged edit" hint once per session
|
var savedHintShown = false; // show the "staged edit" hint once per session
|
||||||
|
var matrixHintShown = false; // show transform-matrix export hint once per session
|
||||||
|
var matrixEditSlides = {}; // {name: true} when a staged edit wrote transform=matrix(...)
|
||||||
var annotationsDirty = false; // unsaved annotations added/removed this session
|
var annotationsDirty = false; // unsaved annotations added/removed this session
|
||||||
|
var annotationEditSlides = {}; // {name: true} when annotations changed this session
|
||||||
|
|
||||||
|
function pendingDirectEditCount() {
|
||||||
|
return Object.keys(editStackCount).reduce(function (total, name) {
|
||||||
|
return total + Math.max(0, editStackCount[name] || 0);
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function pendingDirectEditSlides() {
|
||||||
|
return Object.keys(editStackCount).filter(function (name) {
|
||||||
|
return (editStackCount[name] || 0) > 0;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function pendingAnnotationSlides() {
|
||||||
|
return Object.keys(annotationEditSlides).filter(function (name) {
|
||||||
|
return !!annotationEditSlides[name];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function pendingAnnotationChangeCount() {
|
||||||
|
return pendingAnnotationSlides().length;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pendingSlideNames() {
|
||||||
|
var seen = {};
|
||||||
|
pendingDirectEditSlides().concat(pendingAnnotationSlides()).forEach(function (name) {
|
||||||
|
seen[name] = true;
|
||||||
|
});
|
||||||
|
return Object.keys(seen).sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updatePendingStatus() {
|
||||||
|
if (!pendingStatusEl) return;
|
||||||
|
var edits = pendingDirectEditCount();
|
||||||
|
var annotations = pendingAnnotationChangeCount();
|
||||||
|
if (edits === 0 && annotations === 0) {
|
||||||
|
pendingStatusEl.className = "pending-status empty";
|
||||||
|
pendingStatusEl.textContent = t("pending_none");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var pages = pendingSlideNames();
|
||||||
|
pendingStatusEl.className = "pending-status active";
|
||||||
|
pendingStatusEl.innerHTML =
|
||||||
|
'<div>' + escapeHtml(t("pending_summary", {
|
||||||
|
edits: edits,
|
||||||
|
annotations: annotations,
|
||||||
|
})) + '</div>' +
|
||||||
|
(pages.length ? '<div class="pending-pages">' + escapeHtml(t("pending_pages", {
|
||||||
|
pages: pages.join(", "),
|
||||||
|
})) + '</div>' : '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function markAnnotationChanged(slide) {
|
||||||
|
if (!slide) return;
|
||||||
|
annotationEditSlides[slide] = true;
|
||||||
|
annotationsDirty = true;
|
||||||
|
updatePendingStatus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function initAnnotationQuickActions() {
|
||||||
|
if (!annotationQuickActions) return;
|
||||||
|
var actions = [
|
||||||
|
{ key: "quick_align" },
|
||||||
|
{ key: "quick_resize" },
|
||||||
|
{ key: "quick_replace_image" },
|
||||||
|
{ key: "quick_copy" },
|
||||||
|
{ key: "quick_relayout" },
|
||||||
|
];
|
||||||
|
annotationQuickActions.innerHTML = "";
|
||||||
|
actions.forEach(function (action) {
|
||||||
|
var btn = document.createElement("button");
|
||||||
|
btn.type = "button";
|
||||||
|
btn.className = "annotation-quick-chip";
|
||||||
|
btn.textContent = t(action.key);
|
||||||
|
btn.addEventListener("click", function () {
|
||||||
|
var label = t(action.key);
|
||||||
|
var prefix = LANG === "zh" ? label + ":" : label + ": ";
|
||||||
|
if (!annotationText.value.trim()) {
|
||||||
|
annotationText.value = prefix;
|
||||||
|
} else if (annotationText.value.indexOf(prefix) === -1) {
|
||||||
|
annotationText.value = prefix + annotationText.value;
|
||||||
|
}
|
||||||
|
annotationText.focus();
|
||||||
|
annotationText.selectionStart = annotationText.selectionEnd = annotationText.value.length;
|
||||||
|
});
|
||||||
|
annotationQuickActions.appendChild(btn);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Staged edits live in server memory until "Apply changes"; the server can
|
// Staged edits live in server memory until "Apply changes"; the server can
|
||||||
// still idle-timeout or be killed and drop them. Warn before the tab leaves
|
// still idle-timeout or be killed and drop them. Warn before the tab leaves
|
||||||
@@ -304,6 +427,7 @@
|
|||||||
svgContent.style.display = "none";
|
svgContent.style.display = "none";
|
||||||
}
|
}
|
||||||
updateNavLabel();
|
updateNavLabel();
|
||||||
|
updatePendingStatus();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -356,6 +480,7 @@
|
|||||||
showReloadBanner(currentSlide);
|
showReloadBanner(currentSlide);
|
||||||
}
|
}
|
||||||
updateNavLabel();
|
updateNavLabel();
|
||||||
|
updatePendingStatus();
|
||||||
})
|
})
|
||||||
.catch(function (err) {
|
.catch(function (err) {
|
||||||
console.error("loadSlides:", err);
|
console.error("loadSlides:", err);
|
||||||
@@ -468,6 +593,7 @@
|
|||||||
refreshAnnotationVisuals();
|
refreshAnnotationVisuals();
|
||||||
updateAnnotationList();
|
updateAnnotationList();
|
||||||
updateUndoButton();
|
updateUndoButton();
|
||||||
|
updatePendingStatus();
|
||||||
})
|
})
|
||||||
.catch(function (err) {
|
.catch(function (err) {
|
||||||
console.error("selectSlide:", err);
|
console.error("selectSlide:", err);
|
||||||
@@ -1253,7 +1379,7 @@
|
|||||||
ids.forEach(function (eid) {
|
ids.forEach(function (eid) {
|
||||||
slideAnnotations[eid] = text;
|
slideAnnotations[eid] = text;
|
||||||
});
|
});
|
||||||
annotationsDirty = true;
|
markAnnotationChanged(currentSlide);
|
||||||
refreshAnnotationVisuals();
|
refreshAnnotationVisuals();
|
||||||
updateAnnotationList();
|
updateAnnotationList();
|
||||||
annotationText.value = "";
|
annotationText.value = "";
|
||||||
@@ -1277,7 +1403,7 @@
|
|||||||
.then(function (res) { return res.json(); })
|
.then(function (res) { return res.json(); })
|
||||||
.then(function () {
|
.then(function () {
|
||||||
delete slideAnnotations[elementId];
|
delete slideAnnotations[elementId];
|
||||||
annotationsDirty = true;
|
markAnnotationChanged(currentSlide);
|
||||||
refreshAnnotationVisuals();
|
refreshAnnotationVisuals();
|
||||||
updateAnnotationList();
|
updateAnnotationList();
|
||||||
loadSlides();
|
loadSlides();
|
||||||
@@ -1366,7 +1492,8 @@
|
|||||||
// ================================================================
|
// ================================================================
|
||||||
btnSave.addEventListener("click", function () {
|
btnSave.addEventListener("click", function () {
|
||||||
pendingModalAction = "submit";
|
pendingModalAction = "submit";
|
||||||
modalMessage.textContent = t("modal_confirm_submit");
|
modalMessage.textContent = t("modal_confirm_submit") +
|
||||||
|
(hasPendingMatrixEdits() ? t("modal_matrix_transform_note") : "");
|
||||||
modalConfirm.textContent = t("modal_submit");
|
modalConfirm.textContent = t("modal_submit");
|
||||||
modalConfirm.style.display = "";
|
modalConfirm.style.display = "";
|
||||||
modalCancel.style.display = "";
|
modalCancel.style.display = "";
|
||||||
@@ -1402,6 +1529,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Step 2: save annotations. Service lifetime is controlled only by Exit preview.
|
// Step 2: save annotations. Service lifetime is controlled only by Exit preview.
|
||||||
|
var hadDirectEdits = pendingDirectEditCount() > 0;
|
||||||
|
var hadAnnotationChanges = pendingAnnotationChangeCount() > 0;
|
||||||
modalConfirm.style.display = "none";
|
modalConfirm.style.display = "none";
|
||||||
modalCancel.style.display = "none";
|
modalCancel.style.display = "none";
|
||||||
|
|
||||||
@@ -1414,11 +1543,23 @@
|
|||||||
if (data.error) {
|
if (data.error) {
|
||||||
modalMessage.textContent = t("err_save") + data.error;
|
modalMessage.textContent = t("err_save") + data.error;
|
||||||
} else {
|
} else {
|
||||||
modalMessage.textContent = t("modal_success_submit");
|
if (hadDirectEdits && hadAnnotationChanges) {
|
||||||
|
modalMessage.textContent = t("modal_success_mixed");
|
||||||
|
} else if (hadDirectEdits) {
|
||||||
|
modalMessage.textContent = t("modal_success_direct_only");
|
||||||
|
} else if (hadAnnotationChanges) {
|
||||||
|
modalMessage.textContent = t("modal_success_annotations_only");
|
||||||
|
} else {
|
||||||
|
modalMessage.textContent = t("modal_success_submit");
|
||||||
|
}
|
||||||
editStackCount = {};
|
editStackCount = {};
|
||||||
|
matrixEditSlides = {};
|
||||||
|
matrixHintShown = false;
|
||||||
savedHintShown = false;
|
savedHintShown = false;
|
||||||
annotationsDirty = false;
|
annotationsDirty = false;
|
||||||
|
annotationEditSlides = {};
|
||||||
updateUndoButton();
|
updateUndoButton();
|
||||||
|
updatePendingStatus();
|
||||||
var activeSlide = currentSlide;
|
var activeSlide = currentSlide;
|
||||||
loadSlides().then(function () {
|
loadSlides().then(function () {
|
||||||
if (activeSlide) {
|
if (activeSlide) {
|
||||||
@@ -1561,7 +1702,9 @@
|
|||||||
.then(function (data) {
|
.then(function (data) {
|
||||||
if (data.status === "empty") {
|
if (data.status === "empty") {
|
||||||
editStackCount[currentSlide] = 0;
|
editStackCount[currentSlide] = 0;
|
||||||
|
matrixEditSlides[currentSlide] = false;
|
||||||
updateUndoButton();
|
updateUndoButton();
|
||||||
|
updatePendingStatus();
|
||||||
showWarning(t("undo_empty"));
|
showWarning(t("undo_empty"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1572,6 +1715,7 @@
|
|||||||
// selectSlide refreshes editStackCount + the undo button.
|
// selectSlide refreshes editStackCount + the undo button.
|
||||||
var item = slideListEl.querySelector('.slide-item[data-name="' + cssAttr(currentSlide) + '"]');
|
var item = slideListEl.querySelector('.slide-item[data-name="' + cssAttr(currentSlide) + '"]');
|
||||||
selectSlide(currentSlide, item || undefined);
|
selectSlide(currentSlide, item || undefined);
|
||||||
|
updatePendingStatus();
|
||||||
showWarning(t("undo_done"));
|
showWarning(t("undo_done"));
|
||||||
})
|
})
|
||||||
.catch(function (err) {
|
.catch(function (err) {
|
||||||
@@ -1585,6 +1729,24 @@
|
|||||||
showWarning(t("edit_saved_hint"));
|
showWarning(t("edit_saved_hint"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function payloadHasMatrixTransform(payload) {
|
||||||
|
var transform = payload && payload.attrs && payload.attrs.transform;
|
||||||
|
return typeof transform === "string" && /^\s*matrix\s*\(/i.test(transform);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasPendingMatrixEdits() {
|
||||||
|
return Object.keys(matrixEditSlides).some(function (name) {
|
||||||
|
return !!matrixEditSlides[name];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function markMatrixTransformEdit(slide) {
|
||||||
|
matrixEditSlides[slide] = true;
|
||||||
|
if (matrixHintShown) return;
|
||||||
|
matrixHintShown = true;
|
||||||
|
showWarning(t("warn_matrix_transform"));
|
||||||
|
}
|
||||||
|
|
||||||
// ================================================================
|
// ================================================================
|
||||||
// Property extraction & rendering
|
// Property extraction & rendering
|
||||||
// ================================================================
|
// ================================================================
|
||||||
@@ -1621,18 +1783,28 @@
|
|||||||
} catch (e) { /* no geometry */ }
|
} catch (e) { /* no geometry */ }
|
||||||
html += '</table>';
|
html += '</table>';
|
||||||
|
|
||||||
// L1: text content — only when the element owns no <tspan> children.
|
var textStyleTargets = textStyleTargetsForSelection(el);
|
||||||
if (tag === "text" || tag === "tspan") {
|
|
||||||
|
// L1: text content + computed text styles. When a textbox/group is
|
||||||
|
// selected, style controls target its descendant text leaves.
|
||||||
|
if (tag === "text" || tag === "tspan" || textStyleTargets.length > 0) {
|
||||||
html += '<div class="prop-section">' + escapeHtml(t("section_text_style")) + '</div>';
|
html += '<div class="prop-section">' + escapeHtml(t("section_text_style")) + '</div>';
|
||||||
html += '<table class="prop-table">';
|
html += '<table class="prop-table">';
|
||||||
if (el.querySelector("tspan")) {
|
if ((tag === "text" || tag === "tspan") && el.querySelector("tspan")) {
|
||||||
html += '<tr><td class="prop-key">content</td><td class="prop-val prop-note">' +
|
html += '<tr><td class="prop-key">content</td><td class="prop-val prop-note">' +
|
||||||
escapeHtml(t("prop_multiline_hint")) + '</td></tr>';
|
escapeHtml(t("prop_multiline_hint")) + '</td></tr>';
|
||||||
} else {
|
} else if (tag === "text" || tag === "tspan") {
|
||||||
html += '<tr><td class="prop-key">content</td><td class="prop-val">' +
|
html += '<tr><td class="prop-key">content</td><td class="prop-val">' +
|
||||||
'<textarea class="prop-edit prop-edit-content" rows="2">' +
|
'<textarea class="prop-edit prop-edit-content" rows="2">' +
|
||||||
escapeHtml(el.textContent || "") + '</textarea></td></tr>';
|
escapeHtml(el.textContent || "") + '</textarea></td></tr>';
|
||||||
}
|
}
|
||||||
|
if (textStyleTargets.length > 0) {
|
||||||
|
textStyleSpecs(textStyleTargets).forEach(function (spec) {
|
||||||
|
html += '<tr><td class="prop-key">' + escapeHtml(spec.key) + '</td><td class="prop-val">';
|
||||||
|
html += renderTextStyleControl(spec, textStyleTargets);
|
||||||
|
html += '</td></tr>';
|
||||||
|
});
|
||||||
|
}
|
||||||
html += '</table>';
|
html += '</table>';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1682,6 +1854,88 @@
|
|||||||
return /^[A-Za-z_][A-Za-z0-9_.:-]*$/.test(key);
|
return /^[A-Za-z_][A-Za-z0-9_.:-]*$/.test(key);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function textStyleTargetsForSelection(el) {
|
||||||
|
if (!el) return [];
|
||||||
|
var tag = localName(el);
|
||||||
|
if (tag === "tspan") return el.id ? [el] : [];
|
||||||
|
if (tag === "text") return textStyleLeavesForText(el);
|
||||||
|
|
||||||
|
var targets = [];
|
||||||
|
el.querySelectorAll("text").forEach(function (textEl) {
|
||||||
|
targets = targets.concat(textStyleLeavesForText(textEl));
|
||||||
|
});
|
||||||
|
return uniqueElements(targets);
|
||||||
|
}
|
||||||
|
|
||||||
|
function textStyleLeavesForText(textEl) {
|
||||||
|
var tspans = Array.from(textEl.querySelectorAll("tspan")).filter(function (tspan) {
|
||||||
|
return !!tspan.id;
|
||||||
|
});
|
||||||
|
if (tspans.length > 0) return tspans;
|
||||||
|
return textEl.id ? [textEl] : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function uniqueElements(elements) {
|
||||||
|
var seen = new Set();
|
||||||
|
return elements.filter(function (el) {
|
||||||
|
if (!el || !el.id || seen.has(el.id)) return false;
|
||||||
|
seen.add(el.id);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function textStyleSpecs(targets) {
|
||||||
|
return [
|
||||||
|
{ key: "fill", type: "color" },
|
||||||
|
{ key: "font-size", type: "text" },
|
||||||
|
{ key: "font-family", type: "text" },
|
||||||
|
{ key: "font-weight", type: "select",
|
||||||
|
options: ["", "normal", "bold", "300", "400", "500", "600", "700", "800"] },
|
||||||
|
{ key: "text-anchor", type: "select", options: ["", "start", "middle", "end"] }
|
||||||
|
].map(function (spec) {
|
||||||
|
var value = commonAttrValue(targets, spec.key);
|
||||||
|
var copy = Object.assign({}, spec);
|
||||||
|
copy.value = value === null ? "" : value;
|
||||||
|
copy.mixed = value === null;
|
||||||
|
return copy;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTextStyleControl(spec, targets) {
|
||||||
|
var targetIds = encodeURIComponent(JSON.stringify(targets.map(function (el) { return el.id; })));
|
||||||
|
var placeholder = spec.mixed ? t("multi_mixed") : "";
|
||||||
|
if (spec.type === "color") {
|
||||||
|
return '<input type="color" class="prop-edit prop-edit-text-style-color" data-key="' +
|
||||||
|
escapeHtml(spec.key) + '" data-target-ids="' + targetIds +
|
||||||
|
'" value="' + normalizeHexForPicker(spec.value) + '">' +
|
||||||
|
'<input type="text" class="prop-edit prop-edit-text-style" data-key="' +
|
||||||
|
escapeHtml(spec.key) + '" data-target-ids="' + targetIds +
|
||||||
|
'" placeholder="' + escapeHtml(placeholder) +
|
||||||
|
'" value="' + escapeHtml(spec.mixed ? "" : spec.value) + '">';
|
||||||
|
}
|
||||||
|
if (spec.type === "select") {
|
||||||
|
var html = '<select class="prop-edit prop-edit-text-style" data-key="' +
|
||||||
|
escapeHtml(spec.key) + '" data-target-ids="' + targetIds + '">';
|
||||||
|
var hasValue = spec.options.indexOf(spec.value) !== -1;
|
||||||
|
if (!spec.mixed && spec.value && !hasValue) {
|
||||||
|
html += '<option value="' + escapeHtml(spec.value) + '" selected>' +
|
||||||
|
escapeHtml(spec.value) + '</option>';
|
||||||
|
}
|
||||||
|
spec.options.forEach(function (opt) {
|
||||||
|
var label = opt || placeholder || "";
|
||||||
|
var selected = !spec.mixed && spec.value === opt ? " selected" : "";
|
||||||
|
html += '<option value="' + escapeHtml(opt) + '"' + selected + '>' +
|
||||||
|
escapeHtml(label) + '</option>';
|
||||||
|
});
|
||||||
|
html += '</select>';
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
return '<input type="text" class="prop-edit prop-edit-text-style" data-key="' +
|
||||||
|
escapeHtml(spec.key) + '" data-target-ids="' + targetIds +
|
||||||
|
'" placeholder="' + escapeHtml(placeholder) +
|
||||||
|
'" value="' + escapeHtml(spec.mixed ? "" : spec.value) + '">';
|
||||||
|
}
|
||||||
|
|
||||||
function isColorAttribute(key, value) {
|
function isColorAttribute(key, value) {
|
||||||
var k = String(key).toLowerCase();
|
var k = String(key).toLowerCase();
|
||||||
if (["fill", "stroke", "color", "stop-color", "flood-color", "lighting-color"].indexOf(k) !== -1) {
|
if (["fill", "stroke", "color", "stop-color", "flood-color", "lighting-color"].indexOf(k) !== -1) {
|
||||||
@@ -1798,6 +2052,27 @@
|
|||||||
return "#000000";
|
return "#000000";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var SVG_UNIT_OPTIONAL_LENGTH_ATTRS = new Set([
|
||||||
|
"x", "y", "x1", "x2", "y1", "y2",
|
||||||
|
"cx", "cy", "r", "rx", "ry",
|
||||||
|
"dx", "dy", "width", "height",
|
||||||
|
"font-size", "stroke-width", "stroke-dashoffset",
|
||||||
|
"letter-spacing", "word-spacing"
|
||||||
|
]);
|
||||||
|
|
||||||
|
function normalizeSvgLengthValue(key, value) {
|
||||||
|
var s = String(value || "");
|
||||||
|
if (!SVG_UNIT_OPTIONAL_LENGTH_ATTRS.has(String(key).toLowerCase())) return s;
|
||||||
|
s = s.trim();
|
||||||
|
var m = s.match(/^(-?\d+(?:\.\d+)?)px$/i);
|
||||||
|
return m ? m[1] : s;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeEditableAttrValue(key, value) {
|
||||||
|
var s = String(value || "").trim();
|
||||||
|
return normalizeSvgLengthValue(key, s);
|
||||||
|
}
|
||||||
|
|
||||||
function isSafeColor(val) {
|
function isSafeColor(val) {
|
||||||
// Only allow values that look like CSS colors (hex, rgb, rgba, hsl, named).
|
// Only allow values that look like CSS colors (hex, rgb, rgba, hsl, named).
|
||||||
// Reject anything with ; : url @ \ to prevent CSS injection.
|
// Reject anything with ; : url @ \ to prevent CSS injection.
|
||||||
@@ -2132,10 +2407,30 @@
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
panel.querySelectorAll(".prop-edit-text-style-color").forEach(function (picker) {
|
||||||
|
var key = picker.getAttribute("data-key");
|
||||||
|
var textInput = panel.querySelector('.prop-edit-text-style[data-key="' + key + '"]');
|
||||||
|
picker.addEventListener("input", function () {
|
||||||
|
if (textInput) textInput.value = picker.value;
|
||||||
|
});
|
||||||
|
picker.addEventListener("change", function () {
|
||||||
|
if (textInput) textInput.value = picker.value;
|
||||||
|
stageTextStyleAttr(key, picker.value, picker.getAttribute("data-target-ids"));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
panel.querySelectorAll(".prop-edit-text-style").forEach(function (input) {
|
||||||
|
input.addEventListener("change", function () {
|
||||||
|
var key = input.getAttribute("data-key");
|
||||||
|
var value = normalizeEditableAttrValue(key, input.value);
|
||||||
|
if (!value) return;
|
||||||
|
stageTextStyleAttr(key, value, input.getAttribute("data-target-ids"));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
panel.querySelectorAll(".prop-edit-attr, .prop-edit-attr-area").forEach(function (input) {
|
panel.querySelectorAll(".prop-edit-attr, .prop-edit-attr-area").forEach(function (input) {
|
||||||
var key = input.getAttribute("data-key");
|
var key = input.getAttribute("data-key");
|
||||||
input.addEventListener("change", function () {
|
input.addEventListener("change", function () {
|
||||||
stageAttr(el, eid, key, input.value.trim());
|
stageAttr(el, eid, key, normalizeEditableAttrValue(key, input.value));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -2195,7 +2490,39 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function stageTextStyleAttr(key, value, encodedTargetIds) {
|
||||||
|
value = normalizeEditableAttrValue(key, value);
|
||||||
|
if ((key === "fill" || key === "stroke") && !isSafeColor(value)) {
|
||||||
|
showError(t("err_edit") + key);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var ids;
|
||||||
|
try {
|
||||||
|
ids = JSON.parse(decodeURIComponent(encodedTargetIds || "%5B%5D"));
|
||||||
|
} catch (e) {
|
||||||
|
ids = [];
|
||||||
|
}
|
||||||
|
var targets = ids.map(function (id) {
|
||||||
|
return svgContent.querySelector("#" + CSS.escape(id));
|
||||||
|
}).filter(function (target) {
|
||||||
|
return target && attrOrComputedValue(target, key) !== value;
|
||||||
|
});
|
||||||
|
if (targets.length === 0) return;
|
||||||
|
|
||||||
|
var attrs = {};
|
||||||
|
attrs[key] = value;
|
||||||
|
var jobs = targets.map(function (target) {
|
||||||
|
return stageEditRequest(target.id, { attrs: attrs }).then(function () {
|
||||||
|
target.setAttribute(key, value);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
Promise.all(jobs)
|
||||||
|
.then(function () { updateSelectionPanel(); })
|
||||||
|
.catch(function (err) { showError(t("err_edit") + err.message); });
|
||||||
|
}
|
||||||
|
|
||||||
function stageAttr(el, eid, key, value) {
|
function stageAttr(el, eid, key, value) {
|
||||||
|
value = normalizeEditableAttrValue(key, value);
|
||||||
if ((key === "fill" || key === "stroke") && !isSafeColor(value)) {
|
if ((key === "fill" || key === "stroke") && !isSafeColor(value)) {
|
||||||
showError(t("err_edit") + key);
|
showError(t("err_edit") + key);
|
||||||
return;
|
return;
|
||||||
@@ -2238,6 +2565,10 @@
|
|||||||
if (data && data.undo_depth !== undefined) {
|
if (data && data.undo_depth !== undefined) {
|
||||||
editStackCount[slide] = data.undo_depth;
|
editStackCount[slide] = data.undo_depth;
|
||||||
if (slide === currentSlide) updateUndoButton();
|
if (slide === currentSlide) updateUndoButton();
|
||||||
|
updatePendingStatus();
|
||||||
|
}
|
||||||
|
if (payloadHasMatrixTransform(payload)) {
|
||||||
|
markMatrixTransformEdit(slide);
|
||||||
}
|
}
|
||||||
maybeShowSavedHint();
|
maybeShowSavedHint();
|
||||||
return data;
|
return data;
|
||||||
@@ -2316,7 +2647,7 @@
|
|||||||
|
|
||||||
function attrOrComputedValue(el, key) {
|
function attrOrComputedValue(el, key) {
|
||||||
var value = el.getAttribute(key);
|
var value = el.getAttribute(key);
|
||||||
if (value !== null && value !== "") return value;
|
if (value !== null && value !== "") return normalizeSvgLengthValue(key, value);
|
||||||
var style = window.getComputedStyle(el);
|
var style = window.getComputedStyle(el);
|
||||||
var map = {
|
var map = {
|
||||||
"fill": "fill",
|
"fill": "fill",
|
||||||
@@ -2327,7 +2658,7 @@
|
|||||||
"font-weight": "fontWeight",
|
"font-weight": "fontWeight",
|
||||||
"text-anchor": "textAnchor"
|
"text-anchor": "textAnchor"
|
||||||
};
|
};
|
||||||
return (map[key] && style[map[key]]) || "";
|
return normalizeSvgLengthValue(key, (map[key] && style[map[key]]) || "");
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderBatchControl(spec) {
|
function renderBatchControl(spec) {
|
||||||
@@ -2418,7 +2749,7 @@
|
|||||||
panel.querySelectorAll(".prop-edit-batch").forEach(function (input) {
|
panel.querySelectorAll(".prop-edit-batch").forEach(function (input) {
|
||||||
input.addEventListener("change", function () {
|
input.addEventListener("change", function () {
|
||||||
var key = input.getAttribute("data-key");
|
var key = input.getAttribute("data-key");
|
||||||
var value = input.value.trim();
|
var value = normalizeEditableAttrValue(key, input.value);
|
||||||
if (!value) return;
|
if (!value) return;
|
||||||
commitBatchAttr(key, value, moveEls);
|
commitBatchAttr(key, value, moveEls);
|
||||||
});
|
});
|
||||||
@@ -2426,6 +2757,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function commitBatchAttr(key, value, elements) {
|
function commitBatchAttr(key, value, elements) {
|
||||||
|
value = normalizeEditableAttrValue(key, value);
|
||||||
if ((key === "fill" || key === "stroke") && !isSafeColor(value)) {
|
if ((key === "fill" || key === "stroke") && !isSafeColor(value)) {
|
||||||
showError(t("err_edit") + key);
|
showError(t("err_edit") + key);
|
||||||
return;
|
return;
|
||||||
@@ -2446,6 +2778,8 @@
|
|||||||
// Boot
|
// Boot
|
||||||
// ================================================================
|
// ================================================================
|
||||||
applyI18n();
|
applyI18n();
|
||||||
|
initAnnotationQuickActions();
|
||||||
|
updatePendingStatus();
|
||||||
var langToggleBtn = document.getElementById("btn-lang-toggle");
|
var langToggleBtn = document.getElementById("btn-lang-toggle");
|
||||||
if (langToggleBtn) {
|
if (langToggleBtn) {
|
||||||
langToggleBtn.textContent = LANG === "zh" ? "EN" : "中";
|
langToggleBtn.textContent = LANG === "zh" ? "EN" : "中";
|
||||||
|
|||||||
+3
-1
@@ -31,7 +31,8 @@
|
|||||||
</div>
|
</div>
|
||||||
<div id="panel-right">
|
<div id="panel-right">
|
||||||
<button id="btn-lang-toggle" class="btn-lang-toggle" title="Switch language">中</button>
|
<button id="btn-lang-toggle" class="btn-lang-toggle" title="Switch language">中</button>
|
||||||
<div class="panel-header" data-i18n="panel_annotations">Annotations</div>
|
<div class="panel-header" data-i18n="panel_edit_annotate">Edit / Annotate</div>
|
||||||
|
<div id="pending-status" class="pending-status"></div>
|
||||||
<div id="selection-info">
|
<div id="selection-info">
|
||||||
<div class="section-label" data-i18n="label_selected_element">Selected element</div>
|
<div class="section-label" data-i18n="label_selected_element">Selected element</div>
|
||||||
<div id="selected-element" class="empty" data-i18n="empty_selected_element">Click an element on the slide to select it</div>
|
<div id="selected-element" class="empty" data-i18n="empty_selected_element">Click an element on the slide to select it</div>
|
||||||
@@ -39,6 +40,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div id="annotation-input" style="display:none;">
|
<div id="annotation-input" style="display:none;">
|
||||||
<div class="section-label" data-i18n="label_edit_instruction">Edit instruction</div>
|
<div class="section-label" data-i18n="label_edit_instruction">Edit instruction</div>
|
||||||
|
<div id="annotation-quick-actions"></div>
|
||||||
<textarea id="annotation-text" data-i18n-placeholder="placeholder_annotation" placeholder="Describe how the AI should modify this element..."></textarea>
|
<textarea id="annotation-text" data-i18n-placeholder="placeholder_annotation" placeholder="Describe how the AI should modify this element..."></textarea>
|
||||||
<button id="btn-add-annotation" data-i18n="btn_add_annotation">Add annotation</button>
|
<button id="btn-add-annotation" data-i18n="btn_add_annotation">Add annotation</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+51
@@ -330,6 +330,33 @@ body.svg-dragging * {
|
|||||||
color: #a0a0c0;
|
color: #a0a0c0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* --- Pending status --- */
|
||||||
|
.pending-status {
|
||||||
|
margin: 0 16px 10px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border: 1px solid #2a2a4a;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pending-status.empty {
|
||||||
|
color: #7a7a9a;
|
||||||
|
background: #18182a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pending-status.active {
|
||||||
|
color: #f8d46b;
|
||||||
|
background: rgba(245, 197, 66, 0.08);
|
||||||
|
border-color: rgba(245, 197, 66, 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pending-pages {
|
||||||
|
margin-top: 3px;
|
||||||
|
color: #b8a866;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
/* --- Annotation input --- */
|
/* --- Annotation input --- */
|
||||||
#annotation-input {
|
#annotation-input {
|
||||||
padding: 12px 16px;
|
padding: 12px 16px;
|
||||||
@@ -337,6 +364,30 @@ body.svg-dragging * {
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#annotation-quick-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.annotation-quick-chip {
|
||||||
|
padding: 4px 8px;
|
||||||
|
font-size: 11.5px;
|
||||||
|
color: #c0c0d8;
|
||||||
|
background: #1e1e36;
|
||||||
|
border: 1px solid #3a3a5a;
|
||||||
|
border-radius: 999px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s, border-color 0.15s, color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.annotation-quick-chip:hover {
|
||||||
|
color: #ffffff;
|
||||||
|
background: #2a2a4a;
|
||||||
|
border-color: #4a9eff;
|
||||||
|
}
|
||||||
|
|
||||||
#annotation-text {
|
#annotation-text {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-height: 80px;
|
min-height: 80px;
|
||||||
|
|||||||
+74
-9
@@ -163,6 +163,43 @@ def _normalize_for_save(img: 'PILImage', mime_type: str) -> 'PILImage':
|
|||||||
return img
|
return img
|
||||||
|
|
||||||
|
|
||||||
|
def _has_alpha(img: 'PILImage') -> bool:
|
||||||
|
"""Return whether a PIL image has transparency."""
|
||||||
|
if img.mode in ('RGBA', 'LA'):
|
||||||
|
return True
|
||||||
|
if img.mode == 'P':
|
||||||
|
return 'transparency' in getattr(img, 'info', {})
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _target_size(
|
||||||
|
box_w: float,
|
||||||
|
box_h: float,
|
||||||
|
*,
|
||||||
|
max_dimension: int | None,
|
||||||
|
image_scale: float,
|
||||||
|
) -> tuple[int, int]:
|
||||||
|
"""Resolve the pixel budget for a rendered SVG image box."""
|
||||||
|
target_w = max(1, int(round(box_w * max(image_scale, 1.0))))
|
||||||
|
target_h = max(1, int(round(box_h * max(image_scale, 1.0))))
|
||||||
|
if max_dimension and max(target_w, target_h) > max_dimension:
|
||||||
|
ratio = max_dimension / max(target_w, target_h)
|
||||||
|
target_w = max(1, int(round(target_w * ratio)))
|
||||||
|
target_h = max(1, int(round(target_h * ratio)))
|
||||||
|
return target_w, target_h
|
||||||
|
|
||||||
|
|
||||||
|
def _downscale_to_target(img: 'PILImage', target_w: int, target_h: int) -> tuple['PILImage', bool]:
|
||||||
|
"""Downscale without upsampling."""
|
||||||
|
width, height = img.size
|
||||||
|
ratio = min(target_w / width, target_h / height, 1.0)
|
||||||
|
if ratio >= 1.0:
|
||||||
|
return img, False
|
||||||
|
from PIL import Image
|
||||||
|
new_size = (max(1, int(round(width * ratio))), max(1, int(round(height * ratio))))
|
||||||
|
return img.resize(new_size, Image.Resampling.LANCZOS), True
|
||||||
|
|
||||||
|
|
||||||
def _encode_pil_to_data_uri(
|
def _encode_pil_to_data_uri(
|
||||||
img: 'PILImage',
|
img: 'PILImage',
|
||||||
src_path: Path,
|
src_path: Path,
|
||||||
@@ -178,7 +215,10 @@ def _encode_pil_to_data_uri(
|
|||||||
already-optimized asset. *fallback_bytes* carries the raw on-disk
|
already-optimized asset. *fallback_bytes* carries the raw on-disk
|
||||||
bytes for that path.
|
bytes for that path.
|
||||||
"""
|
"""
|
||||||
mime_type = get_mime_type(src_path.name, fallback_bytes)
|
original_mime_type = get_mime_type(src_path.name, fallback_bytes)
|
||||||
|
mime_type = original_mime_type
|
||||||
|
if compress and mime_type == 'image/png' and not _has_alpha(img):
|
||||||
|
mime_type = 'image/jpeg'
|
||||||
pil_format = _PIL_FORMAT_BY_MIME.get(mime_type, 'PNG')
|
pil_format = _PIL_FORMAT_BY_MIME.get(mime_type, 'PNG')
|
||||||
|
|
||||||
# Encode current PIL image
|
# Encode current PIL image
|
||||||
@@ -200,7 +240,7 @@ def _encode_pil_to_data_uri(
|
|||||||
# round-tripping an asset that was already well-compressed inflates it),
|
# round-tripping an asset that was already well-compressed inflates it),
|
||||||
# fall back to those.
|
# fall back to those.
|
||||||
chosen = encoded_bytes
|
chosen = encoded_bytes
|
||||||
if fallback_bytes and len(fallback_bytes) < len(encoded_bytes):
|
if fallback_bytes and mime_type == original_mime_type and len(fallback_bytes) < len(encoded_bytes):
|
||||||
chosen = fallback_bytes
|
chosen = fallback_bytes
|
||||||
|
|
||||||
chosen = _optimize_image_bytes(
|
chosen = _optimize_image_bytes(
|
||||||
@@ -239,6 +279,7 @@ def _process_one_image(
|
|||||||
*,
|
*,
|
||||||
compress: bool,
|
compress: bool,
|
||||||
max_dimension: int | None,
|
max_dimension: int | None,
|
||||||
|
image_scale: float,
|
||||||
verbose: bool,
|
verbose: bool,
|
||||||
) -> tuple[bool, str | None]:
|
) -> tuple[bool, str | None]:
|
||||||
"""Align (slice/meet) and embed a single <image>.
|
"""Align (slice/meet) and embed a single <image>.
|
||||||
@@ -288,6 +329,7 @@ def _process_one_image(
|
|||||||
final_img: 'PILImage' = img
|
final_img: 'PILImage' = img
|
||||||
new_x, new_y, new_w, new_h = box_x, box_y, box_w, box_h
|
new_x, new_y, new_w, new_h = box_x, box_y, box_w, box_h
|
||||||
transformed = False # True iff bitmap content changed (crop happened)
|
transformed = False # True iff bitmap content changed (crop happened)
|
||||||
|
target_box_w, target_box_h = box_w, box_h
|
||||||
|
|
||||||
if not par_attr:
|
if not par_attr:
|
||||||
# No preserveAspectRatio at all. The previous pipeline's fix-aspect
|
# No preserveAspectRatio at all. The previous pipeline's fix-aspect
|
||||||
@@ -314,6 +356,16 @@ def _process_one_image(
|
|||||||
new_y = box_y + off_y
|
new_y = box_y + off_y
|
||||||
new_w = new_w_calc
|
new_w = new_w_calc
|
||||||
new_h = new_h_calc
|
new_h = new_h_calc
|
||||||
|
target_box_w, target_box_h = new_w, new_h
|
||||||
|
|
||||||
|
target_w, target_h = _target_size(
|
||||||
|
target_box_w,
|
||||||
|
target_box_h,
|
||||||
|
max_dimension=max_dimension,
|
||||||
|
image_scale=image_scale,
|
||||||
|
)
|
||||||
|
final_img, resized = _downscale_to_target(final_img, target_w, target_h)
|
||||||
|
transformed = transformed or resized
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Encode and rewrite
|
# Encode and rewrite
|
||||||
@@ -372,8 +424,9 @@ def align_and_embed_images_in_svg(
|
|||||||
*,
|
*,
|
||||||
dry_run: bool = False,
|
dry_run: bool = False,
|
||||||
verbose: bool = False,
|
verbose: bool = False,
|
||||||
compress: bool = False,
|
compress: bool = True,
|
||||||
max_dimension: int | None = None,
|
max_dimension: int | None = 2560,
|
||||||
|
image_scale: float = 2.0,
|
||||||
) -> tuple[int, int]:
|
) -> tuple[int, int]:
|
||||||
"""Run the merged align + embed pass on a single SVG file.
|
"""Run the merged align + embed pass on a single SVG file.
|
||||||
|
|
||||||
@@ -412,7 +465,8 @@ def align_and_embed_images_in_svg(
|
|||||||
|
|
||||||
ok, err = _process_one_image(
|
ok, err = _process_one_image(
|
||||||
image, svg_dir,
|
image, svg_dir,
|
||||||
compress=compress, max_dimension=max_dimension, verbose=verbose,
|
compress=compress, max_dimension=max_dimension,
|
||||||
|
image_scale=image_scale, verbose=verbose,
|
||||||
)
|
)
|
||||||
if ok:
|
if ok:
|
||||||
processed += 1
|
processed += 1
|
||||||
@@ -440,10 +494,14 @@ def build_parser() -> argparse.ArgumentParser:
|
|||||||
parser.add_argument('svg', type=Path, help='SVG file to process in place')
|
parser.add_argument('svg', type=Path, help='SVG file to process in place')
|
||||||
parser.add_argument('-n', '--dry-run', action='store_true')
|
parser.add_argument('-n', '--dry-run', action='store_true')
|
||||||
parser.add_argument('-v', '--verbose', action='store_true')
|
parser.add_argument('-v', '--verbose', action='store_true')
|
||||||
parser.add_argument('--compress', action='store_true',
|
parser.add_argument('--compress', dest='compress', action='store_true', default=True,
|
||||||
help='Compress images before embedding')
|
help='Compress images before embedding (default)')
|
||||||
parser.add_argument('--max-dimension', type=int, default=None,
|
parser.add_argument('--no-compress', dest='compress', action='store_false',
|
||||||
help='Downscale images larger than this on either axis')
|
help='Disable image compression')
|
||||||
|
parser.add_argument('--max-dimension', type=int, default=2560,
|
||||||
|
help='Downscale images larger than this on either axis (default: 2560)')
|
||||||
|
parser.add_argument('--image-scale', type=float, default=2.0,
|
||||||
|
help='Target pixels per rendered SVG pixel')
|
||||||
return parser
|
return parser
|
||||||
|
|
||||||
|
|
||||||
@@ -455,6 +513,12 @@ def main(argv: list[str] | None = None) -> int:
|
|||||||
if not args.svg.exists():
|
if not args.svg.exists():
|
||||||
print(f'Error: file not found: {args.svg}', file=sys.stderr)
|
print(f'Error: file not found: {args.svg}', file=sys.stderr)
|
||||||
return 1
|
return 1
|
||||||
|
if args.max_dimension < 1:
|
||||||
|
print('Error: --max-dimension must be >= 1', file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
if args.image_scale < 1:
|
||||||
|
print('Error: --image-scale must be >= 1', file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
proc, err = align_and_embed_images_in_svg(
|
proc, err = align_and_embed_images_in_svg(
|
||||||
args.svg,
|
args.svg,
|
||||||
@@ -462,6 +526,7 @@ def main(argv: list[str] | None = None) -> int:
|
|||||||
verbose=args.verbose,
|
verbose=args.verbose,
|
||||||
compress=args.compress,
|
compress=args.compress,
|
||||||
max_dimension=args.max_dimension,
|
max_dimension=args.max_dimension,
|
||||||
|
image_scale=args.image_scale,
|
||||||
)
|
)
|
||||||
print(f'Processed {proc} image(s), {err} error(s)')
|
print(f'Processed {proc} image(s), {err} error(s)')
|
||||||
return 1 if err else 0
|
return 1 if err else 0
|
||||||
|
|||||||
+420
-12
@@ -221,6 +221,8 @@ class SVGQualityChecker:
|
|||||||
# severity is 'error' or 'warning'. Printed in print_summary.
|
# severity is 'error' or 'warning'. Printed in print_summary.
|
||||||
self._template_issues: List[Tuple[str, str, str]] = []
|
self._template_issues: List[Tuple[str, str, str]] = []
|
||||||
self._animation_issues: List[Tuple[str, str]] = []
|
self._animation_issues: List[Tuple[str, str]] = []
|
||||||
|
self._illustration_issues: List[Tuple[str, str, str]] = []
|
||||||
|
self._aggregate_counts_applied = False
|
||||||
|
|
||||||
def check_file(self, svg_file: str, expected_format: str = None) -> Dict:
|
def check_file(self, svg_file: str, expected_format: str = None) -> Dict:
|
||||||
"""
|
"""
|
||||||
@@ -1167,9 +1169,376 @@ class SVGQualityChecker:
|
|||||||
self._check_template_contract(dir_path, svg_files)
|
self._check_template_contract(dir_path, svg_files)
|
||||||
elif dir_path.is_dir():
|
elif dir_path.is_dir():
|
||||||
self._check_animation_config_contract(dir_path)
|
self._check_animation_config_contract(dir_path)
|
||||||
|
self._check_illustration_resource_contract(dir_path)
|
||||||
|
|
||||||
return self.results
|
return self.results
|
||||||
|
|
||||||
|
def _check_illustration_resource_contract(self, dir_path: Path) -> None:
|
||||||
|
"""Project-level illustration resource checks."""
|
||||||
|
project_path = self._resolve_project_path(dir_path)
|
||||||
|
spec_path = project_path / 'design_spec.md'
|
||||||
|
if not spec_path.exists():
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
spec_text = spec_path.read_text(encoding='utf-8')
|
||||||
|
except OSError as exc:
|
||||||
|
self._illustration_issues.append((
|
||||||
|
'warning',
|
||||||
|
'spec_unreadable',
|
||||||
|
f"could not read {spec_path}: {exc}",
|
||||||
|
))
|
||||||
|
return
|
||||||
|
|
||||||
|
rows = self._extract_image_resource_rows(spec_text)
|
||||||
|
if not rows:
|
||||||
|
return
|
||||||
|
|
||||||
|
lock_images = self._load_project_lock_images(project_path)
|
||||||
|
svg_texts = self._load_project_svg_texts(project_path)
|
||||||
|
all_svg_text = "\n".join(svg_texts.values())
|
||||||
|
|
||||||
|
sheet_rows = [row for row in rows if self._row_type(row).lower() == 'illustration sheet']
|
||||||
|
slice_rows = [row for row in rows if self._row_acquire(row) == 'slice']
|
||||||
|
image_rows = [
|
||||||
|
row for row in rows
|
||||||
|
if self._row_acquire(row) in {'ai', 'web', 'user', 'placeholder', 'slice'}
|
||||||
|
and self._row_type(row).lower() not in {'latex formula', 'illustration sheet'}
|
||||||
|
]
|
||||||
|
|
||||||
|
for row in sheet_rows:
|
||||||
|
filename = self._row_filename(row)
|
||||||
|
if not filename:
|
||||||
|
continue
|
||||||
|
if filename in lock_images:
|
||||||
|
self._illustration_issues.append((
|
||||||
|
'error',
|
||||||
|
'sheet_in_lock',
|
||||||
|
f"{filename} is an Illustration Sheet but is listed in spec_lock.md images; "
|
||||||
|
"only sliced element rows may be listed.",
|
||||||
|
))
|
||||||
|
if filename in all_svg_text:
|
||||||
|
self._illustration_issues.append((
|
||||||
|
'error',
|
||||||
|
'sheet_referenced',
|
||||||
|
f"{filename} is an Illustration Sheet but is referenced by an SVG; "
|
||||||
|
"generate it only as a slice source, never place it.",
|
||||||
|
))
|
||||||
|
|
||||||
|
for row in slice_rows:
|
||||||
|
filename = self._row_filename(row)
|
||||||
|
if not filename:
|
||||||
|
continue
|
||||||
|
if filename not in lock_images:
|
||||||
|
self._illustration_issues.append((
|
||||||
|
'error',
|
||||||
|
'slice_missing_lock',
|
||||||
|
f"{filename} is a slice row but is absent from spec_lock.md images.",
|
||||||
|
))
|
||||||
|
if (
|
||||||
|
self._row_status(row) == 'generated'
|
||||||
|
and not (project_path / 'images' / filename).exists()
|
||||||
|
):
|
||||||
|
self._illustration_issues.append((
|
||||||
|
'error',
|
||||||
|
'slice_file_missing',
|
||||||
|
f"{filename} is a Generated slice row but images/{filename} does not exist.",
|
||||||
|
))
|
||||||
|
|
||||||
|
has_coverage_note = 'Image-as-canvas' in spec_text or 'image-as-canvas' in spec_text
|
||||||
|
pattern_ids = self._collect_layout_pattern_ids(image_rows)
|
||||||
|
if len(image_rows) >= 4 and not any(38 <= pid <= 46 for pid in pattern_ids):
|
||||||
|
if not has_coverage_note:
|
||||||
|
self._illustration_issues.append((
|
||||||
|
'warning',
|
||||||
|
'missing_image_as_canvas',
|
||||||
|
"deck has 4+ image-bearing rows but no #38-#46 image-as-canvas "
|
||||||
|
"layout and no coverage note in design_spec.md §VIII.",
|
||||||
|
))
|
||||||
|
|
||||||
|
conventional_ids = {1, 2, 3, 5, 6}
|
||||||
|
if len(image_rows) >= 4 and pattern_ids and pattern_ids.issubset(conventional_ids):
|
||||||
|
if not has_coverage_note:
|
||||||
|
self._illustration_issues.append((
|
||||||
|
'warning',
|
||||||
|
'layout_pattern_degenerated',
|
||||||
|
"all image-bearing rows use only basic full-bleed / left-right / "
|
||||||
|
"top-bottom patterns (#1/#2/#3/#5/#6); re-check "
|
||||||
|
"references/image-layout-patterns.md for modifiers or image-as-canvas options.",
|
||||||
|
))
|
||||||
|
|
||||||
|
for row in image_rows:
|
||||||
|
self._check_decorative_image_row(row, project_path, svg_texts)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _resolve_project_path(dir_path: Path) -> Path:
|
||||||
|
"""Resolve a checker target directory to its project root."""
|
||||||
|
if dir_path.name in {'svg_output', 'svg_final'}:
|
||||||
|
return dir_path.parent
|
||||||
|
if (dir_path / 'svg_output').exists() or (dir_path / 'design_spec.md').exists():
|
||||||
|
return dir_path
|
||||||
|
return dir_path.parent
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _split_md_table_row(line: str) -> List[str]:
|
||||||
|
"""Split a simple Markdown table row into stripped cells."""
|
||||||
|
return [cell.strip().strip('`') for cell in line.strip().strip('|').split('|')]
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _extract_image_resource_rows(cls, spec_text: str) -> List[Dict[str, str]]:
|
||||||
|
"""Extract rows from design_spec.md §VIII Image Resource List."""
|
||||||
|
section_match = re.search(
|
||||||
|
r"^##\s+VIII\.\s+Image Resource List\b.*?(?=^##\s+|\Z)",
|
||||||
|
spec_text,
|
||||||
|
re.MULTILINE | re.DOTALL,
|
||||||
|
)
|
||||||
|
if not section_match:
|
||||||
|
return []
|
||||||
|
|
||||||
|
lines = section_match.group(0).splitlines()
|
||||||
|
header = None
|
||||||
|
rows: List[Dict[str, str]] = []
|
||||||
|
in_resource_table = False
|
||||||
|
for line in lines:
|
||||||
|
if not line.strip().startswith('|'):
|
||||||
|
if in_resource_table and rows:
|
||||||
|
break
|
||||||
|
continue
|
||||||
|
|
||||||
|
cells = cls._split_md_table_row(line)
|
||||||
|
if not cells:
|
||||||
|
continue
|
||||||
|
if header is None:
|
||||||
|
if any(cell.lower() == 'filename' for cell in cells):
|
||||||
|
header = cells
|
||||||
|
in_resource_table = True
|
||||||
|
continue
|
||||||
|
if set(cell.replace('-', '').strip() for cell in cells) == {''}:
|
||||||
|
continue
|
||||||
|
if not in_resource_table:
|
||||||
|
continue
|
||||||
|
row = {header[i]: cells[i] if i < len(cells) else '' for i in range(len(header))}
|
||||||
|
filename = row.get('Filename', '').strip()
|
||||||
|
if filename and filename.lower() != 'filename':
|
||||||
|
rows.append(row)
|
||||||
|
|
||||||
|
return rows
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _row_filename(row: Dict[str, str]) -> str:
|
||||||
|
return Path(row.get('Filename', '').strip()).name
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _row_type(row: Dict[str, str]) -> str:
|
||||||
|
return row.get('Type', '').strip()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _row_acquire(row: Dict[str, str]) -> str:
|
||||||
|
return row.get('Acquire Via', '').strip().lower()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _row_status(row: Dict[str, str]) -> str:
|
||||||
|
return row.get('Status', '').strip().lower()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _row_layout(row: Dict[str, str]) -> str:
|
||||||
|
return row.get('Layout pattern', '').strip()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _collect_layout_pattern_ids(rows: List[Dict[str, str]]) -> set[int]:
|
||||||
|
ids: set[int] = set()
|
||||||
|
for row in rows:
|
||||||
|
for match in re.finditer(r'#(\d+)\b', SVGQualityChecker._row_layout(row)):
|
||||||
|
ids.add(int(match.group(1)))
|
||||||
|
return ids
|
||||||
|
|
||||||
|
def _load_project_lock_images(self, project_path: Path) -> set[str]:
|
||||||
|
"""Return filenames listed under spec_lock.md images."""
|
||||||
|
lock_path = project_path / 'spec_lock.md'
|
||||||
|
if _parse_spec_lock is None or not lock_path.exists():
|
||||||
|
return set()
|
||||||
|
try:
|
||||||
|
lock = _parse_spec_lock(lock_path)
|
||||||
|
except Exception:
|
||||||
|
return set()
|
||||||
|
images = set()
|
||||||
|
for value in lock.get('images', {}).values():
|
||||||
|
path_part = value.split('|', 1)[0].strip()
|
||||||
|
images.add(Path(path_part).name)
|
||||||
|
return images
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _load_project_svg_texts(project_path: Path) -> Dict[Path, str]:
|
||||||
|
"""Read project SVG output files for project-level cross-checks."""
|
||||||
|
svg_dir = project_path / 'svg_output'
|
||||||
|
if not svg_dir.exists():
|
||||||
|
return {}
|
||||||
|
out: Dict[Path, str] = {}
|
||||||
|
for svg_path in sorted(svg_dir.glob('*.svg')):
|
||||||
|
try:
|
||||||
|
out[svg_path] = svg_path.read_text(encoding='utf-8')
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
return out
|
||||||
|
|
||||||
|
def _check_decorative_image_row(
|
||||||
|
self,
|
||||||
|
row: Dict[str, str],
|
||||||
|
project_path: Path,
|
||||||
|
svg_texts: Dict[Path, str],
|
||||||
|
) -> None:
|
||||||
|
"""Warn when decorative image patterns lack obvious SVG/file evidence."""
|
||||||
|
filename = self._row_filename(row)
|
||||||
|
if not filename:
|
||||||
|
return
|
||||||
|
layout = self._row_layout(row)
|
||||||
|
ids = {int(match.group(1)) for match in re.finditer(r'#(\d+)\b', layout)}
|
||||||
|
decorative_ids = ids & {4, 58, 63, 66, 69}
|
||||||
|
if not decorative_ids:
|
||||||
|
return
|
||||||
|
if self._row_type(row).lower() == 'illustration sheet':
|
||||||
|
return
|
||||||
|
|
||||||
|
referenced_tags: List[Tuple[Path, str]] = []
|
||||||
|
for svg_path, content in svg_texts.items():
|
||||||
|
for tag in re.findall(r'<image\b[^>]*>', content, re.IGNORECASE):
|
||||||
|
if filename in tag:
|
||||||
|
referenced_tags.append((svg_path, tag))
|
||||||
|
|
||||||
|
if 63 in decorative_ids:
|
||||||
|
if Path(filename).suffix.lower() != '.png':
|
||||||
|
self._illustration_issues.append((
|
||||||
|
'warning',
|
||||||
|
'sticker_not_png',
|
||||||
|
f"{filename} uses #63 transparent sticker / cutout but is not a PNG.",
|
||||||
|
))
|
||||||
|
elif not self._png_has_alpha(project_path / 'images' / filename):
|
||||||
|
self._illustration_issues.append((
|
||||||
|
'warning',
|
||||||
|
'sticker_no_alpha',
|
||||||
|
f"{filename} uses #63 transparent sticker / cutout but the PNG "
|
||||||
|
"does not appear to have an alpha channel.",
|
||||||
|
))
|
||||||
|
|
||||||
|
if not referenced_tags:
|
||||||
|
return
|
||||||
|
|
||||||
|
if 69 in decorative_ids and not any('rotate(' in tag for _path, tag in referenced_tags):
|
||||||
|
self._illustration_issues.append((
|
||||||
|
'warning',
|
||||||
|
'rotation_missing',
|
||||||
|
f"{filename} declares #69 slight rotation but no referenced <image> "
|
||||||
|
"tag contains rotate(...).",
|
||||||
|
))
|
||||||
|
|
||||||
|
if 4 in decorative_ids and not self._has_off_canvas_reference(referenced_tags):
|
||||||
|
self._illustration_issues.append((
|
||||||
|
'warning',
|
||||||
|
'edge_bleed_missing',
|
||||||
|
f"{filename} declares #4 edge bleed but no referenced <image> appears "
|
||||||
|
"to extend past the canvas edge.",
|
||||||
|
))
|
||||||
|
|
||||||
|
if 58 in decorative_ids and not self._has_corner_fragment_reference(referenced_tags):
|
||||||
|
self._illustration_issues.append((
|
||||||
|
'warning',
|
||||||
|
'corner_fragment_missing',
|
||||||
|
f"{filename} declares #58 decorative corner fragment but no referenced "
|
||||||
|
"<image> appears near a canvas corner.",
|
||||||
|
))
|
||||||
|
|
||||||
|
if 66 in decorative_ids:
|
||||||
|
content_scope = "\n".join(svg_texts.get(path, '') for path, _tag in referenced_tags)
|
||||||
|
if '<linearGradient' not in content_scope and 'opacity' not in content_scope:
|
||||||
|
self._illustration_issues.append((
|
||||||
|
'warning',
|
||||||
|
'fade_missing',
|
||||||
|
f"{filename} declares #66 fade into background but the referencing "
|
||||||
|
"SVG has no obvious gradient or opacity treatment.",
|
||||||
|
))
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _png_has_alpha(path: Path) -> bool:
|
||||||
|
"""Return True when a PNG appears to carry transparent pixels."""
|
||||||
|
if not path.exists():
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
from PIL import Image as PILImage
|
||||||
|
with PILImage.open(path) as img:
|
||||||
|
if img.mode in {'RGBA', 'LA'}:
|
||||||
|
alpha = img.getchannel('A')
|
||||||
|
return alpha.getextrema()[0] < 255
|
||||||
|
return 'transparency' in img.info
|
||||||
|
except (ImportError, OSError, ValueError):
|
||||||
|
return False
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _parse_image_geometry(tag: str) -> Tuple[float, float, float, float] | None:
|
||||||
|
"""Extract x/y/width/height from an <image> tag."""
|
||||||
|
values = {}
|
||||||
|
for attr in ('x', 'y', 'width', 'height'):
|
||||||
|
match = re.search(rf'\b{attr}\s*=\s*["\']([^"\']+)["\']', tag)
|
||||||
|
if not match:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
values[attr] = float(match.group(1))
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
return values['x'], values['y'], values['width'], values['height']
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _parse_svg_viewbox(content: str) -> Tuple[float, float] | None:
|
||||||
|
"""Return viewBox width/height from SVG content."""
|
||||||
|
match = re.search(r'viewBox="[^"]*?\s+([0-9.]+)\s+([0-9.]+)"', content)
|
||||||
|
if not match:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return float(match.group(1)), float(match.group(2))
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _has_off_canvas_reference(cls, refs: List[Tuple[Path, str]]) -> bool:
|
||||||
|
for svg_path, tag in refs:
|
||||||
|
geometry = cls._parse_image_geometry(tag)
|
||||||
|
if geometry is None:
|
||||||
|
continue
|
||||||
|
x, y, width, height = geometry
|
||||||
|
try:
|
||||||
|
content = svg_path.read_text(encoding='utf-8')
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
viewbox = cls._parse_svg_viewbox(content)
|
||||||
|
if viewbox is None:
|
||||||
|
continue
|
||||||
|
vb_width, vb_height = viewbox
|
||||||
|
if x < 0 or y < 0 or x + width > vb_width or y + height > vb_height:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _has_corner_fragment_reference(cls, refs: List[Tuple[Path, str]]) -> bool:
|
||||||
|
for svg_path, tag in refs:
|
||||||
|
geometry = cls._parse_image_geometry(tag)
|
||||||
|
if geometry is None:
|
||||||
|
continue
|
||||||
|
x, y, width, height = geometry
|
||||||
|
try:
|
||||||
|
content = svg_path.read_text(encoding='utf-8')
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
viewbox = cls._parse_svg_viewbox(content)
|
||||||
|
if viewbox is None:
|
||||||
|
continue
|
||||||
|
vb_width, vb_height = viewbox
|
||||||
|
near_left = x <= 40
|
||||||
|
near_top = y <= 40
|
||||||
|
near_right = x + width >= vb_width - 40
|
||||||
|
near_bottom = y + height >= vb_height - 40
|
||||||
|
if (near_left or near_right) and (near_top or near_bottom):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
def _check_animation_config_contract(self, dir_path: Path) -> None:
|
def _check_animation_config_contract(self, dir_path: Path) -> None:
|
||||||
"""Project-level animations.json reference checks."""
|
"""Project-level animations.json reference checks."""
|
||||||
if _load_animation_config is None or _validate_animation_config is None:
|
if _load_animation_config is None or _validate_animation_config is None:
|
||||||
@@ -1447,6 +1816,8 @@ class SVGQualityChecker:
|
|||||||
|
|
||||||
def print_summary(self):
|
def print_summary(self):
|
||||||
"""Print check summary"""
|
"""Print check summary"""
|
||||||
|
self._apply_aggregated_issue_counts()
|
||||||
|
|
||||||
print("=" * 80)
|
print("=" * 80)
|
||||||
print("[SUMMARY] Check Summary")
|
print("[SUMMARY] Check Summary")
|
||||||
print("=" * 80)
|
print("=" * 80)
|
||||||
@@ -1473,6 +1844,9 @@ class SVGQualityChecker:
|
|||||||
# Animation config aggregation.
|
# Animation config aggregation.
|
||||||
self._print_animation_summary()
|
self._print_animation_summary()
|
||||||
|
|
||||||
|
# Illustration strategy aggregation.
|
||||||
|
self._print_illustration_summary()
|
||||||
|
|
||||||
# Fix suggestions
|
# Fix suggestions
|
||||||
if self.summary['errors'] > 0 or self.summary['warnings'] > 0:
|
if self.summary['errors'] > 0 or self.summary['warnings'] > 0:
|
||||||
print(f"\n[TIP] Common fixes:")
|
print(f"\n[TIP] Common fixes:")
|
||||||
@@ -1488,10 +1862,6 @@ class SVGQualityChecker:
|
|||||||
|
|
||||||
errors = [item for item in self._animation_issues if item[0] == 'error']
|
errors = [item for item in self._animation_issues if item[0] == 'error']
|
||||||
warnings = [item for item in self._animation_issues if item[0] == 'warning']
|
warnings = [item for item in self._animation_issues if item[0] == 'warning']
|
||||||
self.summary['errors'] += len(errors)
|
|
||||||
self.summary['warnings'] += len(warnings)
|
|
||||||
for severity, _msg in self._animation_issues:
|
|
||||||
self.issue_types[f'animation_config_{severity}'] += 1
|
|
||||||
|
|
||||||
print("\n[ANIMATION] animations.json checks")
|
print("\n[ANIMATION] animations.json checks")
|
||||||
for _severity, msg in errors:
|
for _severity, msg in errors:
|
||||||
@@ -1499,6 +1869,24 @@ class SVGQualityChecker:
|
|||||||
for _severity, msg in warnings:
|
for _severity, msg in warnings:
|
||||||
print(f" [WARN] {msg}")
|
print(f" [WARN] {msg}")
|
||||||
|
|
||||||
|
def _print_illustration_summary(self):
|
||||||
|
"""Print project-level illustration strategy issues if present."""
|
||||||
|
if not self._illustration_issues:
|
||||||
|
return
|
||||||
|
|
||||||
|
errors = [item for item in self._illustration_issues if item[0] == 'error']
|
||||||
|
warnings = [item for item in self._illustration_issues if item[0] == 'warning']
|
||||||
|
|
||||||
|
print("\n[ILLUSTRATION] Illustration strategy checks")
|
||||||
|
if errors:
|
||||||
|
print(f" Errors ({len(errors)}):")
|
||||||
|
for _severity, kind, msg in errors:
|
||||||
|
print(f" [{kind}] {msg}")
|
||||||
|
if warnings:
|
||||||
|
print(f" Warnings ({len(warnings)}):")
|
||||||
|
for _severity, kind, msg in warnings:
|
||||||
|
print(f" [{kind}] {msg}")
|
||||||
|
|
||||||
def _print_template_summary(self):
|
def _print_template_summary(self):
|
||||||
"""Aggregate template-mode roster / placeholder issues at the bottom.
|
"""Aggregate template-mode roster / placeholder issues at the bottom.
|
||||||
|
|
||||||
@@ -1512,13 +1900,6 @@ class SVGQualityChecker:
|
|||||||
errors = [item for item in self._template_issues if item[0] == 'error']
|
errors = [item for item in self._template_issues if item[0] == 'error']
|
||||||
warnings = [item for item in self._template_issues if item[0] == 'warning']
|
warnings = [item for item in self._template_issues if item[0] == 'warning']
|
||||||
|
|
||||||
# Mirror into the global summary so downstream "0 errors" gates honor
|
|
||||||
# template-mode issues.
|
|
||||||
self.summary['errors'] += len(errors)
|
|
||||||
self.summary['warnings'] += len(warnings)
|
|
||||||
for severity, kind, _msg in self._template_issues:
|
|
||||||
self.issue_types[f"template_{kind}"] += 1
|
|
||||||
|
|
||||||
print("\n[TEMPLATE] Template mode checks")
|
print("\n[TEMPLATE] Template mode checks")
|
||||||
if errors:
|
if errors:
|
||||||
print(f" Errors ({len(errors)}):")
|
print(f" Errors ({len(errors)}):")
|
||||||
@@ -1532,6 +1913,33 @@ class SVGQualityChecker:
|
|||||||
print(" No structural roster issues. Placeholder hints above are advisory only;")
|
print(" No structural roster issues. Placeholder hints above are advisory only;")
|
||||||
print(" declare 'placeholders:' frontmatter in design_spec.md to silence them.")
|
print(" declare 'placeholders:' frontmatter in design_spec.md to silence them.")
|
||||||
|
|
||||||
|
def _apply_aggregated_issue_counts(self):
|
||||||
|
"""Mirror project-level aggregate issues into summary counters once."""
|
||||||
|
if self._aggregate_counts_applied:
|
||||||
|
return
|
||||||
|
self._aggregate_counts_applied = True
|
||||||
|
|
||||||
|
animation_errors = [item for item in self._animation_issues if item[0] == 'error']
|
||||||
|
animation_warnings = [item for item in self._animation_issues if item[0] == 'warning']
|
||||||
|
self.summary['errors'] += len(animation_errors)
|
||||||
|
self.summary['warnings'] += len(animation_warnings)
|
||||||
|
for severity, _msg in self._animation_issues:
|
||||||
|
self.issue_types[f'animation_config_{severity}'] += 1
|
||||||
|
|
||||||
|
template_errors = [item for item in self._template_issues if item[0] == 'error']
|
||||||
|
template_warnings = [item for item in self._template_issues if item[0] == 'warning']
|
||||||
|
self.summary['errors'] += len(template_errors)
|
||||||
|
self.summary['warnings'] += len(template_warnings)
|
||||||
|
for severity, kind, _msg in self._template_issues:
|
||||||
|
self.issue_types[f'template_{kind}_{severity}'] += 1
|
||||||
|
|
||||||
|
illustration_errors = [item for item in self._illustration_issues if item[0] == 'error']
|
||||||
|
illustration_warnings = [item for item in self._illustration_issues if item[0] == 'warning']
|
||||||
|
self.summary['errors'] += len(illustration_errors)
|
||||||
|
self.summary['warnings'] += len(illustration_warnings)
|
||||||
|
for severity, kind, _msg in self._illustration_issues:
|
||||||
|
self.issue_types[f'illustration_{kind}_{severity}'] += 1
|
||||||
|
|
||||||
def _print_drift_summary(self):
|
def _print_drift_summary(self):
|
||||||
"""Print spec_lock drift aggregation if any was observed.
|
"""Print spec_lock drift aggregation if any was observed.
|
||||||
|
|
||||||
@@ -1571,7 +1979,7 @@ class SVGQualityChecker:
|
|||||||
"""Calculate percentage"""
|
"""Calculate percentage"""
|
||||||
if self.summary['total'] == 0:
|
if self.summary['total'] == 0:
|
||||||
return 0
|
return 0
|
||||||
return int(count / self.summary['total'] * 100)
|
return min(100, int(count / self.summary['total'] * 100))
|
||||||
|
|
||||||
def export_report(self, output_file: str = 'svg_quality_report.txt'):
|
def export_report(self, output_file: str = 'svg_quality_report.txt'):
|
||||||
"""Export check report"""
|
"""Export check report"""
|
||||||
|
|||||||
+12
@@ -50,6 +50,13 @@ class ConvertContext:
|
|||||||
# Default-on flag: merge mergeable paragraph blocks into one editable
|
# Default-on flag: merge mergeable paragraph blocks into one editable
|
||||||
# text frame with multiple <a:p>. Disable it for strict line fidelity.
|
# text frame with multiple <a:p>. Disable it for strict line fidelity.
|
||||||
merge_paragraphs: bool = True
|
merge_paragraphs: bool = True
|
||||||
|
# Native PPTX image optimization. Keeps generated decks compact by
|
||||||
|
# downsampling oversized raster assets to their rendered size.
|
||||||
|
image_optimize: bool = True
|
||||||
|
image_max_dimension: int | None = 2560
|
||||||
|
image_sizing: str = 'cap'
|
||||||
|
image_scale: float = 2.0
|
||||||
|
image_quality: int = 85
|
||||||
# Optional per-element conversion diagnostics. Shared by child contexts so
|
# Optional per-element conversion diagnostics. Shared by child contexts so
|
||||||
# callers can inspect native / skipped / unsupported decisions per slide.
|
# callers can inspect native / skipped / unsupported decisions per slide.
|
||||||
trace_events: list[dict[str, Any]] | None = None
|
trace_events: list[dict[str, Any]] | None = None
|
||||||
@@ -153,6 +160,11 @@ class ConvertContext:
|
|||||||
# anim_targets is intentionally a fresh list on the child;
|
# anim_targets is intentionally a fresh list on the child;
|
||||||
# only the root-level context's list is read by the builder.
|
# only the root-level context's list is read by the builder.
|
||||||
merge_paragraphs=self.merge_paragraphs,
|
merge_paragraphs=self.merge_paragraphs,
|
||||||
|
image_optimize=self.image_optimize,
|
||||||
|
image_max_dimension=self.image_max_dimension,
|
||||||
|
image_sizing=self.image_sizing,
|
||||||
|
image_scale=self.image_scale,
|
||||||
|
image_quality=self.image_quality,
|
||||||
trace_events=self.trace_events,
|
trace_events=self.trace_events,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
+17
-1
@@ -292,7 +292,7 @@ _NON_VISUAL_TAGS = frozenset(('defs', 'title', 'desc', 'metadata', 'style'))
|
|||||||
def _supports_matrix_transform(elem: ET.Element) -> bool:
|
def _supports_matrix_transform(elem: ET.Element) -> bool:
|
||||||
"""Return whether this subtree can consume a full affine matrix directly."""
|
"""Return whether this subtree can consume a full affine matrix directly."""
|
||||||
tag = elem.tag.replace(f'{{{SVG_NS}}}', '')
|
tag = elem.tag.replace(f'{{{SVG_NS}}}', '')
|
||||||
if tag == 'image':
|
if tag in {'rect', 'circle', 'ellipse', 'line', 'path', 'polygon', 'polyline', 'image'}:
|
||||||
return True
|
return True
|
||||||
if tag == 'svg':
|
if tag == 'svg':
|
||||||
visual_children = [
|
visual_children = [
|
||||||
@@ -421,6 +421,11 @@ def convert_svg_to_slide_shapes(
|
|||||||
slide_num: int = 1,
|
slide_num: int = 1,
|
||||||
verbose: bool = False,
|
verbose: bool = False,
|
||||||
merge_paragraphs: bool = True,
|
merge_paragraphs: bool = True,
|
||||||
|
image_optimize: bool = True,
|
||||||
|
image_max_dimension: int | None = 2560,
|
||||||
|
image_sizing: str = 'cap',
|
||||||
|
image_scale: float = 2.0,
|
||||||
|
image_quality: int = 85,
|
||||||
trace_out: list[dict[str, Any]] | None = None,
|
trace_out: list[dict[str, Any]] | None = None,
|
||||||
) -> tuple[str, dict[str, bytes], list[dict[str, str]], list]:
|
) -> tuple[str, dict[str, bytes], list[dict[str, str]], list]:
|
||||||
"""Convert an SVG file to a complete DrawingML slide XML.
|
"""Convert an SVG file to a complete DrawingML slide XML.
|
||||||
@@ -433,6 +438,12 @@ def convert_svg_to_slide_shapes(
|
|||||||
dy clustered around one base line-height) become a single
|
dy clustered around one base line-height) become a single
|
||||||
editable text frame with multiple <a:p>. Disable it to preserve
|
editable text frame with multiple <a:p>. Disable it to preserve
|
||||||
the SVG's exact line layout (one textbox per line).
|
the SVG's exact line layout (one textbox per line).
|
||||||
|
image_optimize: Downsample oversized raster images for PPTX export.
|
||||||
|
image_max_dimension: Maximum optimized image dimension in pixels.
|
||||||
|
image_sizing: ``cap`` to only cap source dimensions, ``display`` to
|
||||||
|
size from rendered SVG boxes.
|
||||||
|
image_scale: Target image pixels per SVG display pixel.
|
||||||
|
image_quality: JPEG quality used for opaque optimized rasters.
|
||||||
trace_out: Optional list populated with one per-slide trace dictionary.
|
trace_out: Optional list populated with one per-slide trace dictionary.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
@@ -495,6 +506,11 @@ def convert_svg_to_slide_shapes(
|
|||||||
slide_num=slide_num,
|
slide_num=slide_num,
|
||||||
svg_dir=Path(svg_path).parent,
|
svg_dir=Path(svg_path).parent,
|
||||||
merge_paragraphs=merge_paragraphs,
|
merge_paragraphs=merge_paragraphs,
|
||||||
|
image_optimize=image_optimize,
|
||||||
|
image_max_dimension=image_max_dimension,
|
||||||
|
image_sizing=image_sizing,
|
||||||
|
image_scale=image_scale,
|
||||||
|
image_quality=image_quality,
|
||||||
trace_events=trace_events,
|
trace_events=trace_events,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
+457
-127
@@ -19,7 +19,7 @@ from .drawingml_utils import (
|
|||||||
parse_hex_color, resolve_url_id, get_effective_filter_id,
|
parse_hex_color, resolve_url_id, get_effective_filter_id,
|
||||||
parse_font_family, is_cjk_char, estimate_text_width,
|
parse_font_family, is_cjk_char, estimate_text_width,
|
||||||
detect_text_lang, resolve_text_run_fonts,
|
detect_text_lang, resolve_text_run_fonts,
|
||||||
parse_transform_matrix, _xml_escape,
|
matrix_multiply, parse_transform_matrix, transform_point, _xml_escape,
|
||||||
)
|
)
|
||||||
from .drawingml_styles import (
|
from .drawingml_styles import (
|
||||||
build_solid_fill, build_gradient_fill,
|
build_solid_fill, build_gradient_fill,
|
||||||
@@ -59,16 +59,18 @@ def _wrap_shape(
|
|||||||
geom_xml: str, fill_xml: str, stroke_xml: str,
|
geom_xml: str, fill_xml: str, stroke_xml: str,
|
||||||
effect_xml: str = '', extra_xml: str = '',
|
effect_xml: str = '', extra_xml: str = '',
|
||||||
rot: int = 0,
|
rot: int = 0,
|
||||||
|
xfrm_attr: str = '',
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Wrap DrawingML content into a <p:sp> shape element."""
|
"""Wrap DrawingML content into a <p:sp> shape element."""
|
||||||
rot_attr = f' rot="{rot}"' if rot else ''
|
rot_attr = f' rot="{rot}"' if rot else ''
|
||||||
|
xfrm_attrs = f'{xfrm_attr}{rot_attr}'
|
||||||
return f'''<p:sp>
|
return f'''<p:sp>
|
||||||
<p:nvSpPr>
|
<p:nvSpPr>
|
||||||
<p:cNvPr id="{shape_id}" name="{_xml_escape(name)}"/>
|
<p:cNvPr id="{shape_id}" name="{_xml_escape(name)}"/>
|
||||||
<p:cNvSpPr/><p:nvPr/>
|
<p:cNvSpPr/><p:nvPr/>
|
||||||
</p:nvSpPr>
|
</p:nvSpPr>
|
||||||
<p:spPr>
|
<p:spPr>
|
||||||
<a:xfrm{rot_attr}><a:off x="{off_x}" y="{off_y}"/><a:ext cx="{ext_cx}" cy="{ext_cy}"/></a:xfrm>
|
<a:xfrm{xfrm_attrs}><a:off x="{off_x}" y="{off_y}"/><a:ext cx="{ext_cx}" cy="{ext_cy}"/></a:xfrm>
|
||||||
{geom_xml}
|
{geom_xml}
|
||||||
{fill_xml}
|
{fill_xml}
|
||||||
{stroke_xml}
|
{stroke_xml}
|
||||||
@@ -78,6 +80,90 @@ def _wrap_shape(
|
|||||||
</p:sp>'''
|
</p:sp>'''
|
||||||
|
|
||||||
|
|
||||||
|
def _context_transform_matrix(ctx: ConvertContext) -> tuple[float, float, float, float, float, float]:
|
||||||
|
"""Return the current context as a full SVG affine matrix."""
|
||||||
|
if ctx.use_transform_matrix:
|
||||||
|
return ctx.transform_matrix
|
||||||
|
return (
|
||||||
|
ctx.scale_x, 0.0,
|
||||||
|
0.0, ctx.scale_y,
|
||||||
|
ctx.translate_x, ctx.translate_y,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _combined_transform_matrix(
|
||||||
|
ctx: ConvertContext,
|
||||||
|
transform: str | None,
|
||||||
|
) -> tuple[float, float, float, float, float, float]:
|
||||||
|
"""Compose context transform with an element-level transform attribute."""
|
||||||
|
matrix = _context_transform_matrix(ctx)
|
||||||
|
if transform:
|
||||||
|
matrix = matrix_multiply(matrix, parse_transform_matrix(transform))
|
||||||
|
return matrix
|
||||||
|
|
||||||
|
|
||||||
|
def _uses_full_transform(ctx: ConvertContext, transform: str | None) -> bool:
|
||||||
|
return ctx.use_transform_matrix or bool(transform)
|
||||||
|
|
||||||
|
|
||||||
|
def _transformed_point(
|
||||||
|
ctx: ConvertContext,
|
||||||
|
x: float,
|
||||||
|
y: float,
|
||||||
|
transform: str | None,
|
||||||
|
) -> tuple[float, float]:
|
||||||
|
if _uses_full_transform(ctx, transform):
|
||||||
|
return transform_point(_combined_transform_matrix(ctx, transform), x, y)
|
||||||
|
return ctx_x(x, ctx), ctx_y(y, ctx)
|
||||||
|
|
||||||
|
|
||||||
|
def _shape_xfrm_from_svg_rect(
|
||||||
|
ctx: ConvertContext,
|
||||||
|
raw_x: float,
|
||||||
|
raw_y: float,
|
||||||
|
raw_w: float,
|
||||||
|
raw_h: float,
|
||||||
|
resolved_x: float,
|
||||||
|
resolved_y: float,
|
||||||
|
resolved_w: float,
|
||||||
|
resolved_h: float,
|
||||||
|
transform: str | None,
|
||||||
|
) -> tuple[str, int, int, int, int, tuple[int, int, int, int]]:
|
||||||
|
"""Build DrawingML xfrm data for an SVG rectangle-like element."""
|
||||||
|
if _uses_full_transform(ctx, transform):
|
||||||
|
return rect_to_dml_xfrm(
|
||||||
|
raw_x, raw_y, raw_w, raw_h,
|
||||||
|
_combined_transform_matrix(ctx, transform),
|
||||||
|
)
|
||||||
|
|
||||||
|
off_x = px_to_emu(resolved_x)
|
||||||
|
off_y = px_to_emu(resolved_y)
|
||||||
|
ext_cx = px_to_emu(resolved_w)
|
||||||
|
ext_cy = px_to_emu(resolved_h)
|
||||||
|
return '', off_x, off_y, ext_cx, ext_cy, (off_x, off_y, off_x + ext_cx, off_y + ext_cy)
|
||||||
|
|
||||||
|
|
||||||
|
def _transform_path_commands(
|
||||||
|
commands: list[PathCommand],
|
||||||
|
matrix: tuple[float, float, float, float, float, float],
|
||||||
|
) -> list[PathCommand]:
|
||||||
|
"""Apply an affine transform to normalized M/L/C/Z path commands."""
|
||||||
|
transformed: list[PathCommand] = []
|
||||||
|
for cmd in commands:
|
||||||
|
if cmd.cmd in ('M', 'L'):
|
||||||
|
x, y = transform_point(matrix, cmd.args[0], cmd.args[1])
|
||||||
|
transformed.append(PathCommand(cmd.cmd, [x, y]))
|
||||||
|
elif cmd.cmd == 'C':
|
||||||
|
args: list[float] = []
|
||||||
|
for i in range(0, 6, 2):
|
||||||
|
x, y = transform_point(matrix, cmd.args[i], cmd.args[i + 1])
|
||||||
|
args.extend([x, y])
|
||||||
|
transformed.append(PathCommand(cmd.cmd, args))
|
||||||
|
else:
|
||||||
|
transformed.append(cmd)
|
||||||
|
return transformed
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# rect
|
# rect
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -215,10 +301,14 @@ def convert_rect(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None:
|
|||||||
for now — current corpora contain none, but the branch keeps callers from
|
for now — current corpora contain none, but the branch keeps callers from
|
||||||
silently producing distorted custom geometry if one ever appears.
|
silently producing distorted custom geometry if one ever appears.
|
||||||
"""
|
"""
|
||||||
x = ctx_x(_f(elem.get('x')), ctx)
|
raw_x = _f(elem.get('x'))
|
||||||
y = ctx_y(_f(elem.get('y')), ctx)
|
raw_y = _f(elem.get('y'))
|
||||||
w = ctx_w(_f(elem.get('width')), ctx)
|
raw_w = _f(elem.get('width'))
|
||||||
h = ctx_h(_f(elem.get('height')), ctx)
|
raw_h = _f(elem.get('height'))
|
||||||
|
x = ctx_x(raw_x, ctx)
|
||||||
|
y = ctx_y(raw_y, ctx)
|
||||||
|
w = ctx_w(raw_w, ctx)
|
||||||
|
h = ctx_h(raw_h, ctx)
|
||||||
|
|
||||||
if w <= 0 or h <= 0:
|
if w <= 0 or h <= 0:
|
||||||
return None
|
return None
|
||||||
@@ -247,12 +337,7 @@ def convert_rect(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None:
|
|||||||
if filt_id and filt_id in ctx.defs:
|
if filt_id and filt_id in ctx.defs:
|
||||||
effect = build_effect_xml(ctx.defs[filt_id])
|
effect = build_effect_xml(ctx.defs[filt_id])
|
||||||
|
|
||||||
rot = 0
|
|
||||||
transform = elem.get('transform')
|
transform = elem.get('transform')
|
||||||
if transform:
|
|
||||||
r_match = re.search(r'rotate\(\s*([-\d.]+)', transform)
|
|
||||||
if r_match:
|
|
||||||
rot = int(float(r_match.group(1)) * ANGLE_UNIT)
|
|
||||||
|
|
||||||
if rx > 0 and abs(rx - ry) < 0.5:
|
if rx > 0 and abs(rx - ry) < 0.5:
|
||||||
# Symmetric corners → native PowerPoint rounded rectangle. adj is
|
# Symmetric corners → native PowerPoint rounded rectangle. adj is
|
||||||
@@ -279,17 +364,25 @@ def convert_rect(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None:
|
|||||||
geom = '<a:prstGeom prst="rect"><a:avLst/></a:prstGeom>'
|
geom = '<a:prstGeom prst="rect"><a:avLst/></a:prstGeom>'
|
||||||
|
|
||||||
shape_id = ctx.next_id()
|
shape_id = ctx.next_id()
|
||||||
off_x = px_to_emu(x)
|
xfrm_attr, off_x, off_y, ext_cx, ext_cy, bounds_emu = _shape_xfrm_from_svg_rect(
|
||||||
off_y = px_to_emu(y)
|
ctx,
|
||||||
ext_cx = px_to_emu(w)
|
raw_x,
|
||||||
ext_cy = px_to_emu(h)
|
raw_y,
|
||||||
|
raw_w,
|
||||||
|
raw_h,
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
w,
|
||||||
|
h,
|
||||||
|
transform,
|
||||||
|
)
|
||||||
return ShapeResult(
|
return ShapeResult(
|
||||||
xml=_wrap_shape(
|
xml=_wrap_shape(
|
||||||
shape_id, f'Rectangle {shape_id}',
|
shape_id, f'Rectangle {shape_id}',
|
||||||
off_x, off_y, ext_cx, ext_cy,
|
off_x, off_y, ext_cx, ext_cy,
|
||||||
geom, fill, stroke, effect, rot=rot,
|
geom, fill, stroke, effect, xfrm_attr=xfrm_attr,
|
||||||
),
|
),
|
||||||
bounds_emu=(off_x, off_y, off_x + ext_cx, off_y + ext_cy),
|
bounds_emu=bounds_emu,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -461,6 +554,7 @@ def convert_circle(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
# --- Normal circle ---
|
# --- Normal circle ---
|
||||||
|
transform = elem.get('transform')
|
||||||
cx_s = ctx_x(cx_, ctx)
|
cx_s = ctx_x(cx_, ctx)
|
||||||
cy_s = ctx_y(cy_, ctx)
|
cy_s = ctx_y(cy_, ctx)
|
||||||
r_x = r * ctx.scale_x
|
r_x = r * ctx.scale_x
|
||||||
@@ -484,17 +578,25 @@ def convert_circle(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None:
|
|||||||
geom = '<a:prstGeom prst="ellipse"><a:avLst/></a:prstGeom>'
|
geom = '<a:prstGeom prst="ellipse"><a:avLst/></a:prstGeom>'
|
||||||
|
|
||||||
shape_id = ctx.next_id()
|
shape_id = ctx.next_id()
|
||||||
off_x = px_to_emu(x)
|
xfrm_attr, off_x, off_y, ext_cx, ext_cy, bounds_emu = _shape_xfrm_from_svg_rect(
|
||||||
off_y = px_to_emu(y)
|
ctx,
|
||||||
ext_cx = px_to_emu(w)
|
cx_ - r,
|
||||||
ext_cy = px_to_emu(h)
|
cy_ - r,
|
||||||
|
r * 2,
|
||||||
|
r * 2,
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
w,
|
||||||
|
h,
|
||||||
|
transform,
|
||||||
|
)
|
||||||
return ShapeResult(
|
return ShapeResult(
|
||||||
xml=_wrap_shape(
|
xml=_wrap_shape(
|
||||||
shape_id, f'Ellipse {shape_id}',
|
shape_id, f'Ellipse {shape_id}',
|
||||||
off_x, off_y, ext_cx, ext_cy,
|
off_x, off_y, ext_cx, ext_cy,
|
||||||
geom, fill, stroke, effect,
|
geom, fill, stroke, effect, xfrm_attr=xfrm_attr,
|
||||||
),
|
),
|
||||||
bounds_emu=(off_x, off_y, off_x + ext_cx, off_y + ext_cy),
|
bounds_emu=bounds_emu,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -510,10 +612,9 @@ def convert_line(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None:
|
|||||||
heads (headEnd / tailEnd) correctly. Plain lines (no markers) continue to
|
heads (headEnd / tailEnd) correctly. Plain lines (no markers) continue to
|
||||||
use custom geometry which is sufficient and avoids flipH/flipV complexity.
|
use custom geometry which is sufficient and avoids flipH/flipV complexity.
|
||||||
"""
|
"""
|
||||||
x1 = ctx_x(_f(elem.get('x1')), ctx)
|
transform = elem.get('transform')
|
||||||
y1 = ctx_y(_f(elem.get('y1')), ctx)
|
x1, y1 = _transformed_point(ctx, _f(elem.get('x1')), _f(elem.get('y1')), transform)
|
||||||
x2 = ctx_x(_f(elem.get('x2')), ctx)
|
x2, y2 = _transformed_point(ctx, _f(elem.get('x2')), _f(elem.get('y2')), transform)
|
||||||
y2 = ctx_y(_f(elem.get('y2')), ctx)
|
|
||||||
|
|
||||||
min_x = min(x1, x2)
|
min_x = min(x1, x2)
|
||||||
min_y = min(y1, y2)
|
min_y = min(y1, y2)
|
||||||
@@ -521,13 +622,6 @@ def convert_line(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None:
|
|||||||
stroke_op = get_stroke_opacity(elem, ctx)
|
stroke_op = get_stroke_opacity(elem, ctx)
|
||||||
stroke = build_stroke_xml(elem, ctx, stroke_op)
|
stroke = build_stroke_xml(elem, ctx, stroke_op)
|
||||||
|
|
||||||
rot = 0
|
|
||||||
transform = elem.get('transform')
|
|
||||||
if transform:
|
|
||||||
r_match = re.search(r'rotate\(\s*([-\d.]+)', transform)
|
|
||||||
if r_match:
|
|
||||||
rot = int(float(r_match.group(1)) * ANGLE_UNIT)
|
|
||||||
|
|
||||||
shape_id = ctx.next_id()
|
shape_id = ctx.next_id()
|
||||||
off_x = px_to_emu(min_x)
|
off_x = px_to_emu(min_x)
|
||||||
off_y = px_to_emu(min_y)
|
off_y = px_to_emu(min_y)
|
||||||
@@ -572,7 +666,6 @@ def convert_line(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None:
|
|||||||
elif flip_v:
|
elif flip_v:
|
||||||
flip_attr = ' flipV="1"'
|
flip_attr = ' flipV="1"'
|
||||||
|
|
||||||
rot_attr = f' rot="{rot}"' if rot else ''
|
|
||||||
xml = (
|
xml = (
|
||||||
f'<p:sp>'
|
f'<p:sp>'
|
||||||
f'<p:nvSpPr>'
|
f'<p:nvSpPr>'
|
||||||
@@ -580,7 +673,7 @@ def convert_line(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None:
|
|||||||
f'<p:cNvSpPr/><p:nvPr/>'
|
f'<p:cNvSpPr/><p:nvPr/>'
|
||||||
f'</p:nvSpPr>'
|
f'</p:nvSpPr>'
|
||||||
f'<p:spPr>'
|
f'<p:spPr>'
|
||||||
f'<a:xfrm{flip_attr}{rot_attr}>'
|
f'<a:xfrm{flip_attr}>'
|
||||||
f'<a:off x="{off_x}" y="{off_y}"/>'
|
f'<a:off x="{off_x}" y="{off_y}"/>'
|
||||||
f'<a:ext cx="{w_emu}" cy="{h_emu}"/>'
|
f'<a:ext cx="{w_emu}" cy="{h_emu}"/>'
|
||||||
f'</a:xfrm>'
|
f'</a:xfrm>'
|
||||||
@@ -617,7 +710,7 @@ def convert_line(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None:
|
|||||||
xml = _wrap_shape(
|
xml = _wrap_shape(
|
||||||
shape_id, f'Line {shape_id}',
|
shape_id, f'Line {shape_id}',
|
||||||
off_x, off_y, w_emu, h_emu,
|
off_x, off_y, w_emu, h_emu,
|
||||||
geom, '<a:noFill/>', stroke, rot=rot,
|
geom, '<a:noFill/>', stroke,
|
||||||
)
|
)
|
||||||
|
|
||||||
return ShapeResult(
|
return ShapeResult(
|
||||||
@@ -640,22 +733,17 @@ def convert_path(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None:
|
|||||||
commands = svg_path_to_absolute(commands)
|
commands = svg_path_to_absolute(commands)
|
||||||
commands = normalize_path_commands(commands)
|
commands = normalize_path_commands(commands)
|
||||||
|
|
||||||
tx, ty = 0.0, 0.0
|
|
||||||
rot = 0
|
|
||||||
transform = elem.get('transform')
|
transform = elem.get('transform')
|
||||||
if transform:
|
if _uses_full_transform(ctx, transform):
|
||||||
t_match = re.search(r'translate\(\s*([-\d.]+)[\s,]+([-\d.]+)\s*\)', transform)
|
commands = _transform_path_commands(commands, _combined_transform_matrix(ctx, transform))
|
||||||
if t_match:
|
path_xml, min_x, min_y, width, height = path_commands_to_drawingml(
|
||||||
tx = float(t_match.group(1))
|
commands, 0, 0, 1.0, 1.0,
|
||||||
ty = float(t_match.group(2))
|
)
|
||||||
r_match = re.search(r'rotate\(\s*([-\d.]+)', transform)
|
else:
|
||||||
if r_match:
|
path_xml, min_x, min_y, width, height = path_commands_to_drawingml(
|
||||||
rot = int(float(r_match.group(1)) * ANGLE_UNIT)
|
commands, ctx.translate_x, ctx.translate_y,
|
||||||
|
ctx.scale_x, ctx.scale_y,
|
||||||
path_xml, min_x, min_y, width, height = path_commands_to_drawingml(
|
)
|
||||||
commands, ctx.translate_x + tx, ctx.translate_y + ty,
|
|
||||||
ctx.scale_x, ctx.scale_y,
|
|
||||||
)
|
|
||||||
|
|
||||||
if not path_xml:
|
if not path_xml:
|
||||||
return None
|
return None
|
||||||
@@ -663,7 +751,7 @@ def convert_path(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None:
|
|||||||
w_emu = px_to_emu(width)
|
w_emu = px_to_emu(width)
|
||||||
h_emu = px_to_emu(height)
|
h_emu = px_to_emu(height)
|
||||||
|
|
||||||
geom = _build_preset_geom_from_meta(elem)
|
geom = None if _uses_full_transform(ctx, transform) else _build_preset_geom_from_meta(elem)
|
||||||
if geom is None:
|
if geom is None:
|
||||||
geom = f'''<a:custGeom>
|
geom = f'''<a:custGeom>
|
||||||
<a:avLst/><a:gdLst/><a:ahLst/><a:cxnLst/>
|
<a:avLst/><a:gdLst/><a:ahLst/><a:cxnLst/>
|
||||||
@@ -690,7 +778,7 @@ def convert_path(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None:
|
|||||||
xml=_wrap_shape(
|
xml=_wrap_shape(
|
||||||
shape_id, f'Freeform {shape_id}',
|
shape_id, f'Freeform {shape_id}',
|
||||||
off_x, off_y, w_emu, h_emu,
|
off_x, off_y, w_emu, h_emu,
|
||||||
geom, fill, stroke, effect, rot=rot,
|
geom, fill, stroke, effect,
|
||||||
),
|
),
|
||||||
bounds_emu=(off_x, off_y, off_x + w_emu, off_y + h_emu),
|
bounds_emu=(off_x, off_y, off_x + w_emu, off_y + h_emu),
|
||||||
)
|
)
|
||||||
@@ -719,10 +807,17 @@ def convert_polygon(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None
|
|||||||
commands.append(PathCommand('L', [px_, py_]))
|
commands.append(PathCommand('L', [px_, py_]))
|
||||||
commands.append(PathCommand('Z', []))
|
commands.append(PathCommand('Z', []))
|
||||||
|
|
||||||
path_xml, min_x, min_y, width, height = path_commands_to_drawingml(
|
transform = elem.get('transform')
|
||||||
commands, ctx.translate_x, ctx.translate_y,
|
if _uses_full_transform(ctx, transform):
|
||||||
ctx.scale_x, ctx.scale_y,
|
commands = _transform_path_commands(commands, _combined_transform_matrix(ctx, transform))
|
||||||
)
|
path_xml, min_x, min_y, width, height = path_commands_to_drawingml(
|
||||||
|
commands, 0, 0, 1.0, 1.0,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
path_xml, min_x, min_y, width, height = path_commands_to_drawingml(
|
||||||
|
commands, ctx.translate_x, ctx.translate_y,
|
||||||
|
ctx.scale_x, ctx.scale_y,
|
||||||
|
)
|
||||||
|
|
||||||
if not path_xml:
|
if not path_xml:
|
||||||
return None
|
return None
|
||||||
@@ -743,13 +838,6 @@ def convert_polygon(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None
|
|||||||
fill = build_fill_xml(elem, ctx, fill_op)
|
fill = build_fill_xml(elem, ctx, fill_op)
|
||||||
stroke = build_stroke_xml(elem, ctx, stroke_op)
|
stroke = build_stroke_xml(elem, ctx, stroke_op)
|
||||||
|
|
||||||
rot = 0
|
|
||||||
transform = elem.get('transform')
|
|
||||||
if transform:
|
|
||||||
r_match = re.search(r'rotate\(\s*([-\d.]+)', transform)
|
|
||||||
if r_match:
|
|
||||||
rot = int(float(r_match.group(1)) * ANGLE_UNIT)
|
|
||||||
|
|
||||||
shape_id = ctx.next_id()
|
shape_id = ctx.next_id()
|
||||||
off_x = px_to_emu(min_x)
|
off_x = px_to_emu(min_x)
|
||||||
off_y = px_to_emu(min_y)
|
off_y = px_to_emu(min_y)
|
||||||
@@ -757,7 +845,7 @@ def convert_polygon(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None
|
|||||||
xml=_wrap_shape(
|
xml=_wrap_shape(
|
||||||
shape_id, f'Polygon {shape_id}',
|
shape_id, f'Polygon {shape_id}',
|
||||||
off_x, off_y, w_emu, h_emu,
|
off_x, off_y, w_emu, h_emu,
|
||||||
geom, fill, stroke, rot=rot,
|
geom, fill, stroke,
|
||||||
),
|
),
|
||||||
bounds_emu=(off_x, off_y, off_x + w_emu, off_y + h_emu),
|
bounds_emu=(off_x, off_y, off_x + w_emu, off_y + h_emu),
|
||||||
)
|
)
|
||||||
@@ -773,10 +861,17 @@ def convert_polyline(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | Non
|
|||||||
for px_, py_ in points[1:]:
|
for px_, py_ in points[1:]:
|
||||||
commands.append(PathCommand('L', [px_, py_]))
|
commands.append(PathCommand('L', [px_, py_]))
|
||||||
|
|
||||||
path_xml, min_x, min_y, width, height = path_commands_to_drawingml(
|
transform = elem.get('transform')
|
||||||
commands, ctx.translate_x, ctx.translate_y,
|
if _uses_full_transform(ctx, transform):
|
||||||
ctx.scale_x, ctx.scale_y,
|
commands = _transform_path_commands(commands, _combined_transform_matrix(ctx, transform))
|
||||||
)
|
path_xml, min_x, min_y, width, height = path_commands_to_drawingml(
|
||||||
|
commands, 0, 0, 1.0, 1.0,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
path_xml, min_x, min_y, width, height = path_commands_to_drawingml(
|
||||||
|
commands, ctx.translate_x, ctx.translate_y,
|
||||||
|
ctx.scale_x, ctx.scale_y,
|
||||||
|
)
|
||||||
|
|
||||||
if not path_xml:
|
if not path_xml:
|
||||||
return None
|
return None
|
||||||
@@ -797,13 +892,6 @@ def convert_polyline(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | Non
|
|||||||
fill = build_fill_xml(elem, ctx, fill_op)
|
fill = build_fill_xml(elem, ctx, fill_op)
|
||||||
stroke = build_stroke_xml(elem, ctx, stroke_op)
|
stroke = build_stroke_xml(elem, ctx, stroke_op)
|
||||||
|
|
||||||
rot = 0
|
|
||||||
transform = elem.get('transform')
|
|
||||||
if transform:
|
|
||||||
r_match = re.search(r'rotate\(\s*([-\d.]+)', transform)
|
|
||||||
if r_match:
|
|
||||||
rot = int(float(r_match.group(1)) * ANGLE_UNIT)
|
|
||||||
|
|
||||||
shape_id = ctx.next_id()
|
shape_id = ctx.next_id()
|
||||||
off_x = px_to_emu(min_x)
|
off_x = px_to_emu(min_x)
|
||||||
off_y = px_to_emu(min_y)
|
off_y = px_to_emu(min_y)
|
||||||
@@ -811,7 +899,7 @@ def convert_polyline(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | Non
|
|||||||
xml=_wrap_shape(
|
xml=_wrap_shape(
|
||||||
shape_id, f'Polyline {shape_id}',
|
shape_id, f'Polyline {shape_id}',
|
||||||
off_x, off_y, w_emu, h_emu,
|
off_x, off_y, w_emu, h_emu,
|
||||||
geom, '<a:noFill/>', stroke, rot=rot,
|
geom, '<a:noFill/>', stroke,
|
||||||
),
|
),
|
||||||
bounds_emu=(off_x, off_y, off_x + w_emu, off_y + h_emu),
|
bounds_emu=(off_x, off_y, off_x + w_emu, off_y + h_emu),
|
||||||
)
|
)
|
||||||
@@ -1664,6 +1752,41 @@ def _picture_xfrm_from_rect(
|
|||||||
return '', off_x, off_y, ext_cx, ext_cy, (off_x, off_y, off_x + ext_cx, off_y + ext_cy)
|
return '', off_x, off_y, ext_cx, ext_cy, (off_x, off_y, off_x + ext_cx, off_y + ext_cy)
|
||||||
|
|
||||||
|
|
||||||
|
def _picture_xfrm_from_svg_rect(
|
||||||
|
ctx: ConvertContext,
|
||||||
|
raw_x: float,
|
||||||
|
raw_y: float,
|
||||||
|
raw_w: float,
|
||||||
|
raw_h: float,
|
||||||
|
resolved_x: float,
|
||||||
|
resolved_y: float,
|
||||||
|
resolved_w: float,
|
||||||
|
resolved_h: float,
|
||||||
|
transform: str | None,
|
||||||
|
) -> tuple[str, int, int, int, int, tuple[int, int, int, int]]:
|
||||||
|
"""Build picture xfrm data, honoring element-level SVG transforms.
|
||||||
|
|
||||||
|
``raw_*`` values stay in the element's source SVG coordinate space for
|
||||||
|
matrix decomposition; ``resolved_*`` values are the existing scalar path.
|
||||||
|
"""
|
||||||
|
if ctx.use_transform_matrix:
|
||||||
|
matrix = ctx.transform_matrix
|
||||||
|
if transform:
|
||||||
|
matrix = matrix_multiply(matrix, parse_transform_matrix(transform))
|
||||||
|
return rect_to_dml_xfrm(raw_x, raw_y, raw_w, raw_h, matrix)
|
||||||
|
|
||||||
|
if transform:
|
||||||
|
context_matrix = (
|
||||||
|
ctx.scale_x, 0.0,
|
||||||
|
0.0, ctx.scale_y,
|
||||||
|
ctx.translate_x, ctx.translate_y,
|
||||||
|
)
|
||||||
|
matrix = matrix_multiply(context_matrix, parse_transform_matrix(transform))
|
||||||
|
return rect_to_dml_xfrm(raw_x, raw_y, raw_w, raw_h, matrix)
|
||||||
|
|
||||||
|
return _picture_xfrm_from_rect(ctx, resolved_x, resolved_y, resolved_w, resolved_h)
|
||||||
|
|
||||||
|
|
||||||
def _read_image_size(data: bytes) -> tuple[int | None, int | None]:
|
def _read_image_size(data: bytes) -> tuple[int | None, int | None]:
|
||||||
"""Read intrinsic image dimensions (width, height) from raw bytes.
|
"""Read intrinsic image dimensions (width, height) from raw bytes.
|
||||||
|
|
||||||
@@ -1685,6 +1808,184 @@ def _read_image_size(data: bytes) -> tuple[int | None, int | None]:
|
|||||||
return (None, None)
|
return (None, None)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_preserve_aspect_ratio(par: str | None) -> tuple[str, str]:
|
||||||
|
"""Parse SVG preserveAspectRatio into ``(align, mode)``."""
|
||||||
|
parts = (par or 'xMidYMid meet').strip().split()
|
||||||
|
align = parts[0] if parts else 'xMidYMid'
|
||||||
|
mode = parts[1] if len(parts) > 1 else 'meet'
|
||||||
|
return align, mode
|
||||||
|
|
||||||
|
|
||||||
|
def _image_has_alpha(img: Any) -> bool:
|
||||||
|
"""Return whether a PIL image carries useful transparency."""
|
||||||
|
if img.mode in ('RGBA', 'LA'):
|
||||||
|
return True
|
||||||
|
if img.mode == 'P':
|
||||||
|
return 'transparency' in getattr(img, 'info', {})
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _image_target_size(
|
||||||
|
display_w: float,
|
||||||
|
display_h: float,
|
||||||
|
*,
|
||||||
|
max_dimension: int | None,
|
||||||
|
scale: float,
|
||||||
|
) -> tuple[int, int]:
|
||||||
|
"""Resolve optimized pixel dimensions from rendered SVG dimensions."""
|
||||||
|
target_w = max(1, int(round(display_w * max(scale, 1.0))))
|
||||||
|
target_h = max(1, int(round(display_h * max(scale, 1.0))))
|
||||||
|
if max_dimension and max(target_w, target_h) > max_dimension:
|
||||||
|
ratio = max_dimension / max(target_w, target_h)
|
||||||
|
target_w = max(1, int(round(target_w * ratio)))
|
||||||
|
target_h = max(1, int(round(target_h * ratio)))
|
||||||
|
return target_w, target_h
|
||||||
|
|
||||||
|
|
||||||
|
def _fit_full_image_target(
|
||||||
|
img_w: int,
|
||||||
|
img_h: int,
|
||||||
|
box_w: float,
|
||||||
|
box_h: float,
|
||||||
|
align: str,
|
||||||
|
mode: str,
|
||||||
|
*,
|
||||||
|
sizing: str,
|
||||||
|
max_dimension: int | None,
|
||||||
|
scale: float,
|
||||||
|
) -> tuple[int, int]:
|
||||||
|
"""Size the full source image; never crop pixels.
|
||||||
|
|
||||||
|
``cap`` mode only limits oversized source images by maximum dimension.
|
||||||
|
``display`` mode sizes to the rendered SVG box budget.
|
||||||
|
"""
|
||||||
|
if img_w <= 0 or img_h <= 0:
|
||||||
|
return (1, 1)
|
||||||
|
|
||||||
|
if sizing == 'cap':
|
||||||
|
target_w, target_h = img_w, img_h
|
||||||
|
if max_dimension and max(target_w, target_h) > max_dimension:
|
||||||
|
ratio = max_dimension / max(target_w, target_h)
|
||||||
|
target_w = max(1, int(round(target_w * ratio)))
|
||||||
|
target_h = max(1, int(round(target_h * ratio)))
|
||||||
|
return target_w, target_h
|
||||||
|
|
||||||
|
target_box_w, target_box_h = _image_target_size(
|
||||||
|
box_w,
|
||||||
|
box_h,
|
||||||
|
max_dimension=None,
|
||||||
|
scale=scale,
|
||||||
|
)
|
||||||
|
img_ratio = img_w / img_h
|
||||||
|
box_ratio = box_w / box_h if box_h else img_ratio
|
||||||
|
|
||||||
|
if align != 'none' and mode == 'slice':
|
||||||
|
if box_ratio >= img_ratio:
|
||||||
|
target_w = target_box_w
|
||||||
|
target_h = int(round(target_w / img_ratio))
|
||||||
|
else:
|
||||||
|
target_h = target_box_h
|
||||||
|
target_w = int(round(target_h * img_ratio))
|
||||||
|
else:
|
||||||
|
ratio = min(target_box_w / img_w, target_box_h / img_h, 1.0)
|
||||||
|
target_w = int(round(img_w * ratio))
|
||||||
|
target_h = int(round(img_h * ratio))
|
||||||
|
|
||||||
|
target_w = max(1, target_w)
|
||||||
|
target_h = max(1, target_h)
|
||||||
|
if max_dimension and max(target_w, target_h) > max_dimension:
|
||||||
|
ratio = max_dimension / max(target_w, target_h)
|
||||||
|
target_w = max(1, int(round(target_w * ratio)))
|
||||||
|
target_h = max(1, int(round(target_h * ratio)))
|
||||||
|
return target_w, target_h
|
||||||
|
|
||||||
|
|
||||||
|
def _resize_for_target(img: Any, target_w: int, target_h: int) -> Any:
|
||||||
|
"""Downscale a PIL image to the target dimensions without upsampling."""
|
||||||
|
width, height = img.size
|
||||||
|
if target_w >= width and target_h >= height:
|
||||||
|
return img
|
||||||
|
ratio = min(target_w / width, target_h / height)
|
||||||
|
if ratio >= 1.0:
|
||||||
|
return img
|
||||||
|
try:
|
||||||
|
from PIL import Image # type: ignore
|
||||||
|
except ImportError:
|
||||||
|
return img
|
||||||
|
new_size = (max(1, int(round(width * ratio))), max(1, int(round(height * ratio))))
|
||||||
|
return img.resize(new_size, Image.Resampling.LANCZOS)
|
||||||
|
|
||||||
|
|
||||||
|
def _encode_optimized_image(img: Any, *, prefer_jpeg: bool, quality: int) -> tuple[bytes, str] | None:
|
||||||
|
"""Encode a PIL image for PPTX media."""
|
||||||
|
buf = io.BytesIO()
|
||||||
|
try:
|
||||||
|
if prefer_jpeg and not _image_has_alpha(img):
|
||||||
|
if img.mode != 'RGB':
|
||||||
|
img = img.convert('RGB')
|
||||||
|
img.save(buf, format='JPEG', quality=max(1, min(quality, 100)), optimize=True)
|
||||||
|
return buf.getvalue(), 'jpg'
|
||||||
|
if img.mode == 'P':
|
||||||
|
img = img.convert('RGBA' if _image_has_alpha(img) else 'RGB')
|
||||||
|
img.save(buf, format='PNG', optimize=True)
|
||||||
|
return buf.getvalue(), 'png'
|
||||||
|
except (OSError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _optimize_image_for_pptx(
|
||||||
|
elem: ET.Element,
|
||||||
|
ctx: ConvertContext,
|
||||||
|
img_data: bytes,
|
||||||
|
img_format: str,
|
||||||
|
box_w: float,
|
||||||
|
box_h: float,
|
||||||
|
) -> tuple[bytes, str]:
|
||||||
|
"""Optimize full raster image bytes for native PPTX embedding."""
|
||||||
|
if not ctx.image_optimize:
|
||||||
|
return img_data, img_format
|
||||||
|
if img_format.lower() in {'svg', 'emf', 'wmf'}:
|
||||||
|
return img_data, img_format
|
||||||
|
|
||||||
|
try:
|
||||||
|
from PIL import Image, UnidentifiedImageError # type: ignore
|
||||||
|
except ImportError:
|
||||||
|
return img_data, img_format
|
||||||
|
|
||||||
|
try:
|
||||||
|
img = Image.open(io.BytesIO(img_data))
|
||||||
|
img.load()
|
||||||
|
except (UnidentifiedImageError, OSError, ValueError):
|
||||||
|
return img_data, img_format
|
||||||
|
|
||||||
|
align, mode = _parse_preserve_aspect_ratio(elem.get('preserveAspectRatio'))
|
||||||
|
target_w, target_h = _fit_full_image_target(
|
||||||
|
img.size[0],
|
||||||
|
img.size[1],
|
||||||
|
box_w,
|
||||||
|
box_h,
|
||||||
|
align,
|
||||||
|
mode,
|
||||||
|
sizing=ctx.image_sizing,
|
||||||
|
max_dimension=ctx.image_max_dimension,
|
||||||
|
scale=ctx.image_scale,
|
||||||
|
)
|
||||||
|
|
||||||
|
original_size = img.size
|
||||||
|
img = _resize_for_target(img, target_w, target_h)
|
||||||
|
resized = img.size != original_size
|
||||||
|
prefer_jpeg = img_format.lower() in {'png', 'jpg', 'jpeg', 'bmp', 'tif', 'tiff'}
|
||||||
|
encoded = _encode_optimized_image(img, prefer_jpeg=prefer_jpeg, quality=ctx.image_quality)
|
||||||
|
if encoded is None:
|
||||||
|
return img_data, img_format
|
||||||
|
|
||||||
|
optimized_data, optimized_format = encoded
|
||||||
|
if not resized and len(optimized_data) >= len(img_data):
|
||||||
|
return img_data, img_format
|
||||||
|
|
||||||
|
return optimized_data, optimized_format
|
||||||
|
|
||||||
|
|
||||||
def _compute_slice_src_rect(
|
def _compute_slice_src_rect(
|
||||||
img_w: float, img_h: float,
|
img_w: float, img_h: float,
|
||||||
box_w: float, box_h: float,
|
box_w: float, box_h: float,
|
||||||
@@ -1746,10 +2047,7 @@ def _resolve_image_src_rect(
|
|||||||
shrinks the picture frame to match image aspect ratio); none mode keeps
|
shrinks the picture frame to match image aspect ratio); none mode keeps
|
||||||
the legacy stretch behaviour intentionally.
|
the legacy stretch behaviour intentionally.
|
||||||
"""
|
"""
|
||||||
par = (elem.get('preserveAspectRatio') or 'xMidYMid meet').strip()
|
align, mode = _parse_preserve_aspect_ratio(elem.get('preserveAspectRatio'))
|
||||||
parts = par.split()
|
|
||||||
align = parts[0] if parts else 'xMidYMid'
|
|
||||||
mode = parts[1] if len(parts) > 1 else 'meet'
|
|
||||||
|
|
||||||
if align == 'none' or mode != 'slice':
|
if align == 'none' or mode != 'slice':
|
||||||
return '' # meet handled by frame fit; none → stretch is correct per SVG spec
|
return '' # meet handled by frame fit; none → stretch is correct per SVG spec
|
||||||
@@ -1787,10 +2085,7 @@ def _resolve_image_meet_fit(
|
|||||||
- intrinsic image dimensions cannot be read
|
- intrinsic image dimensions cannot be read
|
||||||
- frame already matches image ratio (no-op)
|
- frame already matches image ratio (no-op)
|
||||||
"""
|
"""
|
||||||
par = (elem.get('preserveAspectRatio') or 'xMidYMid meet').strip()
|
align, mode = _parse_preserve_aspect_ratio(elem.get('preserveAspectRatio'))
|
||||||
parts = par.split()
|
|
||||||
align = parts[0] if parts else 'xMidYMid'
|
|
||||||
mode = parts[1] if len(parts) > 1 else 'meet'
|
|
||||||
|
|
||||||
if align == 'none' or mode == 'slice':
|
if align == 'none' or mode == 'slice':
|
||||||
return None
|
return None
|
||||||
@@ -1868,6 +2163,10 @@ def convert_image(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None:
|
|||||||
img_format = 'jpg'
|
img_format = 'jpg'
|
||||||
img_data = img_path.read_bytes()
|
img_data = img_path.read_bytes()
|
||||||
|
|
||||||
|
img_data, img_format = _optimize_image_for_pptx(
|
||||||
|
elem, ctx, img_data, img_format, w, h,
|
||||||
|
)
|
||||||
|
|
||||||
img_idx = len(ctx.media_files) + 1
|
img_idx = len(ctx.media_files) + 1
|
||||||
img_filename = f's{ctx.slide_num}_img{img_idx}.{img_format}'
|
img_filename = f's{ctx.slide_num}_img{img_idx}.{img_format}'
|
||||||
ctx.media_files[img_filename] = img_data
|
ctx.media_files[img_filename] = img_data
|
||||||
@@ -1879,21 +2178,14 @@ def convert_image(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None:
|
|||||||
'target': f'../media/{img_filename}',
|
'target': f'../media/{img_filename}',
|
||||||
})
|
})
|
||||||
|
|
||||||
rot = 0
|
|
||||||
transform = elem.get('transform')
|
transform = elem.get('transform')
|
||||||
if transform and not ctx.use_transform_matrix:
|
|
||||||
r_match = re.search(r'rotate\(\s*([-\d.]+)', transform)
|
|
||||||
if r_match:
|
|
||||||
rot = int(float(r_match.group(1)) * ANGLE_UNIT)
|
|
||||||
rot_attr = f' rot="{rot}"' if rot else ''
|
|
||||||
|
|
||||||
# Resolve clip-path → DrawingML geometry
|
# Resolve clip-path → DrawingML geometry
|
||||||
clip_geom = _resolve_clip_geometry(elem, ctx, raw_x, raw_y, raw_w, raw_h)
|
clip_geom = _resolve_clip_geometry(elem, ctx, raw_x, raw_y, raw_w, raw_h)
|
||||||
|
|
||||||
# Resolve preserveAspectRatio="<align> slice" → DrawingML <a:srcRect>.
|
# Resolve preserveAspectRatio="<align> slice" as DrawingML crop metadata.
|
||||||
# This keeps the original image intact in the .pptx and lets users
|
# Image optimization only downscales the full source image; it never crops
|
||||||
# re-crop or reset the picture in PowerPoint, instead of permanently
|
# pixels out of the embedded media.
|
||||||
# baking the crop into the embedded asset.
|
|
||||||
src_rect_xml = _resolve_image_src_rect(elem, img_data, w, h)
|
src_rect_xml = _resolve_image_src_rect(elem, img_data, w, h)
|
||||||
|
|
||||||
# Resolve preserveAspectRatio="<align> meet" by shrinking the picture
|
# Resolve preserveAspectRatio="<align> meet" by shrinking the picture
|
||||||
@@ -1908,15 +2200,41 @@ def convert_image(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None:
|
|||||||
shape_id = ctx.next_id()
|
shape_id = ctx.next_id()
|
||||||
if meet_fit is not None:
|
if meet_fit is not None:
|
||||||
dx, dy, fit_w, fit_h = meet_fit
|
dx, dy, fit_w, fit_h = meet_fit
|
||||||
xfrm_attr, off_x, off_y, ext_cx, ext_cy, bounds_emu = _picture_xfrm_from_rect(
|
if ctx.use_transform_matrix:
|
||||||
ctx, x + dx, y + dy, fit_w, fit_h,
|
raw_fit_x = raw_x + dx
|
||||||
|
raw_fit_y = raw_y + dy
|
||||||
|
raw_fit_w = fit_w
|
||||||
|
raw_fit_h = fit_h
|
||||||
|
else:
|
||||||
|
raw_fit_x = raw_x + (dx / ctx.scale_x if ctx.scale_x else dx)
|
||||||
|
raw_fit_y = raw_y + (dy / ctx.scale_y if ctx.scale_y else dy)
|
||||||
|
raw_fit_w = fit_w / ctx.scale_x if ctx.scale_x else fit_w
|
||||||
|
raw_fit_h = fit_h / ctx.scale_y if ctx.scale_y else fit_h
|
||||||
|
xfrm_attr, off_x, off_y, ext_cx, ext_cy, bounds_emu = _picture_xfrm_from_svg_rect(
|
||||||
|
ctx,
|
||||||
|
raw_fit_x,
|
||||||
|
raw_fit_y,
|
||||||
|
raw_fit_w,
|
||||||
|
raw_fit_h,
|
||||||
|
x + dx,
|
||||||
|
y + dy,
|
||||||
|
fit_w,
|
||||||
|
fit_h,
|
||||||
|
transform,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
xfrm_attr, off_x, off_y, ext_cx, ext_cy, bounds_emu = _picture_xfrm_from_rect(
|
xfrm_attr, off_x, off_y, ext_cx, ext_cy, bounds_emu = _picture_xfrm_from_svg_rect(
|
||||||
ctx, x, y, w, h,
|
ctx,
|
||||||
|
raw_x,
|
||||||
|
raw_y,
|
||||||
|
raw_w,
|
||||||
|
raw_h,
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
w,
|
||||||
|
h,
|
||||||
|
transform,
|
||||||
)
|
)
|
||||||
if rot_attr:
|
|
||||||
xfrm_attr += rot_attr
|
|
||||||
|
|
||||||
return ShapeResult(xml=f'''<p:pic>
|
return ShapeResult(xml=f'''<p:pic>
|
||||||
<p:nvPicPr>
|
<p:nvPicPr>
|
||||||
@@ -1942,10 +2260,14 @@ def convert_image(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None:
|
|||||||
|
|
||||||
def convert_ellipse(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None:
|
def convert_ellipse(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None:
|
||||||
"""Convert SVG <ellipse> to DrawingML ellipse shape."""
|
"""Convert SVG <ellipse> to DrawingML ellipse shape."""
|
||||||
cx_ = ctx_x(_f(elem.get('cx')), ctx)
|
raw_cx = _f(elem.get('cx'))
|
||||||
cy_ = ctx_y(_f(elem.get('cy')), ctx)
|
raw_cy = _f(elem.get('cy'))
|
||||||
rx = _f(elem.get('rx')) * ctx.scale_x
|
raw_rx = _f(elem.get('rx'))
|
||||||
ry = _f(elem.get('ry')) * ctx.scale_y
|
raw_ry = _f(elem.get('ry'))
|
||||||
|
cx_ = ctx_x(raw_cx, ctx)
|
||||||
|
cy_ = ctx_y(raw_cy, ctx)
|
||||||
|
rx = raw_rx * ctx.scale_x
|
||||||
|
ry = raw_ry * ctx.scale_y
|
||||||
|
|
||||||
if rx <= 0 or ry <= 0:
|
if rx <= 0 or ry <= 0:
|
||||||
return None
|
return None
|
||||||
@@ -1962,25 +2284,28 @@ def convert_ellipse(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None
|
|||||||
|
|
||||||
geom = '<a:prstGeom prst="ellipse"><a:avLst/></a:prstGeom>'
|
geom = '<a:prstGeom prst="ellipse"><a:avLst/></a:prstGeom>'
|
||||||
|
|
||||||
rot = 0
|
|
||||||
transform = elem.get('transform')
|
transform = elem.get('transform')
|
||||||
if transform:
|
|
||||||
r_match = re.search(r'rotate\(\s*([-\d.]+)', transform)
|
|
||||||
if r_match:
|
|
||||||
rot = int(float(r_match.group(1)) * ANGLE_UNIT)
|
|
||||||
|
|
||||||
shape_id = ctx.next_id()
|
shape_id = ctx.next_id()
|
||||||
off_x = px_to_emu(x)
|
xfrm_attr, off_x, off_y, ext_cx, ext_cy, bounds_emu = _shape_xfrm_from_svg_rect(
|
||||||
off_y = px_to_emu(y)
|
ctx,
|
||||||
ext_cx = px_to_emu(w)
|
raw_cx - raw_rx,
|
||||||
ext_cy = px_to_emu(h)
|
raw_cy - raw_ry,
|
||||||
|
raw_rx * 2,
|
||||||
|
raw_ry * 2,
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
w,
|
||||||
|
h,
|
||||||
|
transform,
|
||||||
|
)
|
||||||
return ShapeResult(
|
return ShapeResult(
|
||||||
xml=_wrap_shape(
|
xml=_wrap_shape(
|
||||||
shape_id, f'Ellipse {shape_id}',
|
shape_id, f'Ellipse {shape_id}',
|
||||||
off_x, off_y, ext_cx, ext_cy,
|
off_x, off_y, ext_cx, ext_cy,
|
||||||
geom, fill, stroke, rot=rot,
|
geom, fill, stroke, xfrm_attr=xfrm_attr,
|
||||||
),
|
),
|
||||||
bounds_emu=(off_x, off_y, off_x + ext_cx, off_y + ext_cy),
|
bounds_emu=bounds_emu,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -2065,6 +2390,10 @@ def convert_nested_svg(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | N
|
|||||||
img_format = 'jpg'
|
img_format = 'jpg'
|
||||||
img_data = img_path.read_bytes()
|
img_data = img_path.read_bytes()
|
||||||
|
|
||||||
|
img_data, img_format = _optimize_image_for_pptx(
|
||||||
|
image_elem, ctx, img_data, img_format, w, h,
|
||||||
|
)
|
||||||
|
|
||||||
img_idx = len(ctx.media_files) + 1
|
img_idx = len(ctx.media_files) + 1
|
||||||
img_filename = f's{ctx.slide_num}_img{img_idx}.{img_format}'
|
img_filename = f's{ctx.slide_num}_img{img_idx}.{img_format}'
|
||||||
ctx.media_files[img_filename] = img_data
|
ctx.media_files[img_filename] = img_data
|
||||||
@@ -2076,20 +2405,21 @@ def convert_nested_svg(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | N
|
|||||||
'target': f'../media/{img_filename}',
|
'target': f'../media/{img_filename}',
|
||||||
})
|
})
|
||||||
|
|
||||||
rot = 0
|
|
||||||
transform = elem.get('transform')
|
transform = elem.get('transform')
|
||||||
if transform and not ctx.use_transform_matrix:
|
|
||||||
r_match = re.search(r'rotate\(\s*([-\d.]+)', transform)
|
|
||||||
if r_match:
|
|
||||||
rot = int(float(r_match.group(1)) * ANGLE_UNIT)
|
|
||||||
rot_attr = f' rot="{rot}"' if rot else ''
|
|
||||||
|
|
||||||
shape_id = ctx.next_id()
|
shape_id = ctx.next_id()
|
||||||
xfrm_attr, off_x, off_y, ext_cx, ext_cy, bounds_emu = _picture_xfrm_from_rect(
|
xfrm_attr, off_x, off_y, ext_cx, ext_cy, bounds_emu = _picture_xfrm_from_svg_rect(
|
||||||
ctx, x, y, w, h,
|
ctx,
|
||||||
|
svg_x,
|
||||||
|
svg_y,
|
||||||
|
svg_w,
|
||||||
|
svg_h,
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
w,
|
||||||
|
h,
|
||||||
|
transform,
|
||||||
)
|
)
|
||||||
if rot_attr:
|
|
||||||
xfrm_attr += rot_attr
|
|
||||||
clip_geom = _resolve_clip_geometry(elem, ctx, svg_x, svg_y, svg_w, svg_h)
|
clip_geom = _resolve_clip_geometry(elem, ctx, svg_x, svg_y, svg_w, svg_h)
|
||||||
|
|
||||||
return ShapeResult(xml=f'''<p:pic>
|
return ShapeResult(xml=f'''<p:pic>
|
||||||
|
|||||||
+30
@@ -624,6 +624,11 @@ def create_pptx_with_native_svg(
|
|||||||
cache_dir: Path | None = None,
|
cache_dir: Path | None = None,
|
||||||
workers: int | None = None,
|
workers: int | None = None,
|
||||||
merge_paragraphs: bool = True,
|
merge_paragraphs: bool = True,
|
||||||
|
image_optimize: bool = True,
|
||||||
|
image_max_dimension: int | None = 2560,
|
||||||
|
image_sizing: str = 'cap',
|
||||||
|
image_scale: float = 2.0,
|
||||||
|
image_quality: int = 85,
|
||||||
conversion_trace_path: Path | None = None,
|
conversion_trace_path: Path | None = None,
|
||||||
doc_metadata: dict[str, Any] | None = None,
|
doc_metadata: dict[str, Any] | None = None,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
@@ -653,6 +658,12 @@ def create_pptx_with_native_svg(
|
|||||||
narration_audio: Optional dict mapping SVG stem to narration audio file.
|
narration_audio: Optional dict mapping SVG stem to narration audio file.
|
||||||
use_narration_timings: Whether to set slide auto-advance from audio duration.
|
use_narration_timings: Whether to set slide auto-advance from audio duration.
|
||||||
narration_padding: Extra seconds added after each narration before advancing.
|
narration_padding: Extra seconds added after each narration before advancing.
|
||||||
|
image_optimize: Whether native export downscales oversized raster images.
|
||||||
|
image_max_dimension: Maximum optimized image dimension in pixels.
|
||||||
|
image_sizing: ``cap`` only limits source dimensions; ``display`` sizes
|
||||||
|
from rendered SVG boxes.
|
||||||
|
image_scale: Target image pixels per SVG display pixel.
|
||||||
|
image_quality: JPEG quality used for opaque optimized rasters.
|
||||||
conversion_trace_path: Optional JSON path for native conversion diagnostics.
|
conversion_trace_path: Optional JSON path for native conversion diagnostics.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
@@ -700,6 +711,20 @@ def create_pptx_with_native_svg(
|
|||||||
print(f" SVG file count: {len(svg_files)}")
|
print(f" SVG file count: {len(svg_files)}")
|
||||||
if use_native_shapes:
|
if use_native_shapes:
|
||||||
print(f" Mode: Native DrawingML shapes (directly editable)")
|
print(f" Mode: Native DrawingML shapes (directly editable)")
|
||||||
|
if image_optimize:
|
||||||
|
if image_sizing == 'display':
|
||||||
|
image_mode = (
|
||||||
|
f"display scale {image_scale:g}, "
|
||||||
|
f"max {image_max_dimension or 'unlimited'} px"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
image_mode = f"cap max {image_max_dimension or 'unlimited'} px"
|
||||||
|
print(
|
||||||
|
" Image optimization: Enabled "
|
||||||
|
f"({image_mode}, JPEG q{image_quality})"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
print(" Image optimization: Disabled")
|
||||||
elif use_compat_mode:
|
elif use_compat_mode:
|
||||||
print(f" Compatibility mode: Enabled (PNG + SVG dual format)")
|
print(f" Compatibility mode: Enabled (PNG + SVG dual format)")
|
||||||
print(f" PNG renderer: {renderer_name} {renderer_status}")
|
print(f" PNG renderer: {renderer_name} {renderer_status}")
|
||||||
@@ -779,6 +804,11 @@ def create_pptx_with_native_svg(
|
|||||||
convert_svg_to_slide_shapes(
|
convert_svg_to_slide_shapes(
|
||||||
svg_path, slide_num=slide_num, verbose=verbose,
|
svg_path, slide_num=slide_num, verbose=verbose,
|
||||||
merge_paragraphs=merge_paragraphs,
|
merge_paragraphs=merge_paragraphs,
|
||||||
|
image_optimize=image_optimize,
|
||||||
|
image_max_dimension=image_max_dimension,
|
||||||
|
image_sizing=image_sizing,
|
||||||
|
image_scale=image_scale,
|
||||||
|
image_quality=image_quality,
|
||||||
trace_out=conversion_trace,
|
trace_out=conversion_trace,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -189,6 +189,16 @@ Recorded narration:
|
|||||||
'canonical output; live preview already provides the SVG visual reference. '
|
'canonical output; live preview already provides the SVG visual reference. '
|
||||||
'Note: the svg_output/ source snapshot is always written to backup/<ts>/ '
|
'Note: the svg_output/ source snapshot is always written to backup/<ts>/ '
|
||||||
'regardless of this flag.')
|
'regardless of this flag.')
|
||||||
|
parser.add_argument('--no-image-optimize', action='store_true',
|
||||||
|
help='Disable native PPTX raster image optimization; embeds original image bytes.')
|
||||||
|
parser.add_argument('--image-max-dimension', type=int, default=2560,
|
||||||
|
help='Maximum optimized raster image dimension in pixels (default: 2560).')
|
||||||
|
parser.add_argument('--image-sizing', choices=['cap', 'display'], default='cap',
|
||||||
|
help='Raster sizing mode: cap only limits source dimensions; display sizes from the SVG rendered box (default: cap).')
|
||||||
|
parser.add_argument('--image-scale', type=float, default=2.0,
|
||||||
|
help='Target optimized image pixels per SVG display pixel when --image-sizing=display (default: 2.0).')
|
||||||
|
parser.add_argument('--image-quality', type=int, default=85,
|
||||||
|
help='JPEG quality for optimized opaque raster images, 1-100 (default: 85).')
|
||||||
|
|
||||||
def non_negative_float(value: str) -> float:
|
def non_negative_float(value: str) -> float:
|
||||||
try:
|
try:
|
||||||
@@ -262,6 +272,15 @@ Recorded narration:
|
|||||||
if not project_path.exists():
|
if not project_path.exists():
|
||||||
print(f"Error: Path does not exist: {project_path}")
|
print(f"Error: Path does not exist: {project_path}")
|
||||||
return 1
|
return 1
|
||||||
|
if args.image_max_dimension < 1:
|
||||||
|
print("Error: --image-max-dimension must be >= 1", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
if args.image_scale < 1:
|
||||||
|
print("Error: --image-scale must be >= 1", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
if not 1 <= args.image_quality <= 100:
|
||||||
|
print("Error: --image-quality must be between 1 and 100", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
try:
|
try:
|
||||||
project_info = get_project_info(str(project_path))
|
project_info = get_project_info(str(project_path))
|
||||||
@@ -567,6 +586,11 @@ Recorded narration:
|
|||||||
cache_dir=cache_dir,
|
cache_dir=cache_dir,
|
||||||
workers=args.workers,
|
workers=args.workers,
|
||||||
merge_paragraphs=args.merge_paragraphs,
|
merge_paragraphs=args.merge_paragraphs,
|
||||||
|
image_optimize=not args.no_image_optimize,
|
||||||
|
image_max_dimension=args.image_max_dimension,
|
||||||
|
image_sizing=args.image_sizing,
|
||||||
|
image_scale=args.image_scale,
|
||||||
|
image_quality=args.image_quality,
|
||||||
)
|
)
|
||||||
|
|
||||||
success = True
|
success = True
|
||||||
|
|||||||
@@ -266,6 +266,7 @@ Catalog read: 71 templates
|
|||||||
- `Background` — cover / chapter / full-bleed atmosphere
|
- `Background` — cover / chapter / full-bleed atmosphere
|
||||||
- `Photography` — real-world photo
|
- `Photography` — real-world photo
|
||||||
- `Illustration` — vector / flat / painterly art
|
- `Illustration` — vector / flat / painterly art
|
||||||
|
- `Illustration Sheet` — a grid of several spot illustrations generated as one image to be sliced (the `ai` sheet row of a `slice` set; never placed itself)
|
||||||
- `Diagram` — schematic / architecture / flowchart
|
- `Diagram` — schematic / architecture / flowchart
|
||||||
- `Portrait` — single-subject person
|
- `Portrait` — single-subject person
|
||||||
- `Latex Formula` — formula PNG rendered by `latex_render.py`
|
- `Latex Formula` — formula PNG rendered by `latex_render.py`
|
||||||
@@ -284,6 +285,9 @@ Catalog read: 71 templates
|
|||||||
- `formula` — already rendered by `latex_render.py` before this spec was written
|
- `formula` — already rendered by `latex_render.py` before this spec was written
|
||||||
- `user` — user-supplied
|
- `user` — user-supplied
|
||||||
- `placeholder` — intentionally deferred
|
- `placeholder` — intentionally deferred
|
||||||
|
- `slice` — a spot-illustration element derived in Step 5 by cutting it out of an `ai` sheet row (not generated on its own)
|
||||||
|
|
||||||
|
> **Spot-illustration sheets (`slice`).** When the deck draws several same-family spot illustrations from one generated sheet (see [`image-generator.md`](../references/image-generator.md) §4.3), write **two kinds of rows**: one **sheet row** (`Acquire Via: ai`, `Type: Illustration Sheet`, name the intended cell shape + placement purpose in `Reference`, e.g. `portrait side-accent spot set` or `landscape footer-vignette spot set`) that is generated but **never placed** — keep it out of `spec_lock.md images`; and one **element row per used element** (`Acquire Via: slice`, `Reference` naming the parent sheet + cell/element, dimensions filled after slicing) that **is** placed — list every element row in `spec_lock.md images` so the Executor may reference it. Strategist states the shape intent; Image_Generator chooses the exact sheet ratio, grid, and slice command. An element row with no sheet row, or a sliced file absent from `spec_lock.md images`, is an invalid spec. **Each element row's Layout pattern must come from the decorative-cutout family** (`#63` sticker, `#4` edge bleed, `#58` corner fragment, `#66` fade-out, `#69` rotation, `#49` cluster) — a transparent spot is an accessory placed at the margins / off-edge / behind text, never centered in a boxed tile.
|
||||||
|
|
||||||
**text_policy** (`ai` rows only; AI judges per row, no global default bias):
|
**text_policy** (`ai` rows only; AI judges per row, no global default bias):
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ Generate a complete set of reusable PPT templates for the **global template libr
|
|||||||
|
|
||||||
> This workflow is for **library asset creation**, not project-level one-off customization. The output must be reusable by future PPT projects and discoverable from the appropriate index file.
|
> This workflow is for **library asset creation**, not project-level one-off customization. The output must be reusable by future PPT projects and discoverable from the appropriate index file.
|
||||||
|
|
||||||
|
> **Boundary against template-fill**: this workflow does not fill content into a PPTX and does not directly output the user's final generated deck. It creates a reusable `templates/<kind>/<id>/` package. To generate a deck from that package, the user must provide the resulting template directory path to the main SKILL.md pipeline.
|
||||||
|
|
||||||
> **Companion workflow**: identity-only locking (colors / typography / logo / voice without SVG pages) is handled by [`create-brand.md`](./create-brand.md). Use that when the user wants brand identity but free page layout; use this when fixed page structures are required.
|
> **Companion workflow**: identity-only locking (colors / typography / logo / voice without SVG pages) is handled by [`create-brand.md`](./create-brand.md). Use that when the user wants brand identity but free page layout; use this when fixed page structures are required.
|
||||||
|
|
||||||
## Kind decision — deck (default) vs layout
|
## Kind decision — deck (default) vs layout
|
||||||
@@ -26,7 +28,7 @@ Default to **deck** unless the user explicitly says "structure only" / "layout o
|
|||||||
## Process Overview
|
## Process Overview
|
||||||
|
|
||||||
```
|
```
|
||||||
Reference Intake & Analysis -> Fact-Based Brief Proposal -> User Confirmation Gate -> Create Directory + Invoke Template_Designer -> Validate Assets -> Register Index -> Output
|
Reference Intake & Analysis -> Basic Norm Extraction -> Fact-Based Brief Proposal -> User Confirmation Gate -> Create Directory + Invoke Template_Designer -> Validate Assets -> Register Index -> Output
|
||||||
```
|
```
|
||||||
|
|
||||||
The first three steps derive the brief from facts, not guesses. **No final template directory may be created and no template SVG / `design_spec.md` may be written until `[TEMPLATE_BRIEF_CONFIRMED]` is emitted in Step 3.** Reference-analysis intermediates produced by `pptx_template_import.py` (typically under `/tmp/pptx_template_import/`) are explicitly **not** subject to this gate — they are temporary workspaces feeding Step 2.
|
The first three steps derive the brief from facts, not guesses. **No final template directory may be created and no template SVG / `design_spec.md` may be written until `[TEMPLATE_BRIEF_CONFIRMED]` is emitted in Step 3.** Reference-analysis intermediates produced by `pptx_template_import.py` (typically under `/tmp/pptx_template_import/`) are explicitly **not** subject to this gate — they are temporary workspaces feeding Step 2.
|
||||||
@@ -35,7 +37,7 @@ The first three steps derive the brief from facts, not guesses. **No final templ
|
|||||||
|
|
||||||
## Step 1: Reference Intake & Analysis
|
## Step 1: Reference Intake & Analysis
|
||||||
|
|
||||||
Branch by the type of reference source the user supplied. This step produces analysis artefacts only — it does **not** create the final template directory, write `design_spec.md`, or touch `layouts_index.json`.
|
Branch by the type of reference source the user supplied. This step produces analysis artefacts only — it does **not** create the final template directory, write `design_spec.md`, or touch any template index.
|
||||||
|
|
||||||
### Input source taxonomy
|
### Input source taxonomy
|
||||||
|
|
||||||
@@ -51,6 +53,8 @@ Branch by the type of reference source the user supplied. This step produces ana
|
|||||||
- **mirror on type B** — direct 1:1 copy. B's SVGs are already self-contained (one file per page, equivalent to `svg-flat/slide_*.svg`). Page-type for the `<NNN>_<page_type>.svg` filename is read from the source filename when it follows the PPT Master naming convention (`01_cover.svg` → `cover`, `03a_content_two_col.svg` → `content`); fall back to `content` otherwise. Particularly natural when the source is `templates/layouts/<existing>` and the user wants to fork an existing template.
|
- **mirror on type B** — direct 1:1 copy. B's SVGs are already self-contained (one file per page, equivalent to `svg-flat/slide_*.svg`). Page-type for the `<NNN>_<page_type>.svg` filename is read from the source filename when it follows the PPT Master naming convention (`01_cover.svg` → `cover`, `03a_content_two_col.svg` → `content`); fall back to `content` otherwise. Particularly natural when the source is `templates/layouts/<existing>` and the user wants to fork an existing template.
|
||||||
- **fidelity on type B** — clustering relies on the AI's visual judgement of the SVGs; there is no `manifest.json.pageTypeCandidates` to anchor it. Variant count and grouping are more subjective and may need iteration. If the input is already a PPT Master template (`templates/layouts/<existing>`), parse the existing variant filenames (`03a_content_two_col` etc.) as authoritative cluster hints rather than re-clustering visually.
|
- **fidelity on type B** — clustering relies on the AI's visual judgement of the SVGs; there is no `manifest.json.pageTypeCandidates` to anchor it. Variant count and grouping are more subjective and may need iteration. If the input is already a PPT Master template (`templates/layouts/<existing>`), parse the existing variant filenames (`03a_content_two_col` etc.) as authoritative cluster hints rather than re-clustering visually.
|
||||||
|
|
||||||
|
**Replication mode boundary**: `standard` / `fidelity` / `mirror` are construction choices for this workflow only. They decide how the template package is created from the reference source; they do not create a downstream generation route, and `mirror` must not force future decks to preserve the source page count or page order.
|
||||||
|
|
||||||
### 1A. `.pptx` reference
|
### 1A. `.pptx` reference
|
||||||
|
|
||||||
Run the unified preparation helper:
|
Run the unified preparation helper:
|
||||||
@@ -89,7 +93,7 @@ Before the Template_Designer reads imported SVGs, factor large decorative vector
|
|||||||
# standard / fidelity analysis path
|
# standard / fidelity analysis path
|
||||||
python3 skills/ppt-master/scripts/extract_svg_assets.py "<import_workspace>/svg" --icons-dir "<import_workspace>/icons" --inplace --id-prefix layered --min-decoration-bytes 3000 --clean-stale
|
python3 skills/ppt-master/scripts/extract_svg_assets.py "<import_workspace>/svg" --icons-dir "<import_workspace>/icons" --inplace --id-prefix layered --min-decoration-bytes 3000 --clean-stale
|
||||||
|
|
||||||
# mirror path
|
# mirror creation path
|
||||||
python3 skills/ppt-master/scripts/extract_svg_assets.py "<import_workspace>/svg-flat" --icons-dir "<import_workspace>/icons" --inplace --id-prefix flat --min-decoration-bytes 3000 --clean-stale
|
python3 skills/ppt-master/scripts/extract_svg_assets.py "<import_workspace>/svg-flat" --icons-dir "<import_workspace>/icons" --inplace --id-prefix flat --min-decoration-bytes 3000 --clean-stale
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -116,6 +120,22 @@ Interpretation rule (carries forward into Steps 2 and 4):
|
|||||||
- cleaned `svg-flat/slide_NN.svg` is for human preview and screenshot comparison; do not treat duplicated master/layout chrome inside flat slides as separate reusable template structure.
|
- cleaned `svg-flat/slide_NN.svg` is for human preview and screenshot comparison; do not treat duplicated master/layout chrome inside flat slides as separate reusable template structure.
|
||||||
- screenshots remain useful for judging composition and style, but should not override extracted factual metadata unless the import result is clearly incomplete
|
- screenshots remain useful for judging composition and style, but should not override extracted factual metadata unless the import result is clearly incomplete
|
||||||
|
|
||||||
|
### Basic norm extraction (mandatory when reference content exists)
|
||||||
|
|
||||||
|
Before composing Step 2, extract the template's reusable norms from the previous content. These norms are not generic design advice; they are the source deck's observable operating rules, and they must flow into `design_spec.md`.
|
||||||
|
|
||||||
|
| Norm area | Extract from | Record as |
|
||||||
|
|---|---|---|
|
||||||
|
| Canvas / page geometry | `manifest.json` slide size, SVG `width` / `height` / `viewBox` | `[fact]` canvas format, pixel dimensions, source `viewBox`, and aspect ratio |
|
||||||
|
| Identity system | theme colors, font usage, logo / emblem assets, recurring backgrounds | `[fact]` when imported; `[suggested]` only for visual estimates |
|
||||||
|
| Layout grammar | masters / layouts, repeated chrome, margins, columns, card grids, section dividers | Template-specific rules, not generic spacing boilerplate |
|
||||||
|
| Image system | image crops, masks, full-bleed zones, hero-image placement, mosaic rules, caption / overlay treatment | Template-specific image-placement rules with source examples |
|
||||||
|
| Density rhythm | title scale, content block count, whitespace balance, dense vs. breathing pages | Page-type guidance for Strategist / Executor |
|
||||||
|
| Page roster semantics | cover / TOC / chapter / content / ending variants and their intended content slots | `design_spec.md §V Page Roster` rows |
|
||||||
|
| Asset policy | source images / icons / textures that are part of the template vs. sample-only content | `design_spec.md §VI Assets` or omit sample-only assets |
|
||||||
|
|
||||||
|
Distinguish observed facts from template rules: "`slide_07` uses a left photo crop" is a fact; "content pages may use a left photo rail for location / product / case-study pages" is the reusable rule.
|
||||||
|
|
||||||
**Hard read gate** (`standard` / `fidelity` modes — `mirror` differs, see below):
|
**Hard read gate** (`standard` / `fidelity` modes — `mirror` differs, see below):
|
||||||
|
|
||||||
- The agent MUST finish reading every cleaned `master_*.svg`, `layout_*.svg`, and `slide_*.svg` file from the layered `svg/` view before moving on to Step 2
|
- The agent MUST finish reading every cleaned `master_*.svg`, `layout_*.svg`, and `slide_*.svg` file from the layered `svg/` view before moving on to Step 2
|
||||||
@@ -173,19 +193,34 @@ Compose a single message that surfaces every Required brief item to the user, **
|
|||||||
- **`[suggested]`** — AI-inferred from analysis or context (e.g. tone summary, applicable scenarios; visually estimated values from type C)
|
- **`[suggested]`** — AI-inferred from analysis or context (e.g. tone summary, applicable scenarios; visually estimated values from type C)
|
||||||
- **`[decision]`** — pure user choice, no analysis substitute (e.g. `template_id`, `replication mode`, `category`)
|
- **`[decision]`** — pure user choice, no analysis substitute (e.g. `template_id`, `replication mode`, `category`)
|
||||||
|
|
||||||
|
**Language adaptation rule**: write the Step 2 brief in the user's language. For technical enum values, show the localized label first and keep the English ID in parentheses only when needed for precision, e.g. `完整套版(deck)`, `结构版式(layout)`, `逐页采样(mirror)`. Do not assume users know what each English word means.
|
||||||
|
|
||||||
|
**Option visibility rule**: for every field with a finite option set, show both the recommended value and the other valid options. Do not present a single recommended value as if no alternatives exist. If an option is unavailable for the current input type, list it under `Unavailable` with the reason.
|
||||||
|
|
||||||
|
| Field | Must show |
|
||||||
|
|---|---|
|
||||||
|
| Template kind | Recommended localized label with English ID, plus both options and the rule for choosing |
|
||||||
|
| Category | Recommended localized category with English ID, plus `brand` / `general` / `scenario` / `government` / `special` with localized explanations |
|
||||||
|
| Theme mode | Recommended localized mode with English ID, plus available modes such as `light` / `dark` / `mixed` with localized explanations |
|
||||||
|
| Canvas format | Recommended canvas, plus other supported formats from [`canvas-formats.md`](../references/canvas-formats.md) that fit the source aspect ratio or user intent. Always show the concrete pixel size and `viewBox`; do not treat two same-ratio formats such as `ppt169` (`1280x720`) and `banner` (`1920x1080`) as interchangeable. |
|
||||||
|
| Replication mode | Recommended localized mode with English ID, plus all modes available for the current input type; list forbidden modes for type C / D as unavailable with localized reasons |
|
||||||
|
| Visual fidelity for fixed pages | Recommended localized choice with English ID, plus both `literal` / `adapted` options when applicable |
|
||||||
|
| Asset bundling | Recommended included assets, plus excluded candidate assets with a one-line reason when reference assets exist |
|
||||||
|
|
||||||
Items to surface:
|
Items to surface:
|
||||||
|
|
||||||
| Item | Required | Provenance by input type |
|
| Item | Required | Provenance by input type |
|
||||||
|------|----------|--------------------------|
|
|------|----------|--------------------------|
|
||||||
| New template ID | Yes | `[decision]` — user chooses ASCII slug; if Chinese brand name, must be filesystem-safe and match `layouts_index.json` exactly |
|
| New template ID | Yes | `[decision]` — user chooses ASCII slug; if Chinese brand name, must be filesystem-safe and match the target kind's index exactly |
|
||||||
| Template display name | Yes | `[decision]` (often the source deck title — `[suggested]` from `summary.md` for type A) |
|
| Template display name | Yes | `[decision]` (often the source deck title — `[suggested]` from `summary.md` for type A) |
|
||||||
| Category | Yes | `[decision]` — one of `brand` / `general` / `scenario` / `government` / `special` |
|
| Category | Yes | `[decision]` — one of `brand` / `general` / `scenario` / `government` / `special` |
|
||||||
| Applicable scenarios | Yes | `[suggested]` from analysis; user confirms |
|
| Applicable scenarios | Yes | `[suggested]` from analysis; user confirms |
|
||||||
| Tone summary | Yes | `[suggested]` from analysis (e.g. `Modern, restrained, data-driven`) |
|
| Tone summary | Yes | `[suggested]` from analysis (e.g. `Modern, restrained, data-driven`) |
|
||||||
| Theme mode | Yes | A: `[fact]` from `manifest.json` background colors. B: `[fact]` from SVG `fill`. C: `[suggested]` from visual estimate. D: `[decision]` |
|
| Theme mode | Yes | A: `[fact]` from `manifest.json` background colors. B: `[fact]` from SVG `fill`. C: `[suggested]` from visual estimate. D: `[decision]` |
|
||||||
| Canvas format | Yes | A/B: `[fact]` from slide size or SVG `viewBox`. C: `[suggested]` from image aspect ratio. D: `[decision]`, default `ppt169` |
|
| Canvas format and dimensions | Yes | A/B: `[fact]` from slide size or SVG `width` / `height` / `viewBox`; show `canvas_format`, `canvas_width`, `canvas_height`, `canvas_viewbox`, and `source_viewbox`. C: `[suggested]` from image aspect ratio. D: `[decision]`, default `ppt169` (`1280x720`, `0 0 1280 720`) |
|
||||||
| Replication mode | Yes | `[decision]` — `standard` always available; `fidelity` and `mirror` available for type A (canonical, manifest-anchored) and type B (AI visual clustering / direct 1:1 copy — see Step 1 caveats); reject `fidelity` / `mirror` upfront for type C / D |
|
| Replication mode | Yes | `[decision]` — `standard` always available; `fidelity` and `mirror` available for type A (canonical, manifest-anchored) and type B (AI visual clustering / direct 1:1 copy — see Step 1 caveats); reject `fidelity` / `mirror` upfront for type C / D |
|
||||||
| Visual fidelity for fixed pages | Yes for `standard` / `fidelity` when reference exists; **N/A for `mirror`** (mirror is implicitly literal) | `[decision]` — `literal` (preserve original geometry / decoration / sprite crops as-is; for cover / chapter / ending especially) or `adapted` (use the reference for tone/structure but allow design evolution). Different page types may take different settings |
|
| Visual fidelity for fixed pages | Yes for `standard` / `fidelity` when reference exists; **N/A for `mirror`** (mirror is implicitly literal) | `[decision]` — `literal` (preserve original geometry / decoration / sprite crops as-is; for cover / chapter / ending especially) or `adapted` (use the reference for tone/structure but allow design evolution). Different page types may take different settings |
|
||||||
|
| Basic template norms | Yes when reference exists | `[fact]` / `[suggested]` — layout grammar, image system, density rhythm, page roster semantics, and asset policy extracted in Step 1 |
|
||||||
| Reference source | Optional | already known if Step 1 ran |
|
| Reference source | Optional | already known if Step 1 ran |
|
||||||
| Theme color | Optional | A: `[fact]` from theme XML. B: `[fact]` from dominant SVG `fill`. C: `[suggested]` from visual estimate (HEX is approximate). D: `[decision]` |
|
| Theme color | Optional | A: `[fact]` from theme XML. B: `[fact]` from dominant SVG `fill`. C: `[suggested]` from visual estimate (HEX is approximate). D: `[decision]` |
|
||||||
| Fonts | Optional | A: `[fact]` from `manifest.json`. B: `[fact]` from SVG `font-family`. C / D: not derivable — `[decision]` if user wants a custom stack |
|
| Fonts | Optional | A: `[fact]` from `manifest.json`. B: `[fact]` from SVG `font-family`. C / D: not derivable — `[decision]` if user wants a custom stack |
|
||||||
@@ -200,7 +235,7 @@ For type A, also include in this message:
|
|||||||
|
|
||||||
The user replies with corrections, additions, or "all good".
|
The user replies with corrections, additions, or "all good".
|
||||||
|
|
||||||
> **Persist the brief into `design_spec.md`**. When the Template_Designer writes `design_spec.md` in Step 4, declare a YAML frontmatter block at the top with the confirmed brief (`template_id`, `category`, `summary`, `keywords`, `primary_color`, `canvas_format`, `replication_mode`, etc.). `register_template.py` reads this in Step 6, so the brief flows directly into the index without the AI re-deriving it from prose. See Step 6 for the recommended frontmatter shape.
|
> **Persist the brief into `design_spec.md`**. When the Template_Designer writes `design_spec.md` in Step 4, declare a YAML frontmatter block at the top with the confirmed brief (`template_id`, `category`, `summary`, `keywords`, `primary_color`, `canvas_format`, `canvas_width`, `canvas_height`, `canvas_viewbox`, `source_viewbox`, `replication_mode`, etc.). `register_template.py` reads this in Step 6, so the brief flows directly into the index without the AI re-deriving it from prose. See Step 6 for the recommended frontmatter shape.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -216,11 +251,14 @@ Skipping this gate — including silently inferring values from the reference so
|
|||||||
**Required outcome of Step 3** (all must be true before emitting `[TEMPLATE_BRIEF_CONFIRMED]`):
|
**Required outcome of Step 3** (all must be true before emitting `[TEMPLATE_BRIEF_CONFIRMED]`):
|
||||||
|
|
||||||
- [ ] User has been shown every Required item in Step 2 with provenance labels
|
- [ ] User has been shown every Required item in Step 2 with provenance labels
|
||||||
|
- [ ] Every finite-option field has shown a recommended value, other available options, and unavailable options with reasons when applicable
|
||||||
|
- [ ] User-facing labels and option explanations match the user's language; English enum IDs appear only as precision aids
|
||||||
- [ ] User has replied with values or explicit acceptance of suggested defaults
|
- [ ] User has replied with values or explicit acceptance of suggested defaults
|
||||||
- [ ] The template is clearly positioned as a **global library template**
|
- [ ] The template is clearly positioned as a **global library template**
|
||||||
- [ ] The canvas format is fixed before SVG generation
|
- [ ] The canvas format is fixed before SVG generation
|
||||||
- [ ] Replication mode is consistent with the input type (`fidelity` / `mirror` allowed for A and B with B's caveats noted; forbidden for C / D)
|
- [ ] Replication mode is consistent with the input type (`fidelity` / `mirror` allowed for A and B with B's caveats noted; forbidden for C / D)
|
||||||
- [ ] The template metadata is complete enough to register into `layouts_index.json`
|
- [ ] Basic template norms from prior content have been surfaced and accepted, or explicitly marked N/A when no reference exists
|
||||||
|
- [ ] Metadata is complete enough to register into the relevant index
|
||||||
- [ ] Marker `[TEMPLATE_BRIEF_CONFIRMED]` emitted on its own line after the echoed brief
|
- [ ] Marker `[TEMPLATE_BRIEF_CONFIRMED]` emitted on its own line after the echoed brief
|
||||||
|
|
||||||
Step 4 MUST NOT run until `[TEMPLATE_BRIEF_CONFIRMED]` has been emitted in the current conversation.
|
Step 4 MUST NOT run until `[TEMPLATE_BRIEF_CONFIRMED]` has been emitted in the current conversation.
|
||||||
@@ -234,14 +272,12 @@ Step 4 MUST NOT run until `[TEMPLATE_BRIEF_CONFIRMED]` has been emitted in the c
|
|||||||
Create the final template directory:
|
Create the final template directory:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
mkdir -p "skills/ppt-master/templates/layouts/<template_id>"
|
mkdir -p "skills/ppt-master/templates/<kind_dir>/<template_id>"
|
||||||
```
|
```
|
||||||
|
|
||||||
> **Output location**: Global templates go to `skills/ppt-master/templates/layouts/`; project templates go to `projects/<project>/templates/`
|
> **Output location**: Templates go to `skills/ppt-master/templates/<kind>/<template_id>/`. The generated directory name must match the final template ID used in the relevant index.
|
||||||
>
|
|
||||||
> The generated directory name must match the final template ID used in `layouts_index.json`.
|
|
||||||
|
|
||||||
**Switch to the Template_Designer role** and generate per role definition. The role input is the finalized brief from Step 3 plus the analysis bundle from Step 1.
|
**Switch to the Template_Designer role** and generate per role definition. The role input is the finalized brief from Step 3 plus the analysis bundle from Step 1, including the accepted basic template norms.
|
||||||
|
|
||||||
If the input source is type A, pass the following internal package to the role:
|
If the input source is type A, pass the following internal package to the role:
|
||||||
|
|
||||||
@@ -257,7 +293,7 @@ For type B, pass the cleaned SVG file list from the analysis workspace, `*_vecto
|
|||||||
For type C, pass the image file list and the visual analysis notes.
|
For type C, pass the image file list and the visual analysis notes.
|
||||||
For type D, pass only the finalized brief.
|
For type D, pass only the finalized brief.
|
||||||
|
|
||||||
The role uses the analysis bundle to anchor objective facts such as theme colors, fonts, reusable backgrounds, and common branding assets, then rebuilds the final SVG templates in a simplified, maintainable form.
|
The role uses the analysis bundle to anchor objective facts such as theme colors, fonts, reusable backgrounds, common branding assets, layout grammar, image-placement rules, density rhythm, and page-slot semantics, then rebuilds the final SVG templates in a simplified, maintainable form.
|
||||||
|
|
||||||
**Apply the visual-fidelity decision from Step 3**: pages marked `literal` (typically cover / chapter / ending) must reproduce the reference's geometry, decoration, and sprite-sheet crops as-is — "simplified, maintainable form" applies only to genuinely redundant structure, not to load-bearing layout. Pages marked `adapted` may use the reference for tone and structural rhythm but evolve the design.
|
**Apply the visual-fidelity decision from Step 3**: pages marked `literal` (typically cover / chapter / ending) must reproduce the reference's geometry, decoration, and sprite-sheet crops as-is — "simplified, maintainable form" applies only to genuinely redundant structure, not to load-bearing layout. Pages marked `adapted` may use the reference for tone and structural rhythm but evolve the design.
|
||||||
|
|
||||||
@@ -275,7 +311,7 @@ The role uses the analysis bundle to anchor objective facts such as theme colors
|
|||||||
- Type A: assets come from `<import_workspace>/assets/`
|
- Type A: assets come from `<import_workspace>/assets/`
|
||||||
- Type B: resolve relative paths in source `<image href="...">` against the source SVG location and copy each unique asset; if the source already follows PPT Master conventions (assets co-located with SVGs in the same directory), copy the whole asset set and then rewrite paths
|
- Type B: resolve relative paths in source `<image href="...">` against the source SVG location and copy each unique asset; if the source already follows PPT Master conventions (assets co-located with SVGs in the same directory), copy the whole asset set and then rewrite paths
|
||||||
4. **Copies `icons/` when present** and preserves every extracted `<use data-icon="..."/>` reference. Do not inline these assets manually in the template working SVGs; the shared icon embedding path owns re-inlining before export.
|
4. **Copies `icons/` when present** and preserves every extracted `<use data-icon="..."/>` reference. Do not inline these assets manually in the template working SVGs; the shared icon embedding path owns re-inlining before export.
|
||||||
5. Writes `design_spec.md` per [template-designer.md](../references/template-designer.md) §1 — the **§V Page Roster description per page is the load-bearing artifact** because mirror has no placeholders to advertise the per-page contract; downstream Strategist selects pages purely from these descriptions.
|
5. Writes `design_spec.md` per [template-designer.md](../references/template-designer.md) §1 — the **§V Page Roster description per page is the load-bearing artifact** because mirror has no placeholders to advertise the per-page contract. Mirror is only the template-creation replication mode; downstream generation still treats the finished template package as a selectable / reusable roster, not as a forced 1:1 slide sequence.
|
||||||
|
|
||||||
Mirror mode does **not** invoke the "reconstruct into clean SVG" pathway. The sprite-sheet preservation rule still applies (because the flat SVGs already contain the original sprite wrappers — do not flatten them when copying). Vector illustration placeholders are the only allowed maintainability rewrite.
|
Mirror mode does **not** invoke the "reconstruct into clean SVG" pathway. The sprite-sheet preservation rule still applies (because the flat SVGs already contain the original sprite wrappers — do not flatten them when copying). Vector illustration placeholders are the only allowed maintainability rewrite.
|
||||||
|
|
||||||
@@ -305,23 +341,24 @@ python3 skills/ppt-master/scripts/svg_quality_checker.py "skills/ppt-master/temp
|
|||||||
|
|
||||||
- glob `*.svg` in the template directory directly (templates do not live under `svg_output/`)
|
- glob `*.svg` in the template directory directly (templates do not live under `svg_output/`)
|
||||||
- skip `spec_lock.md` drift checks (templates do not ship a spec_lock)
|
- skip `spec_lock.md` drift checks (templates do not ship a spec_lock)
|
||||||
- enforce roster ↔ `design_spec.md` consistency as **errors** (orphan files / missing files break `layouts_index.json`)
|
- enforce roster ↔ `design_spec.md` consistency as **errors** (orphan files / missing files break the target kind's index)
|
||||||
- emit advisory **warnings** when a page lacks a conventional placeholder — these are hints, not failures. Declare a `placeholders:` block in `design_spec.md` frontmatter to silence them when your template intentionally uses a different vocabulary
|
- emit advisory **warnings** when a page lacks a conventional placeholder — these are hints, not failures. Declare a `placeholders:` block in `design_spec.md` frontmatter to silence them when your template intentionally uses a different vocabulary
|
||||||
|
|
||||||
**Checklist**:
|
**Checklist**:
|
||||||
|
|
||||||
- [ ] `design_spec.md` follows the personality-only skeleton (Overview / Color / Signature / Page Roster); generic constraints (SVG rules, pattern libraries, ratio bands, canonical placeholder table) are NOT restated. §V Page Roster lists every emitted page
|
- [ ] `design_spec.md` follows the personality-only skeleton (Overview / Color / Signature / Page Roster); generic constraints (SVG rules, pattern libraries, ratio bands, canonical placeholder table) are NOT restated. The source-derived basic norms are present as template-specific layout / image / density / asset rules, not generic advice. §V Page Roster lists every emitted page
|
||||||
- [ ] Every page declared in `design_spec.md §V Page Roster` exists as an SVG file in the template directory (and vice versa — no orphan files)
|
- [ ] Every page declared in `design_spec.md §V Page Roster` exists as an SVG file in the template directory (and vice versa — no orphan files)
|
||||||
- [ ] Variant filenames follow the letter-suffix convention (e.g. `03a_content_two_col.svg`); variants typically reuse the parent type's placeholder set unless the spec frontmatter declares otherwise
|
- [ ] Variant filenames follow the letter-suffix convention (e.g. `03a_content_two_col.svg`); variants typically reuse the parent type's placeholder set unless the spec frontmatter declares otherwise
|
||||||
- [ ] If TOC exists, placeholder pattern uses the canonical indexed form
|
- [ ] If TOC exists, placeholder pattern uses the canonical indexed form
|
||||||
- [ ] SVG viewBox matches the chosen canvas format (for `ppt169`: `0 0 1280 720`)
|
- [ ] `design_spec.md` frontmatter declares `canvas_format`, `canvas_width`, `canvas_height`, and `canvas_viewbox`; PPTX/SVG-backed templates also declare `source_canvas_width`, `source_canvas_height`, and `source_viewbox`
|
||||||
|
- [ ] SVG `width` / `height` / `viewBox` match the declared canvas dimensions, not just the aspect ratio (for `ppt169`: `1280x720`, `0 0 1280 720`; for `banner`: `1920x1080`, `0 0 1920 1080`)
|
||||||
- [ ] Placeholder names follow the canonical convention where applicable; templates with intentionally different vocabularies (e.g. `{{KEY_MESSAGE}}` instead of `{{PAGE_TITLE}}`) should declare a `placeholders:` frontmatter block to silence advisory warnings
|
- [ ] Placeholder names follow the canonical convention where applicable; templates with intentionally different vocabularies (e.g. `{{KEY_MESSAGE}}` instead of `{{PAGE_TITLE}}`) should declare a `placeholders:` frontmatter block to silence advisory warnings
|
||||||
- [ ] Asset files referenced by SVGs actually exist in the template package
|
- [ ] Asset files referenced by SVGs actually exist in the template package
|
||||||
- [ ] If any SVG references an extracted vector `data-icon`, the corresponding SVG asset exists directly under the package's `icons/` directory; do not add a separate illustration embedding script
|
- [ ] If any SVG references an extracted vector `data-icon`, the corresponding SVG asset exists directly under the package's `icons/` directory; do not add a separate illustration embedding script
|
||||||
- [ ] For `fidelity` mode: every sprite-sheet asset retains its nested `<svg viewBox=...>` crop wrapper; no image whose file aspect differs from its on-page aspect was flattened to a bare `<image>`
|
- [ ] For `fidelity` mode: every sprite-sheet asset retains its nested `<svg viewBox=...>` crop wrapper; no image whose file aspect differs from its on-page aspect was flattened to a bare `<image>`
|
||||||
- [ ] For `mirror` mode: file count equals source page count (type A: `ls templates/layouts/<id>/*_*.svg | wc -l` matches the cleaned flat `<import_workspace>/svg-flat/slide_*.svg | wc -l`; type B: matches the source SVG count); filenames follow the `<NNN>_<page_type>.svg` convention; **no `{{...}}` placeholder strings appear in any copied SVG** (`grep -l "{{" templates/layouts/<id>/*.svg` should return nothing — if the type B source itself contains placeholders, the user should be in `standard` mode, not `mirror`); §V Page Roster in `design_spec.md` lists every emitted file with a one-line description of what the page contains and what content slot it suits
|
- [ ] For `mirror` mode: file count equals source page count (type A: `ls templates/<kind_dir>/<id>/*_*.svg | wc -l` matches the cleaned flat `<import_workspace>/svg-flat/slide_*.svg | wc -l`; type B: matches the source SVG count); filenames follow the `<NNN>_<page_type>.svg` convention; **no `{{...}}` placeholder strings appear in any copied SVG** (`grep -l "{{" templates/<kind_dir>/<id>/*.svg` should return nothing — if the type B source itself contains placeholders, the user should be in `standard` mode, not `mirror`); §V Page Roster in `design_spec.md` lists every emitted file with a one-line description of what the page contains and what content slot it suits
|
||||||
|
|
||||||
This step is a **hard gate**. Do not register the template into the library index until validation passes.
|
This step is a **hard gate**. Do not register a template into the library index until validation passes.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -358,6 +395,12 @@ The index file is a **discovery index** — it lets the AI answer "what template
|
|||||||
> kind: deck
|
> kind: deck
|
||||||
> summary: ...
|
> summary: ...
|
||||||
> canvas_format: ppt169
|
> canvas_format: ppt169
|
||||||
|
> canvas_width: 1280
|
||||||
|
> canvas_height: 720
|
||||||
|
> canvas_viewbox: "0 0 1280 720"
|
||||||
|
> source_canvas_width: 1280
|
||||||
|
> source_canvas_height: 720
|
||||||
|
> source_viewbox: "0 0 1280 720"
|
||||||
> page_count: 5
|
> page_count: 5
|
||||||
> primary_color: "#005587"
|
> primary_color: "#005587"
|
||||||
> ---
|
> ---
|
||||||
@@ -368,6 +411,12 @@ The index file is a **discovery index** — it lets the AI answer "what template
|
|||||||
> kind: layout
|
> kind: layout
|
||||||
> summary: ...
|
> summary: ...
|
||||||
> canvas_format: ppt169
|
> canvas_format: ppt169
|
||||||
|
> canvas_width: 1280
|
||||||
|
> canvas_height: 720
|
||||||
|
> canvas_viewbox: "0 0 1280 720"
|
||||||
|
> source_canvas_width: 1280
|
||||||
|
> source_canvas_height: 720
|
||||||
|
> source_viewbox: "0 0 1280 720"
|
||||||
> page_count: 5
|
> page_count: 5
|
||||||
> page_types: [cover, toc, chapter, content, ending]
|
> page_types: [cover, toc, chapter, content, ending]
|
||||||
> ---
|
> ---
|
||||||
|
|||||||
@@ -37,8 +37,8 @@ python3 ${SKILL_DIR}/scripts/svg_editor/server.py <project_path> --daemon
|
|||||||
The launcher binds `127.0.0.1:5050` (or the next free port), starts the server in the background, writes runtime files under `<project_path>/live_preview/`, opens the browser on a local desktop when possible, and edits `<project_path>/svg_output/` in place. After it prints the running URL, tell the user in their language, in one short message:
|
The launcher binds `127.0.0.1:5050` (or the next free port), starts the server in the background, writes runtime files under `<project_path>/live_preview/`, opens the browser on a local desktop when possible, and edits `<project_path>/svg_output/` in place. After it prints the running URL, tell the user in their language, in one short message:
|
||||||
|
|
||||||
- editor is at the URL reported by the launcher, e.g. `http://localhost:5050`
|
- editor is at the URL reported by the launcher, e.g. `http://localhost:5050`
|
||||||
- **Direct edit** (deterministic tweaks — wording, color, coordinates, SVG attributes): select an element → change the controls in the right panel → preview updates immediately, but nothing is written to `svg_output/` until **Apply changes**. `Ctrl+Z` or the **Undo** button drops staged edits step by step; applied changes are logged to `<project>/live_preview/edits.jsonl`. Re-export stays chat-driven: say "re-export" / "重新导出" to refresh the PPTX.
|
- **Direct edit** (deterministic tweaks — wording, color, coordinates, SVG attributes): select an element → change the controls in the right panel → preview updates immediately, but nothing is written to `svg_output/` until **Apply changes**. `Ctrl+Z` or the **Undo** button drops staged edits step by step; applied changes are logged to `<project>/live_preview/edits.jsonl`. Re-export stays chat-driven and separate: say "re-export" / "重新导出" to refresh the PPTX.
|
||||||
- **Annotate** (changes that need AI judgement / re-layout): select an element → write the instruction → click **Add annotation** to stage it → click **Apply changes** to write annotation markers → return to the chat and say `apply my annotations` (or quote the browser prompt)
|
- **Annotate** (changes that need AI judgement / re-layout): select an element → write the instruction, optionally starting from a quick type such as move / resize / replace image / copy / relayout → click **Add annotation** to stage it → click **Apply changes** to write annotation markers → return to the chat and say `apply my annotations` (or quote the browser prompt)
|
||||||
- to skip the editor, just describe the change in chat
|
- to skip the editor, just describe the change in chat
|
||||||
|
|
||||||
Do not wait for confirmation before launching — the user already asked for preview, so launching is the response. If another project already holds the port, the launcher auto-advances to the next free one — report the actual URL from the launch log (`--port <other>` still forces a specific port). Remote access → see the appendix.
|
Do not wait for confirmation before launching — the user already asked for preview, so launching is the response. If another project already holds the port, the launcher auto-advances to the next free one — report the actual URL from the launch log (`--port <other>` still forces a specific port). Remote access → see the appendix.
|
||||||
@@ -73,15 +73,15 @@ Triggered by the user signals listed in "When to Run".
|
|||||||
|
|
||||||
## Notes (editor invariants — referenced from SKILL.md Step 6)
|
## Notes (editor invariants — referenced from SKILL.md Step 6)
|
||||||
|
|
||||||
- **UI**: bilingual (EN/中); auto-detects from `navigator.language`, persists in `localStorage`, toggled via the **中 / EN** button on the right panel. Slide navigation: first/prev/next/last buttons at the top of the center panel, plus `←` / `→` / `Home` / `End` (suppressed while typing in the annotation textarea).
|
- **UI**: bilingual (EN/中); auto-detects from `navigator.language`, persists in `localStorage`, toggled via the **中 / EN** button on the right panel. The right panel is an **Edit / Annotate** surface: direct SVG edits and AI-needed annotations are visually separated, with a pending-status strip showing staged direct edits and pages with unsaved annotations. Slide navigation: first/prev/next/last buttons at the top of the center panel, plus `←` / `→` / `Home` / `End` (suppressed while typing in the annotation textarea).
|
||||||
- **Buttons**: `Add annotation` stages annotation text in memory; `Apply changes` writes staged direct edits plus annotation markers to disk and keeps the service running; `Exit preview` is the only UI action that stops Flask.
|
- **Buttons**: `Add annotation` stages annotation text in memory; `Apply changes` writes staged direct edits plus annotation markers to disk and keeps the service running; `Exit preview` is the only UI action that stops Flask.
|
||||||
- **Direct edit (no AI)**: selection mode determines the right-panel surface. Single element = full object inspector (geometry, safe text content, raw SVG attributes except protected fields like `id`, UI `class`, event handlers, and hrefs). SVG `<g>` group = group-level edit surface; select via `Alt/Option` + click or **Select parent group** from a child element. Multi-select = limited batch editor over top-level selected objects only: shared x/y plus `fill` / `stroke` / `opacity`; text style fields (`font-size` / `font-family` / `font-weight` / `text-anchor`) appear only when every selected object is `text`/`tspan`. Preview updates immediately; disk writes wait for **Apply changes**.
|
- **Direct edit (no AI)**: selection mode determines the right-panel surface. Single element = full object inspector (geometry, safe text content, computed text styles for the selected text node or descendant text inside a selected textbox/group, raw SVG attributes except protected fields like `id`, UI `class`, event handlers, and hrefs). SVG `<g>` group = group-level edit surface; select via `Alt/Option` + click or **Select parent group** from a child element. Multi-select = limited batch editor over top-level selected objects only: shared x/y plus `fill` / `stroke` / `opacity`; text style fields (`font-size` / `font-family` / `font-weight` / `text-anchor`) appear only when every selected object is `text`/`tspan`. Preview updates immediately; disk writes wait for **Apply changes**.
|
||||||
- **Drag to move**: press and drag an already-selected element on the canvas to reposition it (selection stays a separate click, so the background is never dragged by accident); the whole selection moves together under multi-select. The pointer delta is mapped through each element's own CTM, so moves track the cursor regardless of viewport scale or group transforms. Each release stages one direct edit per moved element (the same `x`/`y`-or-`transform` write the geometry inputs produce), previewed live and written only on **Apply changes**; dragging on empty canvas is still rubber-band selection. A failed stage rolls the canvas back to the pre-drag position.
|
- **Drag to move**: press and drag an already-selected element on the canvas to reposition it (selection stays a separate click, so the background is never dragged by accident); the whole selection moves together under multi-select. The pointer delta is mapped through each element's own CTM, so moves track the cursor regardless of viewport scale or group transforms. Each release stages one direct edit per moved element (the same `x`/`y`-or-`transform` write the geometry inputs produce), previewed live and written only on **Apply changes**; dragging on empty canvas is still rubber-band selection. A failed stage rolls the canvas back to the pre-drag position.
|
||||||
- **Arrow-key nudge**: with one or more elements selected, `↑ ↓ ← →` moves the selection 1px and `Shift + arrow` moves 10px (suppressed while typing in the annotation box). Arrow keys navigate slides only when nothing is selected. Same staging/coalescing path as drag, so a burst of nudges collapses to one undo step.
|
- **Arrow-key nudge**: with one or more elements selected, `↑ ↓ ← →` moves the selection 1px and `Shift + arrow` moves 10px (suppressed while typing in the annotation box). Arrow keys navigate slides only when nothing is selected. Same staging/coalescing path as drag, so a burst of nudges collapses to one undo step.
|
||||||
- **Overlap picker**: right-click anywhere on the canvas to list every selectable element under the pointer (top→bottom), so stacked shapes can be reached without blind cycling. Left-click is unchanged (selects the topmost). Hovering a row highlights that element; clicking it selects it; `Esc` or an outside click closes the list. With exactly one element under the pointer, right-click selects it directly.
|
- **Overlap picker**: right-click anywhere on the canvas to list every selectable element under the pointer (top→bottom), so stacked shapes can be reached without blind cycling. Left-click is unchanged (selects the topmost). Hovering a row highlights that element; clicking it selects it; `Esc` or an outside click closes the list. With exactly one element under the pointer, right-click selects it directly.
|
||||||
- **Undo**: `Ctrl+Z` or the **Undo** button drops the last staged direct edit on the current slide (per-slide LIFO, this session). Consecutive edits to the *same element and same field set* (e.g. nudging one color or coordinate several times) coalesce into a single undo step, keeping the original pre-edit value; switching element or field starts a new step. Applied old→new history is appended to `<project>/live_preview/edits.jsonl`; annotation save/update/remove history is appended to `<project>/live_preview/annotations.jsonl`; un-applied staged edits are in-memory only.
|
- **Undo**: `Ctrl+Z` or the **Undo** button drops the last staged direct edit on the current slide (per-slide LIFO, this session). Consecutive edits to the *same element and same field set* (e.g. nudging one color or coordinate several times) coalesce into a single undo step, keeping the original pre-edit value; switching element or field starts a new step. Applied old→new history is appended to `<project>/live_preview/edits.jsonl`; annotation save/update/remove history is appended to `<project>/live_preview/annotations.jsonl`; un-applied staged edits are in-memory only.
|
||||||
- **Unsaved-work guard**: staged direct edits and annotation changes (added or removed) live in server memory until **Apply changes**; closing the tab triggers the browser's native "leave site?" prompt while any are unapplied, since an idle timeout or process kill would drop them.
|
- **Unsaved-work guard**: staged direct edits and annotation changes (added or removed) live in server memory until **Apply changes**; closing the tab triggers the browser's native "leave site?" prompt while any are unapplied, since an idle timeout or process kill would drop them.
|
||||||
- **Re-export is chat-driven**: applying changes updates `svg_output/` only. Refreshing the PPTX (finalize + svg_to_pptx) stays a chat step — the editor never runs the export pipeline.
|
- **Re-export is chat-driven**: applying changes updates `svg_output/` only. Refreshing the PPTX (finalize + svg_to_pptx) stays a chat step — the editor never runs the export pipeline or presents browser-side export as part of applying edits.
|
||||||
- **Stop conditions**: the service stops when the user clicks **Exit preview** in the browser, asks in chat to stop it, the idle timeout fires, or the process is killed externally.
|
- **Stop conditions**: the service stops when the user clicks **Exit preview** in the browser, asks in chat to stop it, the idle timeout fires, or the process is killed externally.
|
||||||
- **Port**: default `5050`, auto-advancing to the next free port when another project already holds it (report the actual URL from the launch log); force a specific port with `--port <other>`.
|
- **Port**: default `5050`, auto-advancing to the next free port when another project already holds it (report the actual URL from the launch log); force a specific port with `--port <other>`.
|
||||||
- **Idle timeout**: plain mode `900s`, `--live` mode `7200s`; override with `--timeout <seconds>` (`0` disables).
|
- **Idle timeout**: plain mode `900s`, `--live` mode `7200s`; override with `--timeout <seconds>` (`0` disables).
|
||||||
|
|||||||
@@ -4,25 +4,37 @@ description: PPTX template fill workflow — use a native PowerPoint template de
|
|||||||
|
|
||||||
# Template Fill (PPTX) Workflow
|
# Template Fill (PPTX) Workflow
|
||||||
|
|
||||||
> Run when the user wants to fill new content into an existing deck. Typical requests include "fill this deck with the new content", "fill this back into the template", or "reuse this deck's design". They provide an existing `.pptx` as a native template deck plus topic / text materials and want the content filled back into that deck's design while selecting only the pages that fit the new story (a source page may be reused for several output slides).
|
> Run when the user provides a raw `.pptx` template plus new content / a new topic and asks to generate a `.pptx` from that template. Typical requests include "use this PPT template to generate a PPTX", "fill this deck with the new content", "fill this back into the template", "replace the copy in this PowerPoint", or "keep the original PowerPoint pages and swap in this material". This workflow treats the existing `.pptx` as a native slide library and produces a new `.pptx` by selecting, cloning, and patching source slides.
|
||||||
|
|
||||||
This workflow is **independent** from the SVG generation pipeline. It treats the source PPTX as a native template / slide library, keeps the original PowerPoint design intact, and writes a new `.pptx` by cloning selected source slides and replacing text directly in OOXML.
|
This workflow is **independent** from the SVG generation pipeline. It treats the source PPTX as a native template / slide library, keeps the original PowerPoint design intact, and writes a new `.pptx` by cloning selected source slides and replacing text directly in OOXML.
|
||||||
|
|
||||||
|
**Boundary against template-based generation**: run this workflow for raw PPTX template + generated PPTX requests. Skip this workflow only when the user explicitly wants a reusable template package, SVG-derived template roster, or SVG-generated deck that can freely select / repeat / skip / adapt template pages. In that case, they must run [`create-template.md`](./create-template.md) first and then provide the generated template directory path to the main pipeline.
|
||||||
|
|
||||||
|
| User wants | Route |
|
||||||
|
|---|---|
|
||||||
|
| Generate a PPTX from a raw PPTX template | This workflow |
|
||||||
|
| Directly edit / fill cloned PPTX slides | This workflow |
|
||||||
|
| Create a reusable design asset from the PPTX | `create-template` |
|
||||||
|
| Generate a new PPT from a reusable template package | Main pipeline Step 3 with the explicit template directory path |
|
||||||
|
| Generate through the SVG pipeline directly from a raw PPTX "template" | Not allowed; create the template package first |
|
||||||
|
|
||||||
## When to Run
|
## When to Run
|
||||||
|
|
||||||
Recognize any request that combines an existing PowerPoint with new content or a topic, for example:
|
Recognize requests that combine an existing PowerPoint template with new content or a topic and ask for a generated `.pptx` without explicitly requesting the reusable SVG/template-package route:
|
||||||
|
|
||||||
| Pattern | Example |
|
| Pattern | Example |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Existing `.pptx` + "fill back" intent | "Use this deck and fill in the attached material" |
|
| Existing `.pptx` + "fill back" intent | "Use this deck and fill in the attached material" |
|
||||||
| Existing `.pptx` + topic reuse | "Rework this PPTX around the new topic" |
|
| Raw PPTX template + generated PPTX | "Use this PowerPoint template to generate a PPTX about this topic" |
|
||||||
| Existing `.pptx` + selective reuse | "Do not keep every page; only use the slides that fit" |
|
| Existing `.pptx` + selective reuse | "Do not keep every page; only use the slides that fit" |
|
||||||
| Existing `.pptx` + copywriting replacement | "Keep the original design and replace the copy with this text" |
|
| Existing `.pptx` + copywriting replacement | "Keep the original design and replace the copy with this text" |
|
||||||
| Native PPT template fill | "Use this PowerPoint template for this content" |
|
| Native PPT template fill | "Use this PowerPoint template for this content and fill the slides directly" |
|
||||||
| Direct wording | "Fill this deck with the new content" |
|
| Direct wording | "Fill this deck with the new content" |
|
||||||
|
|
||||||
**Hard rule**: Do not run `pptx_to_svg.py`, `pptx_template_import.py`, `finalize_svg.py`, or `svg_to_pptx.py` for this workflow. SVG conversion is for presentation generation / template creation; this workflow is direct PowerPoint editing.
|
**Hard rule**: Do not run `pptx_to_svg.py`, `pptx_template_import.py`, `finalize_svg.py`, or `svg_to_pptx.py` for this workflow. SVG conversion is for presentation generation / template creation; this workflow is direct PowerPoint editing.
|
||||||
|
|
||||||
|
**Deterministic routing rule**: do not ask a route-choice question for raw PPTX template + generated PPTX requests; route them here. If the user asks for SVG/template-package generation from a raw PPTX, state that `create-template` must run first and stop this workflow until they provide the generated template directory path.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Step 1: Inputs
|
## Step 1: Inputs
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
"sourceId": "ui-ux-pro-max",
|
"sourceId": "ui-ux-pro-max",
|
||||||
"repo": "https://github.com/nextlevelbuilder/ui-ux-pro-max-skill.git",
|
"repo": "https://github.com/nextlevelbuilder/ui-ux-pro-max-skill.git",
|
||||||
"ref": "main",
|
"ref": "main",
|
||||||
"commit": "65e23199492fa911af32d9078e627ab4de01f4c8",
|
"commit": "8a81ed60272d21d4b8808f7308d49a0b1b000555",
|
||||||
"adapter": "claude-skill",
|
"adapter": "claude-skill",
|
||||||
"sourcePath": ".claude/skills/ui-ux-pro-max",
|
"sourcePath": ".claude/skills/ui-ux-pro-max",
|
||||||
"syncedAt": "2026-06-27T16:00:00Z"
|
"syncedAt": "2026-06-28T16:00:00Z"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -159,4 +159,35 @@ No,Product Type,Primary,On Primary,Secondary,On Secondary,Accent,On Accent,Backg
|
|||||||
158,Emergency SOS & Safety,#DC2626,#FFFFFF,#EF4444,#FFFFFF,#2563EB,#FFFFFF,#FFF1F2,#0F172A,#FFFFFF,#0F172A,#FCF1F1,#64748B,#FAE4E4,#DC2626,#FFFFFF,#DC2626,Alert red + safety blue
|
158,Emergency SOS & Safety,#DC2626,#FFFFFF,#EF4444,#FFFFFF,#2563EB,#FFFFFF,#FFF1F2,#0F172A,#FFFFFF,#0F172A,#FCF1F1,#64748B,#FAE4E4,#DC2626,#FFFFFF,#DC2626,Alert red + safety blue
|
||||||
159,Wallpaper & Theme App,#7C3AED,#FFFFFF,#EC4899,#FFFFFF,#2563EB,#FFFFFF,#FAF5FF,#0F172A,#FFFFFF,#0F172A,#F7F3FD,#64748B,#EFE7FC,#DC2626,#FFFFFF,#7C3AED,Aesthetic purple + trending pink
|
159,Wallpaper & Theme App,#7C3AED,#FFFFFF,#EC4899,#FFFFFF,#2563EB,#FFFFFF,#FAF5FF,#0F172A,#FFFFFF,#0F172A,#F7F3FD,#64748B,#EFE7FC,#DC2626,#FFFFFF,#7C3AED,Aesthetic purple + trending pink
|
||||||
160,White Noise & Ambient Sound,#475569,#FFFFFF,#334155,#FFFFFF,#4338CA,#FFFFFF,#0F172A,#FFFFFF,#192134,#FFFFFF,#131B2F,#94A3B8,"rgba(255,255,255,0.08)",#DC2626,#FFFFFF,#475569,Ambient grey + deep indigo on dark
|
160,White Noise & Ambient Sound,#475569,#FFFFFF,#334155,#FFFFFF,#4338CA,#FFFFFF,#0F172A,#FFFFFF,#192134,#FFFFFF,#131B2F,#94A3B8,"rgba(255,255,255,0.08)",#DC2626,#FFFFFF,#475569,Ambient grey + deep indigo on dark
|
||||||
161,Home Decoration & Interior Design,#78716C,#FFFFFF,#A8A29E,#FFFFFF,#D97706,#FFFFFF,#FAF5F2,#0F172A,#FFFFFF,#0F172A,#F6F6F6,#64748B,#EEEDED,#DC2626,#FFFFFF,#78716C,Interior warm grey + gold accent
|
161,Home Decoration & Interior Design,#78716C,#FFFFFF,#A8A29E,#FFFFFF,#D97706,#FFFFFF,#FAF5F2,#0F172A,#FFFFFF,#0F172A,#F6F6F6,#64748B,#EEEDED,#DC2626,#FFFFFF,#78716C,Interior warm grey + gold accent
|
||||||
|
162,Academic Journal / Scholarly Publishing,#1E3A5F,#FFFFFF,#334155,#FFFFFF,#B45309,#FFFFFF,#F8FAFC,#0F172A,#FFFFFF,#0F172A,#E9EEF5,#64748B,#CBD5E1,#DC2626,#FFFFFF,#1E3A5F,Scholarly navy + citation gold + serif accent
|
||||||
|
163,API Developer Portal,#0F172A,#FFFFFF,#1E293B,#FFFFFF,#22C55E,#0F172A,#020617,#F8FAFC,#0E1223,#F8FAFC,#1A1E2F,#94A3B8,#334155,#EF4444,#FFFFFF,#0F172A,Code dark + endpoint green + syntax colors
|
||||||
|
164,Forum / Discussion Board,#475569,#FFFFFF,#64748B,#FFFFFF,#2563EB,#FFFFFF,#F8FAFC,#1E293B,#FFFFFF,#1E293B,#EAEFF3,#64748B,#E2E8F0,#DC2626,#FFFFFF,#475569,Neutral grey + topic accent + unread indicator
|
||||||
|
165,Directory / Listing Site,#059669,#FFFFFF,#10B981,#0F172A,#D97706,#FFFFFF,#ECFDF5,#064E3B,#FFFFFF,#064E3B,#E8F1F3,#64748B,#A7F3D0,#DC2626,#FFFFFF,#059669,Category green + verified badge + map accent
|
||||||
|
166,Status Page / Incident Management,#16A34A,#FFFFFF,#22C55E,#0F172A,#DC2626,#FFFFFF,#F0FDF4,#14532D,#FFFFFF,#14532D,#E8F0F1,#64748B,#BBF7D0,#DC2626,#FFFFFF,#16A34A,Operational green + incident red + maintenance amber
|
||||||
|
167,Wiki / Encyclopedia,#1E3A8A,#FFFFFF,#3B82F6,#FFFFFF,#7C3AED,#FFFFFF,#F8FAFC,#1E40AF,#FFFFFF,#1E40AF,#E9EEF5,#64748B,#BFDBFE,#DC2626,#FFFFFF,#1E3A8A,Knowledge blue + link purple + clean white
|
||||||
|
168,Auction Platform,#0F172A,#FFFFFF,#1E293B,#FFFFFF,#16A34A,#0F172A,#020617,#F8FAFC,#0E1223,#F8FAFC,#1A1E2F,#94A3B8,#334155,#DC2626,#FFFFFF,#0F172A,Dark luxury + bid green + outbid red + urgency
|
||||||
|
169,Changelog / Release Notes,#475569,#FFFFFF,#64748B,#FFFFFF,#059669,#FFFFFF,#F8FAFC,#1E293B,#FFFFFF,#1E293B,#EAEFF3,#64748B,#E2E8F0,#DC2626,#FFFFFF,#475569,Feature green + fix blue + breaking red badges
|
||||||
|
170,Citizen Science Platform,#15803D,#FFFFFF,#22C55E,#0F172A,#D97706,#FFFFFF,#F0FDF4,#14532D,#FFFFFF,#14532D,#E8F0F1,#64748B,#BBF7D0,#DC2626,#FFFFFF,#15803D,Discovery green + volunteer badge + data neutral
|
||||||
|
171,Classifieds / Buy-Sell,#2563EB,#FFFFFF,#3B82F6,#FFFFFF,#16A34A,#FFFFFF,#EFF6FF,#1E40AF,#FFFFFF,#1E40AF,#E9EFF8,#64748B,#BFDBFE,#DC2626,#FFFFFF,#2563EB,Listing blue + price green + seller badge
|
||||||
|
172,Conference / Symposium Landing Page,#1E3A5F,#FFFFFF,#2563EB,#FFFFFF,#A16207,#FFFFFF,#F8FAFC,#0F172A,#FFFFFF,#0F172A,#E9EEF5,#64748B,#CBD5E1,#DC2626,#FFFFFF,#1E3A5F,Academic navy + gold keynote + track chips
|
||||||
|
173,Crowdfunding Platform,#D97706,#FFFFFF,#F59E0B,#0F172A,#16A34A,#FFFFFF,#FFFBEB,#0F172A,#FFFFFF,#0F172A,#FCF6F0,#64748B,#FAEEE1,#DC2626,#FFFFFF,#D97706,Funding progress amber + goal green + urgency
|
||||||
|
174,Digital Signage / Kiosk,#0F172A,#FFFFFF,#1E293B,#FFFFFF,#EF4444,#FFFFFF,#020617,#F8FAFC,#0E1223,#F8FAFC,#1A1E2F,#94A3B8,#334155,#EF4444,#FFFFFF,#0F172A,High contrast dark + brand accent + large touch targets
|
||||||
|
175,E-signature / Document Workflow,#1E3A5F,#FFFFFF,#2563EB,#FFFFFF,#16A34A,#FFFFFF,#F8FAFC,#0F172A,#FFFFFF,#0F172A,#E9EEF5,#64748B,#CBD5E1,#DC2626,#FFFFFF,#1E3A5F,Trust navy + signature green + audit trail
|
||||||
|
176,Feature Flag / Config Management,#0F172A,#FFFFFF,#1E293B,#FFFFFF,#16A34A,#0F172A,#020617,#F8FAFC,#0E1223,#F8FAFC,#1A1E2F,#94A3B8,#334155,#DC2626,#FFFFFF,#0F172A,Enabled green + disabled grey + experimental amber
|
||||||
|
177,Government Portal / Civic Services,#1E40AF,#FFFFFF,#3B82F6,#FFFFFF,#16A34A,#FFFFFF,#EFF6FF,#1E3A8A,#FFFFFF,#1E3A8A,#E9EFF5,#64748B,#BFDBFE,#DC2626,#FFFFFF,#1E40AF,Professional blue + service green + accessibility
|
||||||
|
178,Grant / Funding Portal,#1E3A5F,#FFFFFF,#2563EB,#FFFFFF,#16A34A,#FFFFFF,#F8FAFC,#0F172A,#FFFFFF,#0F172A,#E9EEF5,#64748B,#CBD5E1,#DC2626,#FFFFFF,#1E3A5F,Institution navy + funding green + deadline urgency
|
||||||
|
179,LMS (Learning Management System),#0D9488,#FFFFFF,#2DD4BF,#0F172A,#D97706,#FFFFFF,#F0FDFA,#134E4A,#FFFFFF,#134E4A,#E8F1F4,#64748B,#5EEAD4,#DC2626,#FFFFFF,#0D9488,Education teal + course amber + grade green
|
||||||
|
180,No-code / Low-code Builder,#7C3AED,#FFFFFF,#A78BFA,#0F172A,#EC4899,#FFFFFF,#FAF5FF,#4C1D95,#FFFFFF,#4C1D95,#ECEEF9,#64748B,#DDD6FE,#DC2626,#FFFFFF,#7C3AED,Builder purple + component pink + canvas neutral
|
||||||
|
181,Open Source Project Landing,#0F172A,#FFFFFF,#1E293B,#FFFFFF,#A16207,#FFFFFF,#020617,#F8FAFC,#0E1223,#F8FAFC,#1A1E2F,#94A3B8,#334155,#22C55E,#FFFFFF,#0F172A,Dark code + star gold + fork silver + sponsor purple
|
||||||
|
182,Patient Portal / Health Records,#0284C7,#FFFFFF,#0891B2,#FFFFFF,#16A34A,#FFFFFF,#F0F9FF,#0C4A6E,#FFFFFF,#0C4A6E,#E8F2F8,#64748B,#BAE6FD,#DC2626,#FFFFFF,#0284C7,Clinical blue + health green + alert red
|
||||||
|
183,Patent / IP Database,#475569,#FFFFFF,#64748B,#FFFFFF,#A16207,#FFFFFF,#F8FAFC,#1E293B,#FFFFFF,#1E293B,#EAEFF3,#64748B,#E2E8F0,#DC2626,#FFFFFF,#475569,Formal neutral + patent type chips + status badges
|
||||||
|
184,Q&A Community Platform,#2563EB,#FFFFFF,#0891B2,#FFFFFF,#D97706,#FFFFFF,#F8FAFC,#0F172A,#FFFFFF,#0F172A,#F1F5FD,#64748B,#E4ECFC,#DC2626,#FFFFFF,#2563EB,Knowledge blue + accepted green + reputation gold
|
||||||
|
185,Research Lab / University Department,#1E3A5F,#FFFFFF,#2563EB,#FFFFFF,#A16207,#FFFFFF,#F8FAFC,#0F172A,#FFFFFF,#0F172A,#E9EEF5,#64748B,#CBD5E1,#DC2626,#FFFFFF,#1E3A5F,Institutional navy + research accent + serif headings
|
||||||
|
186,Resume / CV Builder,#1E3A5F,#FFFFFF,#2563EB,#FFFFFF,#16A34A,#FFFFFF,#F8FAFC,#0F172A,#FFFFFF,#0F172A,#E9EEF5,#64748B,#CBD5E1,#DC2626,#FFFFFF,#1E3A5F,Professional navy + section accent + success green
|
||||||
|
187,Review Platform,#F59E0B,#0F172A,#FBBF24,#0F172A,#16A34A,#FFFFFF,#FFFBEB,#0F172A,#FFFFFF,#0F172A,#FCF6F0,#64748B,#FAEEE1,#DC2626,#FFFFFF,#F59E0B,Star gold + positive green + negative red
|
||||||
|
188,RPA / Automation Dashboard,#0F172A,#FFFFFF,#1E293B,#FFFFFF,#16A34A,#0F172A,#020617,#F8FAFC,#0E1223,#F8FAFC,#1A1E2F,#94A3B8,#334155,#DC2626,#FFFFFF,#0F172A,Dark terminal + running green + failed red + queued amber
|
||||||
|
189,Survey / Form Builder,#0D9488,#FFFFFF,#2DD4BF,#0F172A,#D97706,#FFFFFF,#F0FDFA,#134E4A,#FFFFFF,#134E4A,#E8F1F4,#64748B,#5EEAD4,#DC2626,#FFFFFF,#0D9488,Question teal + progress green + submit blue
|
||||||
|
190,Telemedicine Platform,#0891B2,#FFFFFF,#22D3EE,#0F172A,#16A34A,#FFFFFF,#F0FDFA,#134E4A,#FFFFFF,#134E4A,#E8F1F6,#64748B,#CCFBF1,#DC2626,#FFFFFF,#0891B2,Medical teal + video green + waiting amber
|
||||||
|
191,Testimonial & Social Proof Widget,#7C3AED,#FFFFFF,#A78BFA,#0F172A,#F59E0B,#0F172A,#FAF5FF,#4C1D95,#FFFFFF,#4C1D95,#ECEEF9,#64748B,#DDD6FE,#DC2626,#FFFFFF,#7C3AED,Trust purple + quote gold + verified blue
|
||||||
|
192,Ticketing / Box Office,#0F172A,#FFFFFF,#1E293B,#FFFFFF,#16A34A,#0F172A,#020617,#F8FAFC,#0E1223,#F8FAFC,#1A1E2F,#94A3B8,#334155,#DC2626,#FFFFFF,#0F172A,Event theme colors + available green + sold-out red
|
||||||
|
|||||||
|
@@ -1,17 +1,17 @@
|
|||||||
No,Product Type,Keywords,Primary Style Recommendation,Secondary Styles,Landing Page Pattern,Dashboard Style (if applicable),Color Palette Focus,Key Considerations
|
No,Product Type,Keywords,Primary Style Recommendation,Secondary Styles,Landing Page Pattern,Dashboard Style (if applicable),Color Palette Focus,Key Considerations
|
||||||
1,SaaS (General),"app, b2b, cloud, general, saas, software, subscription",Glassmorphism + Flat Design,"Soft UI Evolution, Minimalism",Hero + Features + CTA,Data-Dense + Real-Time Monitoring,Trust blue + accent contrast,Balance modern feel with clarity. Focus on CTAs.
|
1,SaaS (General),"app, b2b, cloud, general, saas, software, subscription",Glassmorphism + Flat Design,"Soft UI Evolution, Minimalism",Hero + Features + CTA,Data-Dense + Real-Time Monitoring,Trust blue + accent contrast,Balance modern feel with clarity. Focus on CTAs.
|
||||||
2,Micro SaaS,"app, b2b, cloud, indie, micro, micro-saas, niche, saas, small, software, solo, subscription",Flat Design + Vibrant & Block,"Motion-Driven, Micro-interactions",Minimal & Direct + Demo,Executive Dashboard,Vibrant primary + white space,"Keep simple, show product quickly. Speed is key."
|
2,Micro SaaS,"indie, micro-saas, niche, solo, bootstrap, micro, side-project, solopreneur, small-team, indie-hacker, product-hunt",Flat Design + Vibrant & Block,"Motion-Driven, Micro-interactions",Minimal & Direct + Demo,Executive Dashboard,Vibrant primary + white space,"Keep simple, show product quickly. Speed is key."
|
||||||
3,E-commerce,"buy, commerce, e, ecommerce, products, retail, sell, shop, store",Vibrant & Block-based,"Aurora UI, Motion-Driven",Feature-Rich Showcase,Sales Intelligence Dashboard,Brand primary + success green,Engagement & conversions. High visual hierarchy.
|
3,E-commerce,"buy, commerce, e, ecommerce, products, retail, sell, shop, store",Vibrant & Block-based,"Aurora UI, Motion-Driven",Feature-Rich Showcase,Sales Intelligence Dashboard,Brand primary + success green,Engagement & conversions. High visual hierarchy.
|
||||||
4,E-commerce Luxury,"buy, commerce, e, ecommerce, elegant, exclusive, high-end, luxury, premium, products, retail, sell, shop, store",Liquid Glass + Glassmorphism,"3D & Hyperrealism, Aurora UI",Feature-Rich Showcase,Sales Intelligence Dashboard,Premium colors + minimal accent,Elegance & sophistication. Premium materials.
|
4,E-commerce Luxury,"buy, commerce, e, ecommerce, elegant, exclusive, high-end, luxury, premium, products, retail, sell, shop, store",Liquid Glass + Glassmorphism,"3D & Hyperrealism, Aurora UI",Feature-Rich Showcase,Sales Intelligence Dashboard,Premium colors + minimal accent,Elegance & sophistication. Premium materials.
|
||||||
5,B2B Service,"appointment, b, b2b, booking, business, consultation, corporate, enterprise, service",Trust & Authority + Minimal,"Feature-Rich, Conversion-Optimized",Feature-Rich Showcase,Sales Intelligence Dashboard,Professional blue + neutral grey,Credibility essential. Clear ROI messaging.
|
5,B2B Service,"b2b, enterprise, consulting, professional, solution, contract, corporate, strategy, advisory, roi, deliverable, whitepaper",Trust & Authority + Minimal,"Feature-Rich, Conversion-Optimized",Feature-Rich Showcase,Sales Intelligence Dashboard,Professional blue + neutral grey,Credibility essential. Clear ROI messaging.
|
||||||
6,Financial Dashboard,"admin, analytics, dashboard, data, financial, panel",Dark Mode (OLED) + Data-Dense,"Minimalism, Accessible & Ethical",N/A - Dashboard focused,Financial Dashboard,Dark bg + red/green alerts + trust blue,"High contrast, real-time updates, accuracy paramount."
|
6,Financial Dashboard,"portfolio, trading, pnl, budget, revenue, expense, cashflow, balance-sheet, investment, bank, accounting, fintech",Dark Mode (OLED) + Data-Dense,"Minimalism, Accessible & Ethical",N/A - Dashboard focused,Financial Dashboard,Dark bg + red/green alerts + trust blue,"High contrast, real-time updates, accuracy paramount."
|
||||||
7,Analytics Dashboard,"admin, analytics, dashboard, data, panel",Data-Dense + Heat Map & Heatmap,"Minimalism, Dark Mode (OLED)",N/A - Analytics focused,Drill-Down Analytics + Comparative,Cool→Hot gradients + neutral grey,Clarity > aesthetics. Color-coded data priority.
|
7,Analytics Dashboard,"kpi, metric, funnel, conversion, cohort, retention, segment, attribution, ab-test, dashboard-data, business-intelligence",Data-Dense + Heat Map & Heatmap,"Minimalism, Dark Mode (OLED)",N/A - Analytics focused,Drill-Down Analytics + Comparative,Cool→Hot gradients + neutral grey,Clarity > aesthetics. Color-coded data priority.
|
||||||
8,Healthcare App,"app, clinic, health, healthcare, medical, patient",Neumorphism + Accessible & Ethical,"Soft UI Evolution, Claymorphism (for patients)",Social Proof-Focused,User Behavior Analytics,Calm blue + health green + trust,Accessibility mandatory. Calming aesthetic.
|
8,Healthcare App,"app, clinic, health, healthcare, medical, patient",Neumorphism + Accessible & Ethical,"Soft UI Evolution, Claymorphism (for patients)",Social Proof-Focused,User Behavior Analytics,Calm blue + health green + trust,Accessibility mandatory. Calming aesthetic.
|
||||||
9,Educational App,"app, course, education, educational, learning, school, training",Claymorphism + Micro-interactions,"Vibrant & Block-based, Flat Design",Storytelling-Driven,User Behavior Analytics,Playful colors + clear hierarchy,Engagement & ease of use. Age-appropriate design.
|
9,Educational App,"app, course, education, educational, learning, school, training",Claymorphism + Micro-interactions,"Vibrant & Block-based, Flat Design",Storytelling-Driven,User Behavior Analytics,Playful colors + clear hierarchy,Engagement & ease of use. Age-appropriate design.
|
||||||
10,Creative Agency,"agency, creative, design, marketing, studio",Brutalism + Motion-Driven,"Retro-Futurism, Storytelling-Driven",Storytelling-Driven,N/A - Portfolio focused,Bold primaries + artistic freedom,Differentiation key. Wow-factor necessary.
|
10,Creative Agency,"branding, identity, portfolio, logo, visual, rebrand, creative-director, campaign, awards, award-winning, showreel",Brutalism + Motion-Driven,"Retro-Futurism, Storytelling-Driven",Storytelling-Driven,N/A - Portfolio focused,Bold primaries + artistic freedom,Differentiation key. Wow-factor necessary.
|
||||||
11,Portfolio/Personal,"creative, personal, portfolio, projects, showcase, work",Motion-Driven + Minimalism,"Brutalism, Aurora UI",Storytelling-Driven,N/A - Personal branding,Brand primary + artistic interpretation,Showcase work. Personality shine through.
|
11,Portfolio/Personal,"creative, personal, portfolio, projects, showcase, work",Motion-Driven + Minimalism,"Brutalism, Aurora UI",Storytelling-Driven,N/A - Personal branding,Brand primary + artistic interpretation,Showcase work. Personality shine through.
|
||||||
12,Gaming,"entertainment, esports, game, gaming, play",3D & Hyperrealism + Retro-Futurism,"Motion-Driven, Vibrant & Block",Feature-Rich Showcase,N/A - Game focused,Vibrant + neon + immersive colors,Immersion priority. Performance critical.
|
12,Gaming,"entertainment, esports, game, gaming, play",3D & Hyperrealism + Retro-Futurism,"Motion-Driven, Vibrant & Block",Feature-Rich Showcase,N/A - Game focused,Vibrant + neon + immersive colors,Immersion priority. Performance critical.
|
||||||
13,Government/Public Service,"appointment, booking, consultation, government, public, service",Accessible & Ethical + Minimalism,"Flat Design, Inclusive Design",Minimal & Direct,Executive Dashboard,Professional blue + high contrast,WCAG AAA mandatory. Trust paramount.
|
13,Government/Public Service,"government, civic, municipal, federal, citizen, public, administration, permit, tax, voter, transparency, regulation",Accessible & Ethical + Minimalism,"Flat Design, Inclusive Design",Minimal & Direct,Executive Dashboard,Professional blue + high contrast,WCAG AAA mandatory. Trust paramount.
|
||||||
14,Fintech/Crypto,"banking, blockchain, crypto, defi, finance, fintech, money, nft, payment, web3",Glassmorphism + Dark Mode (OLED),"Retro-Futurism, Motion-Driven",Conversion-Optimized,Real-Time Monitoring + Predictive,Dark tech colors + trust + vibrant accents,Security perception. Real-time data critical.
|
14,Fintech/Crypto,"banking, blockchain, crypto, defi, finance, fintech, money, nft, payment, web3",Glassmorphism + Dark Mode (OLED),"Retro-Futurism, Motion-Driven",Conversion-Optimized,Real-Time Monitoring + Predictive,Dark tech colors + trust + vibrant accents,Security perception. Real-time data critical.
|
||||||
15,Social Media App,"app, community, content, entertainment, media, network, sharing, social, streaming, users, video",Vibrant & Block-based + Motion-Driven,"Aurora UI, Micro-interactions",Feature-Rich Showcase,User Behavior Analytics,Vibrant + engagement colors,Engagement & retention. Addictive design ethics.
|
15,Social Media App,"app, community, content, entertainment, media, network, sharing, social, streaming, users, video",Vibrant & Block-based + Motion-Driven,"Aurora UI, Micro-interactions",Feature-Rich Showcase,User Behavior Analytics,Vibrant + engagement colors,Engagement & retention. Addictive design ethics.
|
||||||
16,Productivity Tool,"collaboration, productivity, project, task, tool, workflow",Flat Design + Micro-interactions,"Minimalism, Soft UI Evolution",Interactive Product Demo,Drill-Down Analytics,Clear hierarchy + functional colors,Ease of use. Speed & efficiency focus.
|
16,Productivity Tool,"collaboration, productivity, project, task, tool, workflow",Flat Design + Micro-interactions,"Minimalism, Soft UI Evolution",Interactive Product Demo,Drill-Down Analytics,Clear hierarchy + functional colors,Ease of use. Speed & efficiency focus.
|
||||||
@@ -24,21 +24,21 @@ No,Product Type,Keywords,Primary Style Recommendation,Secondary Styles,Landing P
|
|||||||
23,Pet Tech App,"app, pet, tech",Claymorphism + Vibrant & Block-based,"Micro-interactions, Flat Design",Storytelling-Driven,User Behavior Analytics,Playful + Warm colors,Pet profiles. Health tracking. Playful UI. Photo galleries. Vet integration.
|
23,Pet Tech App,"app, pet, tech",Claymorphism + Vibrant & Block-based,"Micro-interactions, Flat Design",Storytelling-Driven,User Behavior Analytics,Playful + Warm colors,Pet profiles. Health tracking. Playful UI. Photo galleries. Vet integration.
|
||||||
24,Smart Home/IoT Dashboard,"admin, analytics, dashboard, data, home, iot, panel, smart",Glassmorphism + Dark Mode (OLED),"Minimalism, AI-Native UI",Interactive Product Demo,Real-Time Monitoring,Dark + Status indicator colors,Device status. Real-time controls. Energy monitoring. Automation rules. Quick actions.
|
24,Smart Home/IoT Dashboard,"admin, analytics, dashboard, data, home, iot, panel, smart",Glassmorphism + Dark Mode (OLED),"Minimalism, AI-Native UI",Interactive Product Demo,Real-Time Monitoring,Dark + Status indicator colors,Device status. Real-time controls. Energy monitoring. Automation rules. Quick actions.
|
||||||
25,EV/Charging Ecosystem,"charging, ecosystem, ev",Minimalism + Aurora UI,"Glassmorphism, Organic Biophilic",Hero-Centric Design,Energy/Utilities Dashboard,Electric Blue (#009CD1) + Green,Charging station maps. Range estimation. Cost calculation. Environmental impact.
|
25,EV/Charging Ecosystem,"charging, ecosystem, ev",Minimalism + Aurora UI,"Glassmorphism, Organic Biophilic",Hero-Centric Design,Energy/Utilities Dashboard,Electric Blue (#009CD1) + Green,Charging station maps. Range estimation. Cost calculation. Environmental impact.
|
||||||
26,Subscription Box Service,"appointment, booking, box, consultation, membership, plan, recurring, service, subscription",Vibrant & Block-based + Motion-Driven,"Claymorphism, Aurora UI",Feature-Rich Showcase,E-commerce Analytics,Brand + Excitement colors,Unboxing experience. Personalization quiz. Subscription management. Product reveals.
|
26,Subscription Box Service,"subscription, box, recurring, membership, unboxing, curated, plan, monthly, surprise, product-box",Vibrant & Block-based + Motion-Driven,"Claymorphism, Aurora UI",Feature-Rich Showcase,E-commerce Analytics,Brand + Excitement colors,Unboxing experience. Personalization quiz. Subscription management. Product reveals.
|
||||||
27,Podcast Platform,"platform, podcast",Dark Mode (OLED) + Minimalism,"Motion-Driven, Vibrant & Block-based",Storytelling-Driven,Media/Entertainment Dashboard,Dark + Audio waveform accents,Audio player UX. Episode discovery. Creator tools. Analytics for podcasters.
|
27,Podcast Platform,"platform, podcast",Dark Mode (OLED) + Minimalism,"Motion-Driven, Vibrant & Block-based",Storytelling-Driven,Media/Entertainment Dashboard,Dark + Audio waveform accents,Audio player UX. Episode discovery. Creator tools. Analytics for podcasters.
|
||||||
28,Dating App,"app, dating",Vibrant & Block-based + Motion-Driven,"Aurora UI, Glassmorphism",Social Proof-Focused,User Behavior Analytics,Warm + Romantic (Pink/Red gradients),Profile cards. Swipe interactions. Match animations. Safety features. Video chat.
|
28,Dating App,"app, dating",Vibrant & Block-based + Motion-Driven,"Aurora UI, Glassmorphism",Social Proof-Focused,User Behavior Analytics,Warm + Romantic (Pink/Red gradients),Profile cards. Swipe interactions. Match animations. Safety features. Video chat.
|
||||||
29,Micro-Credentials/Badges Platform,"badges, credentials, micro, platform",Minimalism + Flat Design,"Accessible & Ethical, Swiss Modernism 2.0",Trust & Authority,Education Dashboard,Trust Blue + Gold (#FFD700),Credential verification. Badge display. Progress tracking. Issuer trust. LinkedIn integration.
|
29,Micro-Credentials/Badges Platform,"badges, credentials, micro, platform",Minimalism + Flat Design,"Accessible & Ethical, Swiss Modernism 2.0",Trust & Authority,Education Dashboard,Trust Blue + Gold (#FFD700),Credential verification. Badge display. Progress tracking. Issuer trust. LinkedIn integration.
|
||||||
30,Knowledge Base/Documentation,"base, documentation, knowledge",Minimalism + Accessible & Ethical,"Swiss Modernism 2.0, Flat Design",FAQ/Documentation,N/A - Documentation focused,Clean hierarchy + minimal color,Search-first. Clear navigation. Code highlighting. Version switching. Feedback system.
|
30,Knowledge Base/Documentation,"base, documentation, knowledge",Minimalism + Accessible & Ethical,"Swiss Modernism 2.0, Flat Design",FAQ/Documentation,N/A - Documentation focused,Clean hierarchy + minimal color,Search-first. Clear navigation. Code highlighting. Version switching. Feedback system.
|
||||||
31,Hyperlocal Services,"appointment, booking, consultation, hyperlocal, service, services",Minimalism + Vibrant & Block-based,"Micro-interactions, Flat Design",Conversion-Optimized,Drill-Down Analytics + Map,Location markers + Trust colors,Map integration. Service categories. Provider profiles. Booking system. Reviews.
|
31,Hyperlocal Services,"hyperlocal, local, neighborhood, nearby, community, nearby, zip, map, local-business, geo-target, city",Minimalism + Vibrant & Block-based,"Micro-interactions, Flat Design",Conversion-Optimized,Drill-Down Analytics + Map,Location markers + Trust colors,Map integration. Service categories. Provider profiles. Booking system. Reviews.
|
||||||
32,Beauty/Spa/Wellness Service,"appointment, beauty, booking, consultation, service, spa, wellness",Soft UI Evolution + Neumorphism,"Glassmorphism, Minimalism",Hero-Centric Design + Social Proof,User Behavior Analytics,Soft pastels (Pink #FFB6C1 Sage #90EE90) + Cream + Gold accents,Calming aesthetic. Booking system. Service menu. Before/after gallery. Testimonials. Relaxing imagery.
|
32,Beauty/Spa/Wellness Service,"spa, beauty, salon, wellness, treatment, relaxation, massage, skincare, facial, aesthetic, self-care, pamper",Soft UI Evolution + Neumorphism,"Glassmorphism, Minimalism",Hero-Centric Design + Social Proof,User Behavior Analytics,Soft pastels (Pink #FFB6C1 Sage #90EE90) + Cream + Gold accents,Calming aesthetic. Booking system. Service menu. Before/after gallery. Testimonials. Relaxing imagery.
|
||||||
33,Luxury/Premium Brand,"brand, elegant, exclusive, high-end, luxury, premium",Liquid Glass + Glassmorphism,"Minimalism, 3D & Hyperrealism",Storytelling-Driven + Feature-Rich,Sales Intelligence Dashboard,Black + Gold (#FFD700) + White + Minimal accent,Elegance paramount. Premium imagery. Storytelling. High-quality visuals. Exclusive feel.
|
33,Luxury/Premium Brand,"brand, elegant, exclusive, high-end, luxury, premium",Liquid Glass + Glassmorphism,"Minimalism, 3D & Hyperrealism",Storytelling-Driven + Feature-Rich,Sales Intelligence Dashboard,Black + Gold (#FFD700) + White + Minimal accent,Elegance paramount. Premium imagery. Storytelling. High-quality visuals. Exclusive feel.
|
||||||
34,Restaurant/Food Service,"appointment, booking, consultation, delivery, food, menu, order, restaurant, service",Vibrant & Block-based + Motion-Driven,"Claymorphism, Flat Design",Hero-Centric Design + Conversion,N/A - Booking focused,Warm colors (Orange Red Brown) + appetizing imagery,Menu display. Online ordering. Reservation system. Food photography. Location/hours prominent.
|
34,Restaurant/Food Service,"restaurant, menu, order, food, dining, reservation, delivery, cuisine, chef, table, takeaway, eatery",Vibrant & Block-based + Motion-Driven,"Claymorphism, Flat Design",Hero-Centric Design + Conversion,N/A - Booking focused,Warm colors (Orange Red Brown) + appetizing imagery,Menu display. Online ordering. Reservation system. Food photography. Location/hours prominent.
|
||||||
35,Fitness/Gym App,"app, exercise, fitness, gym, health, workout",Vibrant & Block-based + Dark Mode (OLED),"Motion-Driven, Neumorphism",Feature-Rich Showcase,User Behavior Analytics,Energetic (Orange #FF6B35 Electric Blue) + Dark bg,Progress tracking. Workout plans. Community features. Achievements. Motivational design.
|
35,Fitness/Gym App,"app, exercise, fitness, gym, health, workout",Vibrant & Block-based + Dark Mode (OLED),"Motion-Driven, Neumorphism",Feature-Rich Showcase,User Behavior Analytics,Energetic (Orange #FF6B35 Electric Blue) + Dark bg,Progress tracking. Workout plans. Community features. Achievements. Motivational design.
|
||||||
36,Real Estate/Property,"buy, estate, housing, property, real, real-estate, rent",Glassmorphism + Minimalism,"Motion-Driven, 3D & Hyperrealism",Hero-Centric Design + Feature-Rich,Sales Intelligence Dashboard,Trust Blue (#0077B6) + Gold accents + White,Property listings. Virtual tours. Map integration. Agent profiles. Mortgage calculator. High-quality imagery.
|
36,Real Estate/Property,"buy, estate, housing, property, real, real-estate, rent",Glassmorphism + Minimalism,"Motion-Driven, 3D & Hyperrealism",Hero-Centric Design + Feature-Rich,Sales Intelligence Dashboard,Trust Blue (#0077B6) + Gold accents + White,Property listings. Virtual tours. Map integration. Agent profiles. Mortgage calculator. High-quality imagery.
|
||||||
37,Travel/Tourism Agency,"agency, booking, creative, design, flight, hotel, marketing, studio, tourism, travel, vacation",Aurora UI + Motion-Driven,"Vibrant & Block-based, Glassmorphism",Storytelling-Driven + Hero-Centric,Booking Analytics,Vibrant destination colors + Sky Blue + Warm accents,Destination showcase. Booking system. Itinerary builder. Reviews. Inspiration galleries. Mobile-first.
|
37,Travel/Tourism Agency,"travel, tourism, vacation, flight, hotel, destination, adventure, cruise, safari, backpacking, guided-tour, holiday-package",Aurora UI + Motion-Driven,"Vibrant & Block-based, Glassmorphism",Storytelling-Driven + Hero-Centric,Booking Analytics,Vibrant destination colors + Sky Blue + Warm accents,Destination showcase. Booking system. Itinerary builder. Reviews. Inspiration galleries. Mobile-first.
|
||||||
38,Hotel/Hospitality,"hospitality, hotel",Liquid Glass + Minimalism,"Glassmorphism, Soft UI Evolution",Hero-Centric Design + Social Proof,Revenue Management Dashboard,Warm neutrals + Gold (#D4AF37) + Brand accent,Room booking. Amenities showcase. Location maps. Guest reviews. Seasonal pricing. Luxury imagery.
|
38,Hotel/Hospitality,"hospitality, hotel",Liquid Glass + Minimalism,"Glassmorphism, Soft UI Evolution",Hero-Centric Design + Social Proof,Revenue Management Dashboard,Warm neutrals + Gold (#D4AF37) + Brand accent,Room booking. Amenities showcase. Location maps. Guest reviews. Seasonal pricing. Luxury imagery.
|
||||||
39,Wedding/Event Planning,"conference, event, meetup, planning, registration, ticket, wedding",Soft UI Evolution + Aurora UI,"Glassmorphism, Motion-Driven",Storytelling-Driven + Social Proof,N/A - Planning focused,Soft Pink (#FFD6E0) + Gold + Cream + Sage,Portfolio gallery. Vendor directory. Planning tools. Timeline. Budget tracker. Romantic aesthetic.
|
39,Wedding/Event Planning,"conference, event, meetup, planning, registration, ticket, wedding",Soft UI Evolution + Aurora UI,"Glassmorphism, Motion-Driven",Storytelling-Driven + Social Proof,N/A - Planning focused,Soft Pink (#FFD6E0) + Gold + Cream + Sage,Portfolio gallery. Vendor directory. Planning tools. Timeline. Budget tracker. Romantic aesthetic.
|
||||||
40,Legal Services,"appointment, attorney, booking, compliance, consultation, contract, law, legal, service, services",Trust & Authority + Minimalism,"Accessible & Ethical, Swiss Modernism 2.0",Trust & Authority + Minimal,Case Management Dashboard,Navy Blue (#1E3A5F) + Gold + White,Credibility paramount. Practice areas. Attorney profiles. Case results. Contact forms. Professional imagery.
|
40,Legal Services,"law, attorney, legal, case, compliance, contract, court, firm, counsel, litigation, practice-area, jurisdiction",Trust & Authority + Minimalism,"Accessible & Ethical, Swiss Modernism 2.0",Trust & Authority + Minimal,Case Management Dashboard,Navy Blue (#1E3A5F) + Gold + White,Credibility paramount. Practice areas. Attorney profiles. Case results. Contact forms. Professional imagery.
|
||||||
41,Insurance Platform,"insurance, platform",Trust & Authority + Flat Design,"Accessible & Ethical, Minimalism",Conversion-Optimized + Trust,Claims Analytics Dashboard,Trust Blue (#0066CC) + Green (security) + Neutral,Quote calculator. Policy comparison. Claims process. Trust signals. Clear pricing. Security badges.
|
41,Insurance Platform,"insurance, platform",Trust & Authority + Flat Design,"Accessible & Ethical, Minimalism",Conversion-Optimized + Trust,Claims Analytics Dashboard,Trust Blue (#0066CC) + Green (security) + Neutral,Quote calculator. Policy comparison. Claims process. Trust signals. Clear pricing. Security badges.
|
||||||
42,Banking/Traditional Finance,"banking, finance, traditional",Minimalism + Accessible & Ethical,"Trust & Authority, Dark Mode (OLED)",Trust & Authority + Feature-Rich,Financial Dashboard,Navy (#0A1628) + Trust Blue + Gold accents,Security-first. Account overview. Transaction history. Mobile banking. Accessibility critical. Trust paramount.
|
42,Banking/Traditional Finance,"banking, finance, traditional",Minimalism + Accessible & Ethical,"Trust & Authority, Dark Mode (OLED)",Trust & Authority + Feature-Rich,Financial Dashboard,Navy (#0A1628) + Trust Blue + Gold accents,Security-first. Account overview. Transaction history. Mobile banking. Accessibility critical. Trust paramount.
|
||||||
43,Online Course/E-learning,"course, e, learning, online",Claymorphism + Vibrant & Block-based,"Motion-Driven, Flat Design",Feature-Rich Showcase + Social Proof,Education Dashboard,Vibrant learning colors + Progress green,Course catalog. Progress tracking. Video player. Quizzes. Certificates. Community forums. Gamification.
|
43,Online Course/E-learning,"course, e, learning, online",Claymorphism + Vibrant & Block-based,"Motion-Driven, Flat Design",Feature-Rich Showcase + Social Proof,Education Dashboard,Vibrant learning colors + Progress green,Course catalog. Progress tracking. Video player. Quizzes. Certificates. Community forums. Gamification.
|
||||||
@@ -53,7 +53,7 @@ No,Product Type,Keywords,Primary Style Recommendation,Secondary Styles,Landing P
|
|||||||
52,Automotive/Car Dealership,"automotive, car, dealership",Motion-Driven + 3D & Hyperrealism,"Dark Mode (OLED), Glassmorphism",Hero-Centric Design + Feature-Rich,Sales Intelligence Dashboard,Brand colors + Metallic accents + Dark/Light,Vehicle showcase. 360° views. Comparison tools. Financing calculator. Test drive booking. High-quality imagery.
|
52,Automotive/Car Dealership,"automotive, car, dealership",Motion-Driven + 3D & Hyperrealism,"Dark Mode (OLED), Glassmorphism",Hero-Centric Design + Feature-Rich,Sales Intelligence Dashboard,Brand colors + Metallic accents + Dark/Light,Vehicle showcase. 360° views. Comparison tools. Financing calculator. Test drive booking. High-quality imagery.
|
||||||
53,Photography Studio,"photography, studio",Motion-Driven + Minimalism,"Aurora UI, Glassmorphism",Storytelling-Driven + Hero-Centric,N/A - Portfolio focused,Black + White + Minimal accent,Portfolio gallery. Before/after. Service packages. Booking system. Client galleries. Full-bleed imagery.
|
53,Photography Studio,"photography, studio",Motion-Driven + Minimalism,"Aurora UI, Glassmorphism",Storytelling-Driven + Hero-Centric,N/A - Portfolio focused,Black + White + Minimal accent,Portfolio gallery. Before/after. Service packages. Booking system. Client galleries. Full-bleed imagery.
|
||||||
54,Coworking Space,"coworking, space",Vibrant & Block-based + Glassmorphism,"Minimalism, Motion-Driven",Hero-Centric Design + Feature-Rich,Occupancy Dashboard,Energetic colors + Wood tones + Brand accent,Space tour. Membership plans. Booking system. Amenities. Community events. Virtual tour.
|
54,Coworking Space,"coworking, space",Vibrant & Block-based + Glassmorphism,"Minimalism, Motion-Driven",Hero-Centric Design + Feature-Rich,Occupancy Dashboard,Energetic colors + Wood tones + Brand accent,Space tour. Membership plans. Booking system. Amenities. Community events. Virtual tour.
|
||||||
55,Home Services (Plumber/Electrician),"appointment, booking, consultation, electrician, home, plumber, service, services",Flat Design + Trust & Authority,"Minimalism, Accessible & Ethical",Conversion-Optimized + Trust,Service Analytics,Trust Blue + Safety Orange + Professional grey,Service list. Emergency contact. Booking. Price transparency. Certifications. Local trust signals.
|
55,Home Services (Plumber/Electrician),"plumber, electrician, hvac, handyman, repair, maintenance, home, emergency, leak, wiring, inspection, licensed",Flat Design + Trust & Authority,"Minimalism, Accessible & Ethical",Conversion-Optimized + Trust,Service Analytics,Trust Blue + Safety Orange + Professional grey,Service list. Emergency contact. Booking. Price transparency. Certifications. Local trust signals.
|
||||||
56,Childcare/Daycare,"childcare, daycare",Claymorphism + Vibrant & Block-based,"Soft UI Evolution, Accessible & Ethical",Social Proof-Focused + Trust,Parent Dashboard,Playful pastels + Safe colors + Warm accents,Programs. Staff profiles. Safety certifications. Parent portal. Activity updates. Cheerful imagery.
|
56,Childcare/Daycare,"childcare, daycare",Claymorphism + Vibrant & Block-based,"Soft UI Evolution, Accessible & Ethical",Social Proof-Focused + Trust,Parent Dashboard,Playful pastels + Safe colors + Warm accents,Programs. Staff profiles. Safety certifications. Parent portal. Activity updates. Cheerful imagery.
|
||||||
57,Senior Care/Elderly,"care, elderly, senior",Accessible & Ethical + Soft UI Evolution,"Minimalism, Neumorphism",Trust & Authority + Social Proof,Healthcare Analytics,Calm Blue + Warm neutrals + Large text,Care services. Staff qualifications. Facility tour. Family portal. Large touch targets. High contrast. Accessibility-first.
|
57,Senior Care/Elderly,"care, elderly, senior",Accessible & Ethical + Soft UI Evolution,"Minimalism, Neumorphism",Trust & Authority + Social Proof,Healthcare Analytics,Calm Blue + Warm neutrals + Large text,Care services. Staff qualifications. Facility tour. Family portal. Large touch targets. High contrast. Accessibility-first.
|
||||||
58,Medical Clinic,"clinic, medical",Accessible & Ethical + Minimalism,"Neumorphism, Trust & Authority",Trust & Authority + Conversion,Healthcare Analytics,Medical Blue (#0077B6) + Trust White + Calm Green,Services. Doctor profiles. Online booking. Patient portal. Insurance info. HIPAA compliant. Trust signals.
|
58,Medical Clinic,"clinic, medical",Accessible & Ethical + Minimalism,"Neumorphism, Trust & Authority",Trust & Authority + Conversion,Healthcare Analytics,Medical Blue (#0077B6) + Trust White + Calm Green,Services. Doctor profiles. Online booking. Patient portal. Insurance info. HIPAA compliant. Trust signals.
|
||||||
@@ -67,7 +67,7 @@ No,Product Type,Keywords,Primary Style Recommendation,Secondary Styles,Landing P
|
|||||||
66,News/Media Platform,"content, entertainment, media, news, platform, streaming, video",Minimalism + Flat Design,"Dark Mode (OLED), Accessible & Ethical",Hero-Centric Design + Feature-Rich,Media Analytics Dashboard,Brand colors + High contrast + Category colors,Article layout. Breaking news. Categories. Search. Subscription. Mobile reading. Fast loading.
|
66,News/Media Platform,"content, entertainment, media, news, platform, streaming, video",Minimalism + Flat Design,"Dark Mode (OLED), Accessible & Ethical",Hero-Centric Design + Feature-Rich,Media Analytics Dashboard,Brand colors + High contrast + Category colors,Article layout. Breaking news. Categories. Search. Subscription. Mobile reading. Fast loading.
|
||||||
67,Magazine/Blog,"articles, blog, content, magazine, posts, writing",Swiss Modernism 2.0 + Motion-Driven,"Minimalism, Aurora UI",Storytelling-Driven + Hero-Centric,Content Analytics,Editorial colors + Brand primary + Clean white,Article showcase. Category navigation. Author profiles. Newsletter signup. Related content. Typography-focused.
|
67,Magazine/Blog,"articles, blog, content, magazine, posts, writing",Swiss Modernism 2.0 + Motion-Driven,"Minimalism, Aurora UI",Storytelling-Driven + Hero-Centric,Content Analytics,Editorial colors + Brand primary + Clean white,Article showcase. Category navigation. Author profiles. Newsletter signup. Related content. Typography-focused.
|
||||||
68,Freelancer Platform,"freelancer, platform",Flat Design + Minimalism,"Vibrant & Block-based, Micro-interactions",Feature-Rich Showcase + Conversion,Marketplace Analytics,Professional Blue + Success Green + Neutral,Profile creation. Portfolio. Skill matching. Messaging. Payment. Reviews. Project management.
|
68,Freelancer Platform,"freelancer, platform",Flat Design + Minimalism,"Vibrant & Block-based, Micro-interactions",Feature-Rich Showcase + Conversion,Marketplace Analytics,Professional Blue + Success Green + Neutral,Profile creation. Portfolio. Skill matching. Messaging. Payment. Reviews. Project management.
|
||||||
69,Marketing Agency,"agency, creative, design, marketing, studio",Brutalism + Motion-Driven,"Vibrant & Block-based, Aurora UI",Storytelling-Driven + Feature-Rich,Campaign Analytics,Bold brand colors + Creative freedom,Portfolio. Case studies. Services. Team. Creative showcase. Results-focused. Bold aesthetic.
|
69,Marketing Agency,"campaign, ads, growth, roi, seo, sem, ppc, social-media, conversion-funnel, ab-test, attribution, performance-marketing",Brutalism + Motion-Driven,"Vibrant & Block-based, Aurora UI",Storytelling-Driven + Feature-Rich,Campaign Analytics,Bold brand colors + Creative freedom,Portfolio. Case studies. Services. Team. Creative showcase. Results-focused. Bold aesthetic.
|
||||||
70,Event Management,"conference, event, management, meetup, registration, ticket",Vibrant & Block-based + Motion-Driven,"Glassmorphism, Aurora UI",Hero-Centric Design + Feature-Rich,Event Analytics,Event theme colors + Excitement accents,Event showcase. Registration. Agenda. Speakers. Sponsors. Ticket sales. Countdown timer.
|
70,Event Management,"conference, event, management, meetup, registration, ticket",Vibrant & Block-based + Motion-Driven,"Glassmorphism, Aurora UI",Hero-Centric Design + Feature-Rich,Event Analytics,Event theme colors + Excitement accents,Event showcase. Registration. Agenda. Speakers. Sponsors. Ticket sales. Countdown timer.
|
||||||
71,Membership/Community,"community, membership",Vibrant & Block-based + Soft UI Evolution,"Bento Box Grid, Micro-interactions",Social Proof-Focused + Conversion,Community Analytics,Community brand colors + Engagement accents,Member benefits. Pricing tiers. Community showcase. Events. Member directory. Exclusive content.
|
71,Membership/Community,"community, membership",Vibrant & Block-based + Soft UI Evolution,"Bento Box Grid, Micro-interactions",Social Proof-Focused + Conversion,Community Analytics,Community brand colors + Engagement accents,Member benefits. Pricing tiers. Community showcase. Events. Member directory. Exclusive content.
|
||||||
72,Newsletter Platform,"newsletter, platform",Minimalism + Flat Design,"Swiss Modernism 2.0, Accessible & Ethical",Minimal & Direct + Conversion,Email Analytics,Brand primary + Clean white + CTA accent,Subscribe form. Archive. About. Social proof. Sample content. Simple conversion.
|
72,Newsletter Platform,"newsletter, platform",Minimalism + Flat Design,"Swiss Modernism 2.0, Accessible & Ethical",Minimal & Direct + Conversion,Email Analytics,Brand primary + Clean white + CTA accent,Subscribe form. Archive. About. Social proof. Sample content. Simple conversion.
|
||||||
@@ -160,3 +160,34 @@ No,Product Type,Keywords,Primary Style Recommendation,Secondary Styles,Landing P
|
|||||||
159,Wallpaper & Theme App,"wallpaper, theme, background, customize, aesthetic, home-screen, lock-screen, widget, design, zedge",Vibrant & Block-based + Aurora UI,"Glassmorphism, Motion-Driven",Feature-Rich Showcase + Social Proof,N/A - Gallery focused,Content-driven + trending aesthetic palettes + download accent,Category browsing. Preview on device. Daily wallpaper auto-set. Widget matching. Creator uploads. Resolution auto-fit.
|
159,Wallpaper & Theme App,"wallpaper, theme, background, customize, aesthetic, home-screen, lock-screen, widget, design, zedge",Vibrant & Block-based + Aurora UI,"Glassmorphism, Motion-Driven",Feature-Rich Showcase + Social Proof,N/A - Gallery focused,Content-driven + trending aesthetic palettes + download accent,Category browsing. Preview on device. Daily wallpaper auto-set. Widget matching. Creator uploads. Resolution auto-fit.
|
||||||
160,White Noise & Ambient Sound,"white noise, ambient, sound, sleep, focus, rain, nature, relax, concentration, background, noisli",Minimalism + Dark Mode (OLED),"Neumorphism, Organic Biophilic",Minimal & Direct + Social Proof,N/A - Player focused,Calming dark + ambient texture visual + subtle sound wave + sleep blue,Sound mixer with multiple simultaneous layers. Sleep timer with fade. Custom soundscapes. Offline. Background audio.
|
160,White Noise & Ambient Sound,"white noise, ambient, sound, sleep, focus, rain, nature, relax, concentration, background, noisli",Minimalism + Dark Mode (OLED),"Neumorphism, Organic Biophilic",Minimal & Direct + Social Proof,N/A - Player focused,Calming dark + ambient texture visual + subtle sound wave + sleep blue,Sound mixer with multiple simultaneous layers. Sleep timer with fade. Custom soundscapes. Offline. Background audio.
|
||||||
161,Home Decoration & Interior Design,"home, interior, decor, design, furniture, room, renovation, ar, plan, inspire, 3d, houzz",Minimalism + 3D Product Preview,"Organic Biophilic, Aurora UI",Storytelling-Driven + Feature-Rich,N/A - Project focused,Neutral interior palette + material texture accent + AR blue,AR room visualization. Style quiz. Product catalog with purchase links. 3D room planner. Mood board. Before/after.
|
161,Home Decoration & Interior Design,"home, interior, decor, design, furniture, room, renovation, ar, plan, inspire, 3d, houzz",Minimalism + 3D Product Preview,"Organic Biophilic, Aurora UI",Storytelling-Driven + Feature-Rich,N/A - Project focused,Neutral interior palette + material texture accent + AR blue,AR room visualization. Style quiz. Product catalog with purchase links. 3D room planner. Mood board. Before/after.
|
||||||
|
162,Academic Journal / Scholarly Publishing,"academic, journal, paper, research, peer-review, open-access, scholarly, publication, citation, manuscript, issn, doi",Swiss Modernism 2.0 + Minimalism,"Trust & Authority, Accessible & Ethical",Content-Index + Search,N/A - Publication focused,Trust navy + White + Citation blue + Serif accents,"Prioritize readability (serif body text). Clear article hierarchy. Abstract/DOI prominence. WCAG AAA. Minimal visual noise. Trust signals: ISSN, indexing badges."
|
||||||
|
163,API Developer Portal,"api, developer, documentation, sdk, endpoint, integration, rest, graphql, webhook, reference, getting-started, auth",Trust & Authority + Minimalism,"Glassmorphism, Dark Mode (OLED)",Quick Start + Interactive Docs,N/A - Documentation focused,Dark code theme + Brand accent + Syntax colors,"Endpoint discoverability. Copy-paste code samples. Auth flow clarity. Version switching. Interactive playground. Rate limit visibility."
|
||||||
|
164,Forum / Discussion Board,"forum, discussion, thread, post, reply, community, comment, moderation, subreddit, stackexchange, topic",Dark Mode (OLED) + Minimalism,"Flat Design, Vibrant & Block-based",Feed + Thread View,N/A - Discussion focused,Dark neutral + topic accent colors + unread indicator + reputation badge,Thread list with pagination. Rich text editor. Quote/mention system. Upvote/downvote. User badges. Moderation tools.
|
||||||
|
165,Directory / Listing Site,"directory, listing, classifieds, catalogue, business-directory, yellow-pages, venue, find, search, filter, map",Flat Design + Vibrant & Block-based,"Minimalism, Trust & Authority",Filter-Heavy Grid + Map,N/A - Listing focused,Neutral bg + category color chips + map accent + verified badge,Category tree. Multi-filter sidebar. Map/list toggle. Verified badges. Reviews. Claim listing flow.
|
||||||
|
166,Status Page / Incident Management,"status, incident, outage, uptime, downtime, statuspage, monitoring, sla, maintenance, sev1, postmortem",Data-Dense + Trust & Authority,"Minimalism, Dark Mode (OLED)",Timeline + Severity Indicators,Real-Time Monitoring + Timeline,Status green + incident red + maintenance amber + neutral dark,Service status matrix. Incident timeline. Severity badges. Maintenance schedule. SLA uptime history. Email/SMS subscribe.
|
||||||
|
167,Wiki / Encyclopedia,"wiki, encyclopedia, knowledge, article, reference, wikipedia, documentation, collaborative, edit, version, citation",Minimalism + Flat Design,"Swiss Modernism 2.0, Accessible & Ethical",Search-First + Hierarchical Navigation,N/A - Reference focused,Clean white + link blue + heading hierarchy + citation grey,Full-text search bar. Table of contents sidebar. Edit history. Inter-page linking. Mobile responsive. Print-friendly.
|
||||||
|
168,Auction Platform,"auction, bid, hammer, lot, live-auction, bidding-war, estate-sale, proxibid, gavel, lot-number, reserve-price",Dark Mode (OLED) + Motion-Driven,"Vibrant & Block-based, Real-Time Monitor",Live Auction Feed + Countdown,N/A - Auction focused,Dark bg + bid green + outbid red + countdown amber,Real-time bid updates. Countdown timer urgency. Auto-bid ceiling. Outbid notifications. Bid history. Reserve price indicator.
|
||||||
|
169,Changelog / Release Notes,"changelog, release-notes, version-history, whats-new, product-updates, semver, patch-notes, release-tracker",Minimalism + Flat Design,"Swiss Modernism 2.0, Trust & Authority",Timeline + Version List,N/A - Documentation focused,Neutral bg + version badge colors (feat=green, fix=blue, breaking=red) + date grey,Chronological release feed. Semver badges. Breaking change warnings. Copy-paste install commands. Subscribe to feed. Search by version.
|
||||||
|
170,Citizen Science Platform,"citizen-science, zooniverse, crowdsourced-research, volunteer-science, public-participation, distributed-research, citizen-researcher",Organic Biophilic + Vibrant & Block-based,"Claymorphism, Motion-Driven",Storytelling-Driven + Social Proof,Project Participation Dashboard,Earth green + discovery orange + volunteer badge blue + data neutral,Project cards with impact metrics. Contribution tracker. Beginner-friendly onboarding. Data quality feedback loop. Leaderboards. Community forums.
|
||||||
|
171,Classifieds / Buy-Sell,"classifieds, buy-sell, craigslist, secondhand, marketplace-listing, for-sale, trade, flea-market, thrift, resell",Flat Design + Vibrant & Block-based,"Minimalism, Trust & Authority",Filter-Heavy Grid + Map,N/A - Listing focused,Neutral bg + price green + category chips + verified seller badge,Category tree. Photo-first listing cards. Price negotiation. Location radius filter. Saved searches. Seller reputation. Flag/report.
|
||||||
|
172,Conference / Symposium Landing Page,"conference, symposium, summit, cfp, call-for-papers, speaker-lineup, registration, venue, proceedings, keynote, track",Swiss Modernism 2.0 + Minimalism,"Trust & Authority, Accessible & Ethical",Hero + Agenda + CFP,N/A - Event focused,Academic navy + track color chips + gold keynote + neutral white,Speaker grid. Multi-track agenda. CFP deadline countdown. Venue map. Sponsor tiers. Early-bird pricing. Proceedings download.
|
||||||
|
173,Crowdfunding Platform,"crowdfunding, kickstarter, indiegogo, campaign, backer, pledge, funding-goal, stretch-goal, reward-tier, all-or-nothing",Vibrant & Block-based + Motion-Driven,"Claymorphism, Storytelling-Driven",Storytelling-Driven + Social Proof,Campaign Analytics Dashboard,Brand primary + funding progress green + urgency amber + reward tier colors,Funding progress bar with % goal. Reward tier selector. Backer count. Countdown timer. Updates feed. Creator profile. Risk/disclaimer section.
|
||||||
|
174,Digital Signage / Kiosk,"digital-signage, kiosk, interactive-display, touchscreen, wayfinding, lobby-display, menu-board, point-of-sale-display",Minimalism + Dark Mode (OLED),"Flat Design, Motion-Driven",Full-Screen Immersive,N/A - Display focused,High contrast + brand accent + touch target emphasis (56px min),Full-screen single-purpose layout. Touch targets ≥56px. Auto-rotate content. Offline fallback. Brightness-aware color palette. No scroll.
|
||||||
|
175,E-signature / Document Workflow,"esignature, e-sign, docusign, digital-signature, document-workflow, approval-chain, contract-signing, signing-ceremony",Trust & Authority + Minimalism,"Accessible & Ethical, Flat Design",Feature-Rich Showcase + Conversion,Document Pipeline Dashboard,Trust navy + signature green + pending amber + neutral grey,Document preview with annotation. Signature placement UI. Multi-signer workflow. Audit trail. Compliance badges. Mobile signing. Expiry reminders.
|
||||||
|
176,Feature Flag / Config Management,"feature-flag, config, launchdarkly, feature-toggle, experiment, rollout, kill-switch, a-b-test-config, percentage-rollout",Dark Mode (OLED) + Data-Dense,"Minimalism, Trust & Authority",Feature List + Toggle Panel,N/A - Config focused,Dark bg + enabled green + disabled grey + experimental amber + kill-switch red,Feature list with on/off toggles. Percentage rollout slider. Environment selector (prod/staging). User targeting rules. Kill switch. Audit log.
|
||||||
|
177,Government Portal / Civic Services,"government-portal, civic-services, city-hall, permit-application, tax-payment, voter-registration, public-records, municipal-online",Accessible & Ethical + Trust & Authority,"Flat Design, Inclusive Design",Service Directory + Search,N/A - Service focused,Professional blue + accessibility high contrast + service category colors,Multilingual toggle. Service A-Z index. Form wizard with save-progress. Document upload. Appointment booking. Status tracker. WCAG AAA. Plain language.
|
||||||
|
178,Grant / Funding Portal,"grant, funding, rfp, proposal, research-grant, foundation, fellowship, award, application-portal, funding-opportunity",Trust & Authority + Minimalism,"Accessible & Ethical, Swiss Modernism 2.0",Opportunity Grid + Search,Application Tracking Dashboard,Institution navy + funding green + deadline red + neutral white,Funding opportunity cards. Eligibility checker. Deadline countdown. Application form wizard. Document checklist. Review status tracker. Award announcement feed.
|
||||||
|
179,LMS (Learning Management System),"lms, course-management, learning-management, canvas, moodle, blackboard, enrollment, gradebook, syllabus, assignment-submit",Flat Design + Accessible & Ethical,"Minimalism, Vibrant & Block-based",Dashboard + Course Grid,Education Analytics Dashboard,Calm blue + course category colors + grade green + alert red,Dashboard with enrolled courses. Assignment deadlines. Gradebook view. Discussion forums. File upload. Calendar integration. Mobile offline sync.
|
||||||
|
180,No-code / Low-code Builder,"no-code, low-code, builder, bubble, webflow, drag-drop, visual-builder, app-builder, workflow-builder, logic-blocks",Vibrant & Block-based + Bento Box Grid,"Motion-Driven, Glassmorphism",Interactive Product Demo,App Builder Workspace,Brand primary + component palette colors + canvas neutral + connect blue,Drag-drop canvas. Component library sidebar. Logic flow visual editor. Preview pane. Template gallery. Publish button. Version history.
|
||||||
|
181,Open Source Project Landing,"open-source, github-project, oss, contributor, star, fork, pull-request, maintainer, sponsoring, readme, repository",Dark Mode (OLED) + Minimalism,"Trust & Authority, Flat Design",Hero + Install + Contribute,Contributor Analytics Dashboard,Dark bg + language color bar + star gold + fork silver + sponsor purple,Star/fork count badges. Install command (copy-paste). Language breakdown bar. Top contributors grid. Sponsor CTA. Documentation link. Issue/pr status.
|
||||||
|
182,Patient Portal / Health Records,"patient-portal, health-records, ehr, emr, mychart, lab-results, prescription-refill, medical-history, test-results, care-team",Trust & Authority + Accessible & Ethical,"Minimalism, Flat Design",Health Summary Dashboard,Healthcare Analytics,Clinical blue + health green + alert red + calm white + accessible contrast,Labs and results timeline. Medication list with refill. Appointment scheduling. Message care team. Immunization records. Allergy alerts. Family access proxy.
|
||||||
|
183,Patent / IP Database,"patent, intellectual-property, trademark, prior-art, uspto, wipo, invention, ip-portfolio, patent-search, claims",Swiss Modernism 2.0 + Minimalism,"Trust & Authority, Data-Dense",Search-First + Results Grid,N/A - Search focused,Formal neutral + patent type chips + status badges (granted/pending/rejected),Full-text patent search. Classification tree. Citation graph. Prior art comparison. Patent family view. PDF download. Legal status tracker.
|
||||||
|
184,Q&A Community Platform,"qa, stack-overflow, question-answer, knowledge-sharing, community-qa, expert-answer, upvote, accepted-answer, reputation",Minimalism + Flat Design,"Dark Mode (OLED), Accessible & Ethical",Feed + Thread View,Community Analytics Dashboard,Clean white + upvote orange + accepted green + reputation gold + tag colors,Question list with vote count. Rich code blocks. Tag filter. Reputation system. Accepted answer highlight. Comment threads. Bookmark/save.
|
||||||
|
185,Research Lab / University Department,"research-lab, university-department, academic-lab, principal-investigator, lab-members, publications, research-group, pi-page",Swiss Modernism 2.0 + Minimalism,"Trust & Authority, Accessible & Ethical",Overview + People + Publications,N/A - Academic focused,Institutional navy + white + research area accent colors + serif headings,PI bio and research focus. Current members grid. Publication list with links. Open positions. Lab facilities photos. Funding acknowledgments.
|
||||||
|
186,Resume / CV Builder,"resume, cv, builder, job-search, curriculum-vitae, portfolio-resume, cover-letter, career-builder, ats-friendly",Minimalism + Flat Design,"Swiss Modernism 2.0, Trust & Authority",Interactive Product Demo + CTA,Template Selection Gallery,Professional navy + section accent + success green + clean white,Template picker. Section-by-section editor. Real-time preview. ATS score indicator. PDF export. Cover letter generator. Import from LinkedIn.
|
||||||
|
187,Review Platform,"review, rating, yelp, trustpilot, testimonial, customer-review, star-rating, verified-purchase, pros-cons",Flat Design + Vibrant & Block-based,"Trust & Authority, Minimalism",Hero + Rating Summary + Review Feed,Review Analytics Dashboard,Brand primary + star gold + positive green + negative red + verified blue,Star rating summary with distribution. Verified purchase badge. Photo/video reviews. Helpful/upvote. Filter by rating. Response from business. Sort by recency.
|
||||||
|
188,RPA / Automation Dashboard,"rpa, robotic-process-automation, uipath, automation-anywhere, bot-orchestrator, process-discovery, attended-bot, unattended-bot",Dark Mode (OLED) + Data-Dense,"Minimalism, Trust & Authority",Bot Fleet Dashboard,Real-Time Monitoring + Process Analytics,Dark bg + running green + failed red + queued amber + completed blue,Bot status grid (running/idle/failed). Queue depth. Process flow visualization. Exception handling alert. ROI metrics. Bot scheduling calendar. Audit trail.
|
||||||
|
189,Survey / Form Builder,"survey, form-builder, questionnaire, typeform, survey-monkey, poll, feedback-form, multi-step-form, nps-survey, logic-jump",Minimalism + Micro-interactions,"Claymorphism, Flat Design",Interactive Product Demo,Response Analytics Dashboard,Clean white + question accent + progress green + submit blue,Drag-drop form builder. Question type library. Conditional logic visualizer. Theme picker. Response dashboard with charts. Export CSV. Share link/QR/embed.
|
||||||
|
190,Telemedicine Platform,"telemedicine, telehealth, virtual-visit, remote-consultation, video-doctor, remote-patient-monitoring, telehealth-app",Neumorphism + Accessible & Ethical,"Trust & Authority, Soft UI Evolution",Trust & Authority + Conversion,Healthcare Analytics,Calm medical blue + video green + waiting amber + trust white,Video call UI with screen share. Appointment queue. Symptom intake form. Prescription e-delivery. Waiting room with ETA. Post-visit summary. Insurance verification.
|
||||||
|
191,Testimonial & Social Proof Widget,"testimonial, social-proof, wall-of-love, customer-quote, case-study, review-widget, trust-signal, user-story",Vibrant & Block-based + Flat Design,"Motion-Driven, Minimalism",Wall-of-Love Grid,Engagement Analytics Dashboard,Brand primary + quote accent + star gold + verified blue,Testimonial cards with photo. Star ratings. Video testimonials. Case study summaries. Filter by industry/product. Embeddable widget code. Auto-rotate carousel.
|
||||||
|
192,Ticketing / Box Office,"ticketing, box-office, eventbrite, ticket-sales, seat-selection, will-call, qr-ticket, venue-capacity, will-call-pickup",Vibrant & Block-based + Motion-Driven,"Dark Mode (OLED), Glassmorphism",Event Grid + Seat Map,Sales Analytics Dashboard,Event theme colors + available green + sold-out red + seat map neutral,Event cards with date/venue. Interactive seat map. Cart with countdown. QR code ticket. Will-call pickup. Group discounts. Refund policy.
|
||||||
|
|||||||
|
Reference in New Issue
Block a user