Allow empty Superpowers hooks manifest

Publish the completed third-party source refresh after Superpowers 6.1.1 changed its Codex manifest to use an empty no-op hooks object instead of a hooks file path.

Constraint: Current Codex CLI installs superpowers@eapil-skill-market with hooks: {}, while the marketplace validator still rejected empty inline hooks objects.
Rejected: Restore the old hooks directory by hand | Upstream no longer declares those Codex hook files, and hand-restoring generated content would drift from source.
Confidence: high
Scope-risk: moderate
Directive: Empty hooks objects are placeholders; allow them but do not count them as the plugin's only effective component.
Tested: /Users/eapil/git/keyinfo/.venv/bin/python3 scripts/validate_marketplace.py; rg public-secret scan; git diff --check; Codex temp CODEX_HOME marketplace add/list/add superpowers@eapil-skill-market.
Not-tested: Interactive Superpowers skill invocation inside a live Codex thread.
This commit is contained in:
KeyInfo Bot
2026-07-06 09:53:32 +08:00
parent 0de6e7dda7
commit bfe3759cdd
193 changed files with 12300 additions and 2097 deletions
+24 -24
View File
@@ -196,18 +196,6 @@
},
"category": "多媒体与生成"
},
{
"name": "superpowers",
"source": {
"source": "local",
"path": "./plugins/superpowers"
},
"policy": {
"installation": "AVAILABLE",
"authentication": "ON_INSTALL"
},
"category": "开发工具"
},
{
"name": "frontend-slides",
"source": {
@@ -256,6 +244,30 @@
},
"category": "知识库与检索"
},
{
"name": "superpowers",
"source": {
"source": "local",
"path": "./plugins/superpowers"
},
"policy": {
"installation": "AVAILABLE",
"authentication": "ON_INSTALL"
},
"category": "开发工具"
},
{
"name": "shadcn",
"source": {
"source": "local",
"path": "./plugins/shadcn"
},
"policy": {
"installation": "AVAILABLE",
"authentication": "ON_INSTALL"
},
"category": "开发工具"
},
{
"name": "oh-my-codex",
"source": {
@@ -280,18 +292,6 @@
},
"category": "设计"
},
{
"name": "shadcn",
"source": {
"source": "local",
"path": "./plugins/shadcn"
},
"policy": {
"installation": "AVAILABLE",
"authentication": "ON_INSTALL"
},
"category": "开发工具"
},
{
"name": "ppt-master",
"source": {
@@ -3,5 +3,5 @@
"name": "playwright浏览器自动化操作",
"version": "20260605",
"keySource": "none",
"syncedAt": "2026-06-29T16:01:28Z"
"syncedAt": "2026-07-05T16:01:18Z"
}
@@ -2,8 +2,8 @@
"sourceId": "next-skills",
"repo": "https://github.com/vercel/next.js.git",
"ref": "canary",
"commit": "1682685a357bb219a07499f1f846603d430ef796",
"commit": "00598045032a0e5b313de7b6ef0af60ed9390c2a",
"adapter": "skill-collection",
"sourcePath": "skills",
"syncedAt": "2026-06-29T15:59:59Z"
"syncedAt": "2026-07-05T16:00:01Z"
}
@@ -1,6 +1,6 @@
{
"name": "oh-my-codex",
"version": "0.18.16",
"version": "0.19.0",
"description": "oh-my-codex 是 Codex CLI 的多 Agent 编排、结构化工作流、插件级 hooks、MCP 和 HUD 扩展插件。",
"author": {
"name": "Yeachan Heo",
@@ -2,8 +2,8 @@
"sourceId": "oh-my-codex",
"repo": "https://github.com/Yeachan-Heo/oh-my-codex.git",
"ref": "main",
"commit": "d96b698c5f2fabc85c890f7b8fcdc6e02f253ec7",
"commit": "f947e3a41c062c25fd107686862b68ee5d1b66a5",
"adapter": "codex-plugin",
"sourcePath": "plugins/oh-my-codex",
"syncedAt": "2026-06-27T16:00:00Z"
"syncedAt": "2026-07-05T16:00:01Z"
}
@@ -162,6 +162,23 @@ function writeStopFallback(stopReason, detail) {
process.exitCode = 0;
}
function writeOversizedStopFallback(stopReason, detail) {
const reason =
'OMX plugin Stop rejected oversized stdin before launcher delegation; continue once with a smaller valid Stop JSON response.';
process.stdout.write(`${JSON.stringify({
decision: 'block',
reason,
stopReason,
systemMessage: detail ? `${reason} Failure: ${detail}` : reason,
})}\n`);
process.exitCode = 0;
}
function writeOversizedStopNoop() {
process.stdout.write('{}\n');
process.exitCode = 0;
}
function writeCompactFallback() {
process.exitCode = 0;
}
@@ -321,11 +338,6 @@ function parseSingleJsonObjectOutput(raw) {
}
}
function writeJsonNoop() {
process.stdout.write(`${JSON.stringify({})}\n`);
process.exitCode = 0;
}
async function main() {
const { input, oversized, totalBytes } = await readBoundedStdin();
const isStop = detectStopHookInput(input);
@@ -336,10 +348,10 @@ async function main() {
if (isStop) {
if (hasActiveAutopilotStateForOversizedStop(input)) {
console.error(`[oh-my-codex] ${message}`);
writeStopFallback('plugin_stop_hook_stdin_oversized_active_workflow', message);
writeOversizedStopFallback('plugin_stop_hook_stdin_oversized_active_workflow', message);
return;
}
writeJsonNoop();
writeOversizedStopNoop();
return;
}
console.error(`[oh-my-codex] ${message}`);
@@ -67,6 +67,22 @@ Delegates to the `code-reviewer` and `architect` agents in parallel for a two-la
- Else final recommendation follows the `code-reviewer` lane
- The final report must make architect blockers impossible to miss
## State/HUD Phase Contract
Code-review is a merge-readiness gate and Autopilot child phase, not a standalone tracked mode with a `code-review-state.json` lifecycle. Keep HUD/current-phase state explicit and minimal:
- **Standalone `$code-review` activation**: rely on the hook-owned `skill-active-state.json` entry (`skill:"code-review"`, `phase:"planning"`) for HUD visibility; do not create an ad-hoc `code-review-state.json`.
- **Inside active Autopilot**: before review work starts, keep `mode:"autopilot"` active and set the supervised phase to `current_phase:"code-review"` / skill-active `phase:"code-review"`; do not activate a peer workflow over Autopilot.
- **On clean review**: persist the review artifact/verdict under Autopilot `handoff_artifacts.code_review` and transition Autopilot to `current_phase:"ultraqa"` only after durable independent review evidence exists.
- **On non-clean review**: persist the review artifact/verdict, set Autopilot `current_phase:"rework"` for implementation-only fixes or `current_phase:"ralplan"` when requirements/planning must change, and keep the findings as the scoped handoff.
Minimal Autopilot phase declaration when the review stage begins:
```sh
omx state write --input '{"mode":"autopilot","active":true,"current_phase":"code-review"}' --json
```
## Agent Delegation
Do not self-review as a fallback. If the `code-reviewer` or `architect` agent path is missing, unavailable, skipped, or fails, emit a clear unavailable-review result and block approval until the independent lane evidence exists.
@@ -17,6 +17,29 @@ Use when the user asks for `ultragoal`, `create-goals`, `complete-goals`, durabl
Existing aggregate plans with the legacy enumerated objective are migrated to the stable pointer objective on read, persisted to `goals.json`, retained in `codexObjectiveAliases` for already-active hidden Codex goal reconciliation, and audited with an `aggregate_objective_migrated` ledger entry.
## State/HUD Phase Contract
Ultragoal is both a tracked workflow skill and the Autopilot durable-implementation child phase. Keep the phase/HUD contract explicit at workflow boundaries:
- **Standalone `$ultragoal` activation**: ensure `.omx/state[/sessions/<session>]/ultragoal-state.json` exists with `mode:"ultragoal"`, `active:true`, and a non-empty `current_phase` such as `planning` before or while goals are created. This state is a lightweight HUD/runtime declaration; `.omx/ultragoal/goals.json` and `ledger.jsonl` remain the durable goal source of truth.
- **During execution**: update `current_phase` to the smallest accurate phase (`planning`, `executing`, `verifying`, `reviewing`, `checkpointing`, or `blocked`) when the visible workflow phase changes.
- **Inside active Autopilot**: keep `mode:"autopilot"` active and set the supervised phase to `current_phase:"ultragoal"`; do not start a peer Autopilot replacement. Ultragoal's own mode state may still exist as child-phase detail, but Autopilot owns the parent phase.
- **On handoff to code-review**: persist implementation/test/ledger evidence under Autopilot `handoff_artifacts.ultragoal`, then set Autopilot `current_phase:"code-review"`.
- **On completion/blocker**: set standalone Ultragoal `active:false,current_phase:"complete"` only when all durable goals are complete; otherwise keep it active with a blocker/review-blocked phase and ledger evidence.
Minimal standalone phase declaration:
```sh
omx state write --input '{"mode":"ultragoal","active":true,"current_phase":"planning"}' --json
```
Minimal Autopilot child-phase declaration:
```sh
omx state write --input '{"mode":"autopilot","active":true,"current_phase":"ultragoal"}' --json
```
## Create goals
1. Run one of:
@@ -46,8 +46,9 @@ Use role to choose responsibility, tier to choose depth, and posture to choose o
- Best for steerable frontier models and leader-style roles.
- Prioritizes intent classification, delegation, verification, and architectural judgment.
- Typical roles: `planner`, `analyst`, `architect`, `critic`, `code-reviewer`.
- Ralplan keeps `planner` and `architect` in this posture but pins them to
exact `gpt-5.4-mini` with high reasoning; the `critic` consensus gate stays
- Ralplan keeps `planner` and `architect` in this posture; `planner`
uses exact `gpt-5.5` with medium reasoning, `architect` uses exact
`gpt-5.5` with xhigh reasoning, and the `critic` consensus gate stays
on the frontier lane.
- `deep-worker`:
+67 -61
View File
@@ -1,6 +1,6 @@
# PPT Master — AI generates natively editable PPTX from any document
[![Version](https://img.shields.io/badge/version-v2.11.0-blue.svg)](https://github.com/hugohe3/ppt-master/releases)
[![Version](https://img.shields.io/github/v/release/hugohe3/ppt-master?label=version&color=blue)](https://github.com/hugohe3/ppt-master/releases)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![GitHub stars](https://img.shields.io/github/stars/hugohe3/ppt-master.svg)](https://github.com/hugohe3/ppt-master/stargazers)
[![AtomGit stars](https://atomgit.com/hugohe3/ppt-master/star/badge.svg)](https://atomgit.com/hugohe3/ppt-master)
@@ -30,27 +30,22 @@ English | [中文](./README_CN.md)
</tr>
<tr>
<td width="180"><a href="https://www.compshare.cn/coding-plan?ytag=GPU_YY-git_pptmaster0624"><img src="docs/assets/sponsors/youyun.png" alt="YouYun ZhiSuan" width="150"></a></td>
<td>Thanks to YouYun ZhiSuan for sponsoring this project! YouYun ZhiSuan is UCloud's AI cloud platform, providing one-stop API services for mainstream domestic and international models, all accessible with a single key. The platform features cost-effective CodingPlan packages for domestic models (including GLM5.2, Deepseek-v4, and more), along with official channels for stable access to overseas models, meeting diverse development needs. It's compatible with mainstream AI coding tools like Claude Code and Codex, as well as general API calls. The platform supports enterprise-level high concurrency, 24/7 technical support, and self-service invoicing. Register through <a href="https://www.compshare.cn/coding-plan?ytag=GPU_YY-git_pptmaster0624">this link</a> to receive up to <strong>¥10 in free credits</strong> and get started right away.</td>
<td>Thanks to YouYun ZhiSuan for sponsoring this project! YouYun ZhiSuan is UCloud's AI cloud platform, providing one-stop API services for mainstream domestic and international models, all accessible with a single key. The platform features cost-effective CodingPlan packages for domestic models (including GLM5.2, Deepseek-v4, and more), along with official channels for stable access to overseas models, meeting diverse development needs. It's compatible with mainstream AI coding tools like Claude Code and Codex, as well as general API calls. The platform supports enterprise-level high concurrency, 24/7 technical support, and self-service invoicing. Register through <a href="https://www.compshare.cn/coding-plan?ytag=GPU_YY-git_pptmaster0624">this link</a> to receive up to <strong>¥10 in free credits</strong>. This project has been built into an Agent — <strong>PPT Master</strong> — ready to use without local deployment.</td>
</tr>
</table>
</details>
> [!IMPORTANT]
> ### This is a tool, not a wishing well
> Don't expect it to hand you a finished, perfect deck in one shot. Its real value is taking most of the tedious work off your plate; the polishing that's left is yours — a natively editable deck exists precisely so you can keep working on it, not a flat image you can't touch. The cheaper the model, the more there is to do. How good the result turns out comes down to your skill with this project and with PowerPoint.
> **AI generates your deck — it doesn't fill in a template.** PPT Master is a workflow that runs inside AI IDEs (Claude Code, Cursor, VS Code + Copilot, etc.): hand the AI your PDF / DOCX / web pages, and it produces a real PowerPoint on your machine — every element editable in PowerPoint, your data stays local, no platform or model lock-in. How it works and where the limits are → [Product Positioning](#product-positioning).
<p align="center">
<a href="https://hugohe3.github.io/ppt-master/"><strong>Live Demo</strong></a> ·
<a href="https://www.hehugo.com/"><strong>About Hugo He</strong></a> ·
<a href="./examples/"><strong>Examples</strong></a> ·
<a href="./docs/faq.md"><strong>FAQ</strong></a> ·
<a href="./docs/roadmap.md"><strong>Roadmap</strong></a> ·
<a href="mailto:heyug3@gmail.com"><strong>Contact</strong></a>
<a href="./docs/roadmap.md"><strong>Roadmap</strong></a>
</p>
<h3 align="center">Download the new <a href="https://raw.githubusercontent.com/hugohe3/ppt-master/main/examples/ppt169_attention_is_all_you_need/exports/attention_is_all_you_need_narrated.pptx">narrated <em>Attention Is All You Need</em> deck</a> — play it in PowerPoint and every slide reads itself out loud. That's just the tip of what PPT Master can do.</h3>
<h3 align="center">Of course, you can also download any of the six example decks below — opening the raw .pptx in PowerPoint is the fastest way to see this project's real capability ceiling.</h3>
<table>
<tr>
@@ -90,29 +85,16 @@ English | [中文](./README_CN.md)
</table>
<p align="center">
<sub>Generated with Claude Opus 4.7 + <code>gpt-image-2</code>. <a href="https://hugohe3.github.io/ppt-master/">Flip through all examples online →</a> · <a href="./examples/"><code>examples/</code> directory</a> · <a href="./docs/why-ppt-master.md">Why PPT Master?</a></sub>
<sub>All examples above were generated in a single pass, with no manual polish (Claude Opus 4.7 + <code>gpt-image-2</code>). Downloading any .pptx and opening it in PowerPoint is the fastest way to see what it can really do.<br/><a href="https://hugohe3.github.io/ppt-master/">Flip through all examples online →</a> · <a href="./examples/"><code>examples/</code> directory</a> · <a href="./docs/why-ppt-master.md">Why PPT Master?</a></sub>
</p>
---
Drop in your source material and get back a **real PowerPoint**: directly editable, with native slide transitions and entrance animations, speaker notes you can turn into audio narration, and the option to follow your own PPT template — a complete deck you can present as-is and keep editing. How to use each capability → [Getting Started](./docs/getting-started.md).
Drop in your source material, and the deck you get back is **more than just editable**: it has native slide transitions and entrance animations, speaker notes that can become audio narration, charts and tables that can ship as real data-backed PowerPoint objects, and it can follow your own PPT template — a complete deck you can present as-is and keep refining. How to use each capability → [Getting Started](./docs/getting-started.md).
> **⚠️ PPT Master is a harness, not a complete agent.** `harness + model = agent` — the tool owns the workflow; the model sets the ceiling. To form a genuinely high-quality agent, use **Claude with a large context window (~1M tokens) + AI image generation (`gpt-image-2`)**. Other models can run the pipeline but cannot reach the same quality ceiling. If results disappoint, upgrade the model — don't blame the harness.
## Product Positioning
> **How it works** — PPT Master is a workflow (a "skill") that works inside AI IDEs like Claude Code, Cursor, VS Code + Copilot, or Codebuddy. You chat with the AI — "make a deck from this PDF" — and it follows the workflow to produce a real editable `.pptx` on your computer. No coding on your side; the IDE is just where the conversation happens.
>
> **What you'll do**: install Python, install an AI IDE, drop in your material.
> **Why it's shaped this way** — knowing how to use Python and AI agents will matter more and more. This project is meant to show how far you can go with just those two things. There's a learning curve if you're starting cold, but it's the curve worth climbing. Making a deck is just the excuse — what I'm really pushing is Python and agents.
PPT Master is different:
- **Real PowerPoint** — if a file can't be opened and edited in PowerPoint, it shouldn't be called a PPT. Every element PPT Master outputs is directly clickable and editable
- **Transparent, predictable cost** — the tool is free and open source; the only cost is your AI model usage. As AI tools move to usage-based billing, you pay exactly what you consume — no separate PPT subscription added on top
- **Data stays local** — your files shouldn't have to be uploaded to someone else's server just to make a presentation. Apart from AI model communication, the entire pipeline runs on your machine
- **No platform lock-in** — your workflow shouldn't be held hostage by any single company. Works with Claude Code, Cursor, VS Code Copilot, and more; supports Claude, GPT, Gemini, Kimi, and other models
AI presentation tools roughly fall into four categories. PPT Master only does the last one:
**If a file can't be opened and edited in PowerPoint, it shouldn't be called a PPT.** AI presentation tools roughly fall into four categories, and PPT Master only does the last one:
| Category | Output | Editable element-by-element in PowerPoint? |
|---|---|:---:|
@@ -121,13 +103,19 @@ AI presentation tools roughly fall into four categories. PPT Master only does th
| HTML presentation | Web-based deck | ❌ not a PPTX |
| **Native editable (PPT Master)** | **Real DrawingML shapes, text boxes, charts** | ✅ click any element to edit |
---
In form, it's not a website or an app but a workflow (a "skill") that runs inside AI IDEs like Claude Code, Cursor, VS Code + Copilot, or Codebuddy: you tell the AI in the IDE's chat — "make a deck from this PDF" — and it follows the workflow to produce a genuinely editable `.pptx` on your machine. No coding on your side; you do exactly three things — install Python, install an AI IDE, drop in your material.
## The person using it matters more
This form buys three promises that other tools struggle to make at the same time:
The examples above were all made in a single pass — I didn't even refine them; spend some time polishing and it's a different story entirely. With the same PowerPoint, a designer can produce something stunning while most people only ever touch a few basic features — the difference isn't the tool, it's the person using it. If you can't get there yet, it's most likely that you haven't learned the workflow — start with [Getting Started](./docs/getting-started.md) and the example projects.
- **Transparent, predictable cost** — the tool is free and open source; the only cost is your AI model usage. You pay exactly what you consume — no separate PPT subscription added on top
- **Data stays local** — your files shouldn't have to be uploaded to someone else's server just to make a presentation. Apart from AI model communication, the entire pipeline runs on your machine
- **No platform lock-in** — your workflow shouldn't be held hostage by any single company. Works with Claude Code, Cursor, VS Code Copilot, and more; supports Claude, GPT, Gemini, Kimi, and other models
The best results do need Claude. Before you call it expensive, think about what it would cost to hire someone to produce a deck at the same level. The project also supports GPT, Gemini, Kimi, and other models — the results simply differ. Expecting top-tier output while paying the lowest possible cost was never reasonable to begin with.
> [!IMPORTANT]
> ### This is a tool, not a wishing well
> `harness + model = agent` — PPT Master only owns the workflow; the model sets the ceiling. Recommended: **Claude with a large context window (~1M tokens) + AI image generation (`gpt-image-2`)**; other models can run the pipeline, with a quality gap.
>
> And don't expect a finished, perfect deck in one shot. The tool's value is taking most of the tedious work off your plate; the polishing that's left is yours — a natively editable deck exists precisely so you can keep working on it, not a flat image you can't touch. The cheaper the model, the more there is to do; if results disappoint, upgrade the model first, then check your usage against [Getting Started](./docs/getting-started.md) and the example projects.
---
@@ -135,7 +123,7 @@ The best results do need Claude. Before you call it expensive, think about what
I'm a finance professional (CPA · CPV · Consulting Engineer (Investment)) who regularly reviews and edits presentation decks. I wanted AI-generated slides to remain editable in PowerPoint, not flattened into images — so I built this.
🌐 [Personal website](https://www.hehugo.com/) · 📧 [heyug3@gmail.com](mailto:heyug3@gmail.com) · 🐙 [@hugohe3](https://github.com/hugohe3)
Knowing how to use Python and AI agents will matter more and more, and this project is also meant to show how far you can go with just those two things. There's a learning curve if you're starting cold, but it's the curve worth climbing — making a deck is just the excuse; what I'm really pushing is Python and agents.
---
@@ -143,22 +131,16 @@ I'm a finance professional (CPA · CPV · Consulting Engineer (Investment)) who
### 1. Prerequisites
**You only need Python.** Everything else is installed via `pip install -r requirements.txt`.
**All you need to install is [Python](https://www.python.org/downloads/) 3.10+.** Everything else comes with one line — `pip install -r requirements.txt` — after you download the project in Step 3.
| Dependency | Required? | What it does |
|------------|:---------:|--------------|
| [Python](https://www.python.org/downloads/) 3.10+ | ✅ **Yes** | Core runtime — the only thing you actually need to install |
> **TL;DR** — Install Python, run `pip install -r requirements.txt`, and you're ready to generate presentations.
<details open>
<summary><strong>Windows</strong> — see the dedicated step-by-step guide ⚠️</summary>
<details>
<summary><strong>Windows</strong> — see the dedicated <a href="./docs/windows-installation.md">step-by-step guide</a> ⚠️</summary>
Windows requires a few extra steps (PATH setup, execution policy, etc.). We wrote a **step-by-step guide** specifically for Windows users:
**📖 [Windows Installation Guide](./docs/windows-installation.md)** — from zero to a working presentation in 10 minutes.
Quick version: download Python from [python.org](https://www.python.org/downloads/) → **check "Add to PATH"** during install → `pip install -r requirements.txt` → done.
Quick version: download Python from [python.org](https://www.python.org/downloads/) → **check "Add to PATH"** during install → done; dependencies are installed in Step 3.
</details>
<details>
@@ -167,11 +149,9 @@ Quick version: download Python from [python.org](https://www.python.org/download
```bash
# macOS
brew install python
pip install -r requirements.txt
# Ubuntu / Debian
sudo apt install python3 python3-pip
pip install -r requirements.txt
```
</details>
@@ -193,11 +173,15 @@ sudo apt install pandoc
PPT Master runs in **any tool with agent capability** — read/write files, execute commands, and sustain multi-turn conversation.
Never used one of these? Don't worry — in this project they play exactly one role: an AI chat window that can read and write files. Pick any tool from the table, install it, and you'll only ever use its chat panel. No coding involved.
> **Author's pick: [Claude Code](https://claude.ai/code)** — the environment this project is developed and tested on most thoroughly, as the CLI or the VS Code / JetBrains extension.
| Type | Examples | Notes |
|---|---|---|
| **IDE-native agent** | • VS Code architecture ([VS Code](https://code.visualstudio.com/) itself, plus forks & derivatives): [Cursor](https://cursor.sh/), Trae, Codebuddy IDE, [Windsurf](https://codeium.com/windsurf), Void, etc.<br>• Other architectures: [Zed](https://zed.dev/), etc. | Editor with a built-in agent |
| **IDE plugin / extension** | [GitHub Copilot](https://github.com/features/copilot), [Claude Code](https://claude.ai/code) (VS Code / JetBrains extension), [Cline](https://cline.bot/), [Continue](https://continue.dev/), Roo Code, etc. | Installed inside hosts like VS Code or JetBrains |
| **CLI agent** | [Claude Code](https://claude.ai/code) CLI, [Codex CLI](https://github.com/openai/codex), [Aider](https://aider.chat/), Gemini CLI, etc. | Runs in the terminal; suits scripting, remote, or server use |
| **IDE-native agent** | • VS Code architecture ([VS Code](https://code.visualstudio.com/) itself, plus forks & derivatives): [Cursor](https://cursor.sh/), Trae, Codebuddy IDE, [Windsurf](https://codeium.com/windsurf), etc.<br>• Other architectures: [Zed](https://zed.dev/), etc. | Editor with a built-in agent |
| **IDE plugin / extension** | [Claude Code](https://claude.ai/code) (VS Code / JetBrains extension), [GitHub Copilot](https://github.com/features/copilot), [Cline](https://cline.bot/), etc. | Installed inside hosts like VS Code or JetBrains |
| **CLI agent** | [Claude Code](https://claude.ai/code) CLI, [Codex CLI](https://github.com/openai/codex), Gemini CLI, etc. | Runs in the terminal; suits scripting, remote, or server use |
> **Model recommendation**: for the best results, use **Claude Opus** with `gpt-image-2`; **Gemini 3.5 Flash** currently offers great overall value for money — notably fast and well worth a try.
@@ -207,9 +191,11 @@ PPT Master runs in **any tool with agent capability** — read/write files, exec
### 3. Set Up
**Option A — Download ZIP** (no Git required): click **Code → Download ZIP** on the [GitHub page](https://github.com/hugohe3/ppt-master), then unzip.
**Option A — Download ZIP** (no Git required; best for a quick trial): click **Code → Download ZIP** on the [GitHub page](https://github.com/hugohe3/ppt-master), then unzip.
**Option B — Git clone** (requires [Git](https://git-scm.com/downloads) installed):
If you plan to keep using PPT Master and update it over time, use Git clone instead.
**Option B — Git clone** (recommended; requires [Git](https://git-scm.com/downloads) installed):
```bash
git clone https://github.com/hugohe3/ppt-master.git
@@ -222,7 +208,23 @@ Then install dependencies:
pip install -r requirements.txt
```
To update later (Option A / B): `python3 skills/ppt-master/scripts/update_repo.py`
#### Updating Later
**Git clone installs:**
```bash
python3 skills/ppt-master/scripts/update_repo.py
```
The script pulls the latest version and syncs Python dependencies when `requirements.txt` changes.
**Download ZIP installs:**
ZIP folders do not include Git history, so they cannot run `git pull`. To update, download the latest ZIP, unzip it into a new folder, copy your old `.env` and `projects/` folder into the new folder, then run:
```bash
pip install -r requirements.txt
```
> **Option C — Skill marketplace**: the repo ships `.claude-plugin/marketplace.json`, so it can be installed through the [Claude Code plugin marketplace](https://code.claude.com/docs/en/plugin-marketplaces) ecosystem:
>
@@ -239,6 +241,8 @@ To update later (Option A / B): `python3 skills/ppt-master/scripts/update_repo.p
### 4. Create
**First, open the project folder in your agent:** the goal is to point the AI at the `ppt-master` directory you unzipped / cloned in the previous step. In an IDE-type tool, use **File → Open Folder** — the AI chat panel is usually in the sidebar; in a CLI agent, `cd ppt-master` first, then launch it. Everything from here on happens in the chat.
**Provide source materials (recommended):** Place your PDF, DOCX, images, or other files in the `projects/` directory, then tell the AI chat panel which files to use. The quickest way to get the path: right-click the file in your file manager or IDE sidebar → **Copy Path** (or **Copy Relative Path**) and paste it directly into the chat.
```
@@ -263,19 +267,26 @@ AI: Sure. Let's confirm the design spec:
The AI handles everything — content analysis, visual design, SVG generation, and PPTX export.
> **Output:** Native-shapes `.pptx` (directly editable) saved to `exports/<name>_<timestamp>.pptx`. A copy of `svg_output/` is always snapshotted to `backup/<timestamp>/svg_output/` for re-export / archival. Pass `--svg-snapshot` to additionally emit an SVG-image preview pptx alongside the native pptx in `exports/` (see [FAQ](./docs/faq.md)). Requires Office 2016+.
> **Output:** Native-shapes `.pptx` (directly editable) saved to `exports/<name>_<timestamp>.pptx`. A copy of `svg_output/` is always snapshotted to `backup/<timestamp>/svg_output/` for re-export / archival. Pass `--svg-snapshot` to additionally emit an SVG-image preview pptx alongside the native pptx in `exports/` (see [FAQ](./docs/faq.md)). Requires Office 2016+. By default charts and tables export as SVG-derived shapes (pixel-consistent across PowerPoint / Keynote / WPS); pass `--native-objects` to instead emit them as **real editable PowerPoint chart / table objects backed by data** (rendering may vary across apps), saved as `exports/<name>_<timestamp>_native_charts.pptx`.
> **Already have a `.pptx` you want to reuse?** Hand the AI that deck plus your material and ask it to "fill this deck with the new content" — it fills text, table, and chart data into your existing design and exports only the pages you pick, staying natively editable. See the [FAQ](./docs/faq.md) and [template-fill workflow](./skills/ppt-master/workflows/template-fill-pptx.md).
> **AI lost context?** Ask it to read `skills/ppt-master/SKILL.md`.
> **Something went wrong?** Check the **[FAQ](./docs/faq.md)** — it covers model selection, layout issues, export problems, and more. Continuously updated from real user reports.
> **Something went wrong?** If the AI loses context, ask it to read `skills/ppt-master/SKILL.md`; for everything else, check the **[FAQ](./docs/faq.md)** — it covers model selection, layout issues, export problems, and more. Continuously updated from real user reports.
### 5. Image Acquisition (Optional)
Two paths for non-user images, mixable per row in the same deck:
Two paths for non-user images, mixable per image in the same deck:
For API-backed features, put credentials in `.env`. Clone installs can use `cp .env.example .env`; skill marketplace installs should use a persistent user config:
**A) AI generation**`image_gen.py`. Set `IMAGE_BACKEND` plus the provider's `*_API_KEY` (`OPENAI_API_KEY`, `GEMINI_API_KEY`, etc.), and the pipeline calls it automatically. Run `python3 skills/ppt-master/scripts/image_gen.py --list-backends` for the full backend list. `gpt-image-2` is currently the best default.
**B) Web image search**`image_search.py`. **Zero-config works**; configure `PEXELS_API_KEY` / `PIXABAY_API_KEY` (both free) for consistently higher-quality results:
- Without keys, search uses Openverse / Wikimedia Commons only — useful as a fallback, but image quality can be uneven because many results are ordinary user uploads
- With keys, the default provider chain also appends Pexels / Pixabay, which materially improves modern stock photography, people, workplace, lifestyle, and illustration coverage
- Licensing is handled automatically: CC0, Public Domain, Pexels / Pixabay no-attribution licenses, CC BY, and CC BY-SA are all considered together, and Executor adds a small inline credit whenever the selected image requires attribution. Use `--strict-no-attribution` only when a slide cannot tolerate any credit line
- For high-impact covers, product shots, portraits, and branded scenes, prefer this order: user-provided high-resolution assets / AI generation > web search with Pexels / Pixabay keys > zero-config web search
The API keys above all live in `.env`. Clone installs can use `cp .env.example .env`; skill marketplace installs should use a persistent user config:
```bash
mkdir -p ~/.ppt-master
@@ -284,10 +295,6 @@ cp /path/to/installed/ppt-master/.env.example ~/.ppt-master/.env
PPT Master reads the current process environment first, then the first `.env` found in this order: current working directory, skill directory (e.g. `~/.agents/skills/ppt-master/.env`), clone repo root, `~/.ppt-master/.env`.
**A) AI generation**`image_gen.py`. Set `IMAGE_BACKEND` plus the provider's `*_API_KEY` (`OPENAI_API_KEY`, `GEMINI_API_KEY`, etc.), and the pipeline calls it automatically. Run `python3 skills/ppt-master/scripts/image_gen.py --list-backends` for the full backend list. `gpt-image-2` is currently the best default.
**B) Web image search**`image_search.py`. **Zero-config works**, but configure `PEXELS_API_KEY` / `PIXABAY_API_KEY` (both free) for higher-quality results. Without keys, search uses Openverse / Wikimedia Commons only; this is useful as a fallback, but image quality can be uneven because many results are ordinary user uploads. With keys, the default provider chain also appends Pexels / Pixabay, which materially improves modern stock photography, people, workplace, lifestyle, and illustration coverage. The default is quality-first: CC0, Public Domain, Pexels / Pixabay no-attribution licenses, CC BY, and CC BY-SA are considered together, and Executor adds a small inline credit whenever the selected image requires attribution. Use `--strict-no-attribution` only when a slide cannot tolerate any credit line. For high-impact covers, product shots, portraits, and branded scenes, prefer this order: user-provided high-resolution assets / AI generation > web search with Pexels / Pixabay keys > zero-config web search.
> Full reference: [`image-generator.md`](./skills/ppt-master/references/image-generator.md) (AI) · [`image-searcher.md`](./skills/ppt-master/references/image-searcher.md) (web).
---
@@ -322,7 +329,7 @@ See [CONTRIBUTING.md](./CONTRIBUTING.md) for how to get involved.
## Related Tools
[cc-switch](https://github.com/farion1231/cc-switch) — a cross-platform desktop app to manage and one-click switch API providers across Claude Code, Codex, Gemini CLI and more. Handy when running PPT Master inside any of these agents.
[cc-switch](https://github.com/farion1231/cc-switch) — one-click switching of API providers across Claude Code / Codex / Gemini CLI and more.
## Contact & Collaboration
@@ -330,7 +337,6 @@ Looking to collaborate, integrate PPT Master into your workflow, or just have qu
- 💬 **Questions & sharing** — [GitHub Discussions](https://github.com/hugohe3/ppt-master/discussions)
- 🐛 **Bug reports & feature requests** — [GitHub Issues](https://github.com/hugohe3/ppt-master/issues)
- 🌐 **Learn more about the author** — [www.hehugo.com](https://www.hehugo.com/)
---
@@ -2,8 +2,8 @@
"sourceId": "ppt-master",
"repo": "https://github.com/hugohe3/ppt-master.git",
"ref": "main",
"commit": "f888bff138f96e9e5e2e43f8c90c66695e979f60",
"commit": "eb9243be673d0019dc1c7998262214a9a977a7d4",
"adapter": "claude-skill",
"sourcePath": "skills/ppt-master",
"syncedAt": "2026-06-29T15:59:59Z"
"syncedAt": "2026-07-05T16:00:01Z"
}
@@ -16,7 +16,7 @@ description: "多格式源文档到高质量 SVG 页面再导出 PPTX 的多阶
>
> 1. **SERIAL EXECUTION** — Steps MUST be executed in order; the output of each step is the input for the next. Non-BLOCKING adjacent steps may proceed continuously once prerequisites are met, without waiting for the user to say "continue"
> 2. **BLOCKING = HARD STOP** — Steps marked ⛔ BLOCKING require a full stop; the AI MUST wait for an explicit user response before proceeding and MUST NOT make any decisions on behalf of the user
> 3. **NO CROSS-PHASE BUNDLING** — Cross-phase bundling is FORBIDDEN. (Note: the Eight Confirmations in Step 4 are ⛔ BLOCKING — the AI MUST present recommendations and wait for explicit user confirmation before proceeding. Once the user confirms, all subsequent non-BLOCKING steps — design spec output, SVG generation, speaker notes, and post-processing — may proceed automatically without further user confirmation)
> 3. **NO CROSS-PHASE BUNDLING** — Cross-phase bundling is FORBIDDEN. (Note: the Strategist confirmation stage in Step 4 is ⛔ BLOCKING — the AI MUST present recommendations and wait for explicit user confirmation before proceeding. Once the user confirms, all subsequent non-BLOCKING steps — design spec output, SVG generation, speaker notes, and post-processing — may proceed automatically without further user confirmation)
> 4. **GATE BEFORE ENTRY** — Each Step has prerequisites (🚧 GATE) listed at the top; these MUST be verified before starting that Step
> 5. **NO SPECULATIVE EXECUTION** — "Pre-preparing" content for subsequent Steps is FORBIDDEN (e.g., writing SVG code during the Strategist phase)
> 6. **NO SUB-AGENT SVG GENERATION** — Executor Step 6 SVG generation is context-dependent and MUST be completed by the current main agent end-to-end. Delegating page SVG generation to sub-agents is FORBIDDEN
@@ -65,12 +65,8 @@ description: "多格式源文档到高质量 SVG 页面再导出 PPTX 的多阶
| Script | Purpose |
|--------|---------|
| `${SKILL_DIR}/scripts/source_to_md/pdf_to_md.py` | PDF to Markdown |
| `${SKILL_DIR}/scripts/source_to_md/doc_to_md.py` | Documents to Markdown — native Python for DOCX/HTML/EPUB/IPYNB, pandoc fallback for legacy formats (.doc/.odt/.rtf/.tex/.rst/.org/.typ) |
| `${SKILL_DIR}/scripts/source_to_md/excel_to_md.py` | Excel workbooks to Markdown — supports .xlsx/.xlsm; legacy .xls should be resaved as .xlsx |
| `${SKILL_DIR}/scripts/source_to_md/ppt_to_md.py` | PowerPoint to Markdown |
| `${SKILL_DIR}/scripts/source_to_md.py` | Unified source-to-Markdown dispatcher — default Step 1 entry for explicit file(s) or URL(s) |
| `${SKILL_DIR}/scripts/pptx_intake.py` | Standard PPTX intake enrichment — canvas / identity / slide geometry / tables / native chart data |
| `${SKILL_DIR}/scripts/source_to_md/web_to_md.py` | Web page to Markdown (supports WeChat via `curl_cffi`) |
| `${SKILL_DIR}/scripts/project_manager.py` | Project init / validate / manage |
| `${SKILL_DIR}/scripts/icon_sync.py` | Copy chosen library icons into `<project>/icons/` at selection time; missing names reported + non-zero (re-pick gate) |
| `${SKILL_DIR}/scripts/analyze_images.py` | Image analysis |
@@ -110,7 +106,7 @@ For complete tool documentation, see `${SKILL_DIR}/scripts/README.md`.
|---|---|
| Raw PPTX template plus new material/topic, generate a PPTX | [`template-fill-pptx`](workflows/template-fill-pptx.md) |
| Existing PPTX, preserve page count/order and slide wording 1:1, improve layout | [`beautify-pptx`](workflows/beautify-pptx.md) |
| Existing PPTX as source material, rethink outline or change page count/order | Main pipeline via `ppt_to_md.py` plus PPTX intake |
| Existing PPTX as source material, rethink outline or change page count/order | Main pipeline via `source_to_md.py` plus PPTX intake |
| Build a reusable template package from a PPTX/design reference | [`create-template`](workflows/create-template.md), then return with the generated template directory path |
| Finished PPTX, keep content/layout stable and add notes/audio/timing/transitions | [`native-enhance-pptx`](workflows/native-enhance-pptx.md) |
@@ -130,23 +126,27 @@ For complete tool documentation, see `${SKILL_DIR}/scripts/README.md`.
> **No source content?** When the user supplies only a topic name or requirements without any file or substantive description, run the [`topic-research`](workflows/topic-research.md) workflow first, then return here with its products as input.
When the user provides non-Markdown content, convert immediately:
When the user provides non-Markdown content, convert immediately through the
unified dispatcher. It preserves the backend converters' existing behavior,
routes by source type, and writes the standard Markdown plus conversion profile.
| User Provides | Command |
|---------------|---------|
| PDF file | `python3 ${SKILL_DIR}/scripts/source_to_md/pdf_to_md.py <file>` |
| DOCX / Word / Office document | `python3 ${SKILL_DIR}/scripts/source_to_md/doc_to_md.py <file>` |
| XLSX / XLSM / Excel workbook | `python3 ${SKILL_DIR}/scripts/source_to_md/excel_to_md.py <file>` |
| User Provides | Action |
|---------------|--------|
| PDF / DOCX / Office document / XLSX / XLSM / PPTX / EPUB / HTML / LaTeX / RST / web URL | `python3 ${SKILL_DIR}/scripts/source_to_md.py <file_or_URL_or_dir> [<file_or_URL_or_dir> ...]` |
| CSV / TSV | Read directly as plain-text table source |
| PPTX / PowerPoint deck | `python3 ${SKILL_DIR}/scripts/source_to_md/ppt_to_md.py <file>` for Markdown content; after Step 2 `import-sources`, standard PPTX intake is also written to `<project>/analysis/` |
| EPUB / HTML / LaTeX / RST / other | `python3 ${SKILL_DIR}/scripts/source_to_md/doc_to_md.py <file>` |
| Web link | `python3 ${SKILL_DIR}/scripts/source_to_md/web_to_md.py <URL>` |
| WeChat / high-security site | `python3 ${SKILL_DIR}/scripts/source_to_md/web_to_md.py <URL>` (requires `curl_cffi`, included in `requirements.txt`) |
| Markdown | Read directly |
For PPTX sources, Step 1 converts the deck to Markdown content; after Step 2
`import-sources`, standard PPTX intake is also written to `<project>/analysis/`.
Use `source_to_md.py -t <type>` only when extension detection is ambiguous.
Default local conversion writes Markdown/profile outputs beside each source file.
Use `-o` only when a specific output file/directory is required; with multiple
inputs or directory inputs, `-o` is an output directory. Backend converter details are documented in
[`scripts/docs/conversion.md`](scripts/docs/conversion.md).
> **Office vector assets (EMF/WMF) from DOCX/PPTX sources**:
> `doc_to_md.py` / `ppt_to_md.py` extract embedded Office vector images (.emf/.wmf)
> alongside bitmap images. After `import-sources`, these land in `images/`
> Source conversion extracts embedded Office vector images (.emf/.wmf)
> alongside bitmap images when the source format exposes them. After `import-sources`, these land in `images/`
> together with `image_manifest.json` and are first-class assets in §VIII Image Resource List.
>
> **Do NOT convert EMF/WMF to PNG.** The PPT Master pipeline preserves them as external
@@ -176,7 +176,7 @@ Import source content (choose based on the situation):
| Situation | Action |
|-----------|--------|
| Has source files (PDF/MD/etc.) | `python3 ${SKILL_DIR}/scripts/project_manager.py import-sources <project_path> <source_files...> --move` |
| Has source files (PDF/MD/etc.) | `python3 ${SKILL_DIR}/scripts/project_manager.py import-sources <project_path> <source_files_or_dirs...> --move` |
| User provided text directly in conversation | No import needed — content is already in conversation context; subsequent steps can reference it directly |
For PPTX sources, `import-sources` automatically runs the standard intake enrichment:
@@ -189,7 +189,7 @@ For each PPTX it writes `<stem>.identity.json` (canvas, theme palette/fonts, obs
Multi-deck: several PPTX files may be imported into one main-pipeline project — each gets its own `<stem>.*` artifacts and a deck entry in `source_profile.json`. `source_profile.json` stays the single must-read index (one entry for a one-deck project, several for a combined-source project). Stems must be distinct; re-importing the same stem replaces that deck's entry. The beautify / template-fill workflows remain single-deck (1:1 to one chosen source deck) and read that deck's `<stem>.*` artifacts.
> ⚠️ **MUST use `--move`** (not copy): all source files — Step 1's generated Markdown, original PDFs / MDs / images — go into `sources/` via `import-sources --move`. After execution they no longer exist at the original location. Intermediate artifacts (e.g., `_files/`) are handled automatically.
> ⚠️ **MUST use `--move`** (not copy): all source files — Step 1's generated Markdown, original PDFs / MDs / images — go into `sources/` via `import-sources --move`. If Step 1 wrote Markdown beside the original sources, pass that source path/directory once. If Step 1 used `-o` to write Markdown elsewhere, pass both the original source path(s)/directory and the Markdown output path(s)/directory. After execution they no longer exist at the original location. Intermediate artifacts (e.g., `_files/`) are handled automatically.
**✅ Checkpoint — Confirm project structure created successfully, `sources/` contains all source files, converted materials are ready. Proceed to Step 3.**
@@ -214,7 +214,7 @@ Do **not** reinterpret this boundary as 1:1 redesign or free SVG generation. Use
There is no slug matching, no name lookup, no fuzzy resolution. A name without a path does not trigger — the user must give a path the AI can `cd` into.
> Style descriptions ("麦肯锡风格" / "Keynote 风" / "极简风" / etc.) never trigger Step 3. They flow into Strategist's Eight Confirmations as a style brief (color / typography / tone in confirmations eg).
> Style descriptions ("麦肯锡风格" / "Keynote 风" / "极简风" / etc.) never trigger Step 3. They flow into the Strategist confirmation stage as a style brief (color / typography / tone in fields eg).
> Bare names ("academic_defense", "招商银行", "anthropic") do NOT trigger Step 3 even if a matching directory exists in the library. The user must give a path. AI must not "helpfully" resolve a name to a path.
@@ -245,8 +245,8 @@ The architecture has three independent reference bundles. Full schema in [`docs/
| User path's `kind` | Step 3 action |
|---|---|
| `kind: brand` | `design_spec.md` + non-image assets → `<project>/templates/`; logo / illustration / icon **bitmaps**`<project>/images/`. Strategist locks identity segment as truth; structure stays free. |
| `kind: layout` | `design_spec.md` + SVG roster → `<project>/templates/`; any **bitmap** assets → `<project>/images/`. Strategist locks structure; identity decided in Eight Confirmations eg. |
| `kind: deck` | `design_spec.md` + template SVGs → `<project>/templates/`; logos / backgrounds / other **bitmaps**`<project>/images/`. Strategist locks all segments; Eight Confirmations narrows to deck-content fields (audience / page count / outline / tone tweaks). |
| `kind: layout` | `design_spec.md` + SVG roster → `<project>/templates/`; any **bitmap** assets → `<project>/images/`. Strategist locks structure; identity decided in Strategist confirmation stage eg. |
| `kind: deck` | `design_spec.md` + template SVGs → `<project>/templates/`; logos / backgrounds / other **bitmaps**`<project>/images/`. Strategist locks all segments; Strategist confirmation stage narrows to deck-content fields (audience / page count / outline / tone tweaks). |
```bash
TEMPLATE_DIR=<user-supplied path>
@@ -275,7 +275,7 @@ Override priority by segment:
| layout + deck | deck | layout (overrides deck) | deck |
| brand + layout + deck | brand | layout | deck |
Field-level micro-adjustment (e.g. "use anthropic brand but primary changed to #FF0000") is **not** part of Step 3 fusion — it flows into Strategist Eight Confirmations eg as a normal user request.
Field-level micro-adjustment (e.g. "use anthropic brand but primary changed to #FF0000") is **not** part of Step 3 fusion — it flows into Strategist confirmation stage eg as a normal user request.
#### Same-kind multiple paths — conflict resolution
@@ -329,13 +329,13 @@ Read references/strategist.md
**Artifact ownership**: fact-channel and source/derived artifact boundaries are defined in [`references/artifact-ownership.md`](references/artifact-ownership.md). This Step uses those ownership rules; it does not redefine them.
**`<project_path>/analysis/` is the project's intermediate-analysis folder: the canonical home for machine-extracted source/asset facts — the PPTX intake bundle (`source_profile.json` index + per-deck `<stem>.identity.json` / `<stem>.slide_library.json`) and `image_analysis.csv`. It holds facts, not design contracts — `design_spec.md` / `spec_lock.md` stay at the project root.** The MUST-read contract covers only the **compact structured data files (`.json` / `.csv`)**; other artifacts that may live under `analysis/` (e.g. a beautify `source_svg_import/` vector reference package) are NOT bulk-read — they are read selectively only when a specific workflow step calls for them. Before the Eight Confirmations, Strategist MUST read the auto-extracted fact files already in `analysis/` — currently `source_profile.json` (PPTX intake), when present. This file is the multi-deck index: read it once for the `decks[]` digests (canvas / chart / table entries per source deck), then open a specific deck's `<stem>.identity.json` / `<stem>.slide_library.json` only if you need its full raw facts. Use these entries as **factual source context** (format default + content facts); when several decks are present, synthesize across all of them. The source's **palette / typography / visual identity are a reference, not a constraint**: the main pipeline may inherit them where they fit the content and the confirmed style, or design fresh where they don't — the Strategist's judgment, never an obligation to either keep or discard. (Template-fill preserves the native source design by editing cloned slides directly; beautify defaults to the source identity but still follows the confirmed values; the main pipeline treats source identity as reference only and defaults to fresh design.) (`image_analysis.csv` lands later, at the image-analysis step below, and is the authoritative regenerated image-fact view there — re-derived from the live `images/` folder, not a durable store.)
**`<project_path>/analysis/` is the project's intermediate-analysis folder: the canonical home for machine-extracted source/asset facts — the PPTX intake bundle (`source_profile.json` index + per-deck `<stem>.identity.json` / `<stem>.slide_library.json`) and `image_analysis.csv`. It holds facts, not design contracts — `design_spec.md` / `spec_lock.md` stay at the project root.** The MUST-read contract covers only the **compact structured data files (`.json` / `.csv`)**; other artifacts that may live under `analysis/` (e.g. a beautify `source_svg_import/` vector reference package) are NOT bulk-read — they are read selectively only when a specific workflow step calls for them. Before the Strategist confirmation stage, Strategist MUST read the auto-extracted fact files already in `analysis/` — currently `source_profile.json` (PPTX intake), when present. This file is the multi-deck index: read it once for the `decks[]` digests (canvas / chart / table entries per source deck), then open a specific deck's `<stem>.identity.json` / `<stem>.slide_library.json` only if you need its full raw facts. Use these entries as **factual source context** (format default + content facts); when several decks are present, synthesize across all of them. The source's **palette / typography / visual identity are a reference, not a constraint**: the main pipeline may inherit them where they fit the content and the confirmed style, or design fresh where they don't — the Strategist's judgment, never an obligation to either keep or discard. (Template-fill preserves the native source design by editing cloned slides directly; beautify defaults to the source identity but still follows the confirmed values; the main pipeline treats source identity as reference only and defaults to fresh design.) (`image_analysis.csv` lands later, at the image-analysis step below, and is the authoritative regenerated image-fact view there — re-derived from the live `images/` folder, not a durable store.)
**Channel ownership — read each fact once from its owning channel.** In the main pipeline the **content contract is the Markdown** (`sources/<stem>.md`): text, tables, and chart data values all come from there (`ppt_to_md` now transcribes native chart data into Markdown tables). The `analysis/` chart / table entries are a **structural digest** for outline decisions (which slides carried charts, type, series names) — not a second copy of the values; do NOT also pull chart values from `<stem>.slide_library.json` in the main pipeline. The `<stem>.slide_library.json` full structured data is owned by the direct-PPTX workflows: template-fill uses it as the native fill contract; beautify uses it for native chart / table data while keeping slide text from the Markdown.
**Channel ownership — read each fact once from its owning channel.** In the main pipeline the **content contract is the content-type files in `sources/`** — primarily `<stem>.md`, but also any user-supplied content the import archived there: `.md` / `.markdown` / `.txt` / `.csv` / `.tsv` / `.json` / `.jsonl` / `.yaml` / `.yml` (a `metrics.json` or `data.csv` may carry core content — judge by what the file holds). Text, tables, and chart data values come from these (`ppt_to_md` now transcribes native chart data into Markdown tables). **Do NOT read pipeline sidecars in `sources/` as content**: `*.conversion_profile.json` (conversion audit) and `*_files/image_manifest.json` (asset index) are process metadata — open them only to audit a conversion or resolve assets, never as slide content. Converted-source originals archived in `sources/` (`.pdf` / `.pptx` / `.docx` / `.xlsx` / `.html` / `.epub` / `.tex` / `.rst` / `.ipynb` / `.typ`, etc.) are read via their converted `<stem>.md`, not scanned directly in the main pipeline. The `analysis/` chart / table entries are a **structural digest** for outline decisions (which slides carried charts, type, series names) — not a second copy of the values; do NOT also pull chart values from `<stem>.slide_library.json` in the main pipeline. The `<stem>.slide_library.json` full structured data is owned by the direct-PPTX workflows: template-fill uses it as the native fill contract; beautify uses it for native chart / table data while keeping slide text from the Markdown.
**Eight Confirmations** (full template: `templates/design_spec_reference.md`):
**Strategist confirmation stage** (full template: `templates/design_spec_reference.md`):
**BLOCKING**: present the Eight Confirmations and **wait for explicit user confirmation or modification** before outputting Design Specification & Content Outline. This is the single core confirmation gate — once the final confirmation lands, all subsequent steps proceed automatically. The default Confirm UI delivers the gate in **two tiers** (anchors → re-derive → realization; see below); the chat fallback mirrors the same two steps.
**BLOCKING**: present the Strategist confirmation stage and **wait for explicit user confirmation or modification** before outputting Design Specification & Content Outline. This is the single core confirmation gate — once the final confirmation lands, all subsequent steps proceed automatically. The default Confirm UI delivers the gate in **three stages** (direction → design system → images / execution; see below); the chat fallback mirrors the same staged order.
1. Canvas format
2. Page count range
@@ -346,38 +346,43 @@ Read references/strategist.md
7. Typography plan, including formula rendering policy
8. Image usage approach
**Confirm UI Auto-Launch (Mandatory — default visual confirmation surface)**: by default the Eight Confirmations are presented through an interactive local page in **two tiers within one browser session**Tier 1 confirms the *anchors*; the AI then re-derives the realization layer from the **user's actual** anchors; Tier 2 confirms that layer. Color swatches, live font previews, candidate picks; the chat path is the always-valid fallback. [`scripts/docs/confirm_ui.md`](scripts/docs/confirm_ui.md) owns the schema, server lifecycle, port strategy, and fallback details; this section keeps the orchestration contract. The split:
**Confirm UI Auto-Launch (Mandatory — default visual confirmation surface)**: by default the Strategist confirmation stage is presented through an interactive local page in **three stages within one browser session**Stage 1 confirms the direction anchors; the AI then re-derives the design-system layer from the **user's actual** anchors; Stage 2 confirms that layer; the AI then re-derives image and execution choices from the confirmed direction + design system; Stage 3 confirms the final operational layer. Color swatches, live font previews, icon samples, image-style reference previews, and candidate picks appear where they help judgment; the chat path is the always-valid fallback. [`scripts/docs/confirm_ui.md`](scripts/docs/confirm_ui.md) owns the schema, server lifecycle, port strategy, and fallback details; this section keeps the orchestration contract. The split:
| Tier | Confirms | Driven by |
| Stage | 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 |
| **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 |
| **1 — direction 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 — design system** (re-derived from Stage 1) | page count · color · typography (font + size) · icons · formula policy | the confirmed Stage 1 |
| **3 — images / execution** (re-derived from Stage 1 + Stage 2) | image usage · generated-image style · AI-image generation path · generation mode · refine-spec toggle | the confirmed direction + design system |
> **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 three stages.** Design-system fields are anchored by the same few choices (`visual_style` anchors color / icon / typography; `delivery_purpose` sets the body size, page density, **and** the page-count recommendation). Image strategy depends on both the confirmed visual direction and the confirmed color system — its palette is color behavior only, while final HEX values follow Stage 2. Confirming direction first, then design system, then image / execution choices means each downstream stage fits the user's *real* choices instead of the AI's original assumptions. Page count is a **derived** field (content volume × `delivery_purpose`), which is why it lives in Stage 2, not up front.
Steps:
> ⛔ **Steps 2 → 3 → 4 are ONE uninterrupted run — do NOT yield to the user mid-flow.** When the tier-1 `--wait` (step 2) returns, the AI **immediately and autonomously** continues to step 3 (re-derive + write Tier 2) and step 4 (`--wait-only`) in the **same turn**: do **not** summarize, ask a question, report progress, or end the turn in between. The browser is sitting on a "deriving…" spinner polling for the Tier 2 you must write next — stopping here strands the page and the user must prod you in chat to finish (a bug, not the intended flow). **The tier-1 confirmation is an intermediate machine handoff, not a stopping point.** The single ⛔ BLOCKING wait is the **final** confirmation at the end of step 4. (Chat-fallback path — only when the page never opened — is the exception: there you do present each tier in chat and wait for a reply.)
> ⛔ **Steps 2 → 3 → 4 are ONE uninterrupted run — do NOT yield to the user mid-flow.** When an intermediate `--wait` returns, the AI **immediately and autonomously** re-derives and writes the next stage in the **same turn**: do **not** summarize, ask a question, report progress, or end the turn in between. The browser is sitting on a "deriving…" spinner polling for the next stage you must write — stopping here strands the page and the user must prod you in chat to finish (a bug, not the intended flow). **Stage-1 and Stage-2 confirmations are intermediate machine handoffs, not stopping points.** The single ⛔ BLOCKING wait is the **final** confirmation at the end of step 4. (Chat-fallback path — only when the page never opened — is the exception: there you do present each stage in chat and wait for a reply.)
1. **Write Tier 1** to `<project_path>/confirm_ui/recommendations.json` with `"tier": 1` and only the anchor fields. Enumerable anchors (`canvas` / `mode` / `visual_style` / `delivery_purpose`) name a recommended canonical `id` in a `recommend` block (the page lists common options from `confirm_ui/static/catalogs.json`); `visual_style` also carries the ≥3-style `visual_style_spectrum` (safe / shifted / bold — same hard rule as h.5). `audience` and `content_divergence` are plain `{ "value": "<free text>" }`. `content_divergence` is the **free-text** field shown under audience in §c — how closely to follow the source vs how freely to reshape it (blank = balanced; facts stay sourced at every level); it is consumed by Strategist when authoring `§IX`, recorded in `design_spec.md §I`, carries no page-count coupling, and is **not** written to `spec_lock.md`. Set `lang` to the page language; visible text matches `lang`, or provide bilingual `name_zh` / `name_en` + `note_zh` / `note_en`.
2. **Launch + wait for Tier 1.** Background launch; the parent returns when the page writes the tier-1 `result.json`. **Long tool timeout — 600000 ms** (the `--wait` ≈590 s budget):
1. **Write Stage 1** to `<project_path>/confirm_ui/recommendations.json` with `"stage": "stage1"` and only the anchor fields. New recommendations MUST use the canonical `stage` selector. Enumerable anchors (`canvas` / `mode` / `visual_style` / `delivery_purpose`) name a recommended canonical `id` in a `recommend` block (the page lists common options from `confirm_ui/static/catalogs.json`); `visual_style` also carries the ≥3-style `visual_style_spectrum` (safe / shifted / bold — same hard rule as h.5). `audience` and `content_divergence` are plain `{ "value": "<free text>" }`. `content_divergence` is the **free-text** field shown under audience in §c — how closely to follow the source vs how freely to reshape it (blank = balanced; facts stay sourced at every level); it is consumed by Strategist when authoring `§IX`, recorded in `design_spec.md §I`, carries no page-count coupling, and is **not** written to `spec_lock.md`. Set `lang` to the page language (`zh` / `en` / `ja`); visible text matches `lang`, or provide multilingual `name_zh` / `name_en` / `name_ja` + `note_zh` / `note_en` / `note_ja` — when the user's language is Japanese, set `lang: "ja"` and always include the `_ja` variants (labels resolve in the page language first — a `ja` page falls back ja → en → zh, so missing `_ja` labels silently render in English; zh/en pages keep their zh↔en fallback and only try `_ja` last).
2. **Launch + wait for Stage 1.** Background launch; the parent returns when the page writes the stage-1 `result.json`. **Long tool timeout — 600000 ms** (the `--wait` ≈590 s budget):
```bash
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.**
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):
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 Stage 1: it shows a "deriving…" state and polls for Stage 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** for a fresh `status: stage1-confirmed` before dropping to the chat fallback. **On success (exit 0 with a stage-1 result), do not pause or report — go straight to step 3 in the same turn.**
3. **Re-derive Stage 2 from the confirmed anchors, write it, then wait for the design-system handoff — immediately, same turn (the page is polling for it).** Read the stage-1 `result.json` (`status: stage1-confirmed`). Using the user's **actual** confirmed anchors (not your originals), author the design-system candidates and **overwrite** `recommendations.json` with `"stage": "stage2"`: page count (content volume × `delivery_purpose`); color and typography as **generative ≥3-candidate** fields (creative recommendations always offer real choice; 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); enumerable `icons` / `formula_policy` (recommended `id`). The still-open page polls, renders Stage 2, and preserves the user's Stage 1 picks. Then attach to the already-running page, do **not** relaunch:
```bash
python3 ${SKILL_DIR}/scripts/confirm_ui/server.py <project_path> --wait-only --wait-stage stage2
```
This returns when the page writes the stage-2 `result.json` (`status: stage2-confirmed`). On a non-zero exit, re-check `result.json` once before falling back to chat.
4. **Re-derive Stage 3 from the confirmed anchors + design system, then wait for the final confirmation.** Read the stage-2 `result.json`. Author the image and execution recommendations and **overwrite** `recommendations.json` with `"stage": "stage3"`: `image_usage` as one or more source ids (`["ai"]`, `["ai","provided"]`, `["web","placeholder"]`, or `["none"]`; `none` is exclusive); `image_strategy.candidates` as **exactly three non-custom** rendering × palette recommendations from h.5 when `image_usage` includes `ai` (the page adds the fourth Custom card itself); enumerable `image_ai_path` / `generation_mode` and `refine_spec` (recommended `id` / boolean). If the recommendation involves several image 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. Generated-image style palettes are **color behavior only**; final image colors follow the confirmed Stage-2 `color`. Custom image-strategy dimensions are handled by the built-in Custom card, are prose-only, and should not promise a gallery reference image. Then attach to the already-running page, do **not** relaunch (same 600000 ms budget):
```bash
python3 ${SKILL_DIR}/scripts/confirm_ui/server.py <project_path> --wait-only
```
This is the ⛔ BLOCKING completion: returns when the page writes the final `result.json` (`status: confirmed`, `stage: final`, carrying all Tier 1 + Tier 2 fields). On a non-zero exit, re-check `result.json` once. Confirmed sizes are **already px** (the system is px-only — no pt anywhere, no conversion): write `result.json` `typography.body_size` / `sizes` into `design_spec.md` / `spec_lock.md` / SVG verbatim. `generation_mode: "split"` / `refine_spec: true` are explicit user choices.
This is the ⛔ BLOCKING completion: returns when the page writes the final `result.json` (`status: confirmed`, `stage: final`, carrying Stage 1 + Stage 2 + Stage 3 fields). On a non-zero exit, re-check `result.json` once. Confirmed sizes are **already px** (the system is px-only — no pt anywhere, no conversion): write `result.json` `typography.body_size` / `sizes` into `design_spec.md` / `spec_lock.md` / SVG verbatim. `generation_mode: "split"` / `refine_spec: true` are explicit user choices.
5. **Close the confirm page (Mandatory cleanup — every path).** Shut the server down before leaving Step 4 so it cannot keep holding port 5050 (which Step 6 live preview reuses):
```bash
python3 ${SKILL_DIR}/scripts/confirm_ui/server.py <project_path> --shutdown
```
**Idempotent and required regardless of whether Confirm was clicked**: clicking the final Confirm already shuts the page down (then a no-op); the chat-fallback path leaves it running. Run it after reading the confirmation, before Step 5.
**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 stage's recommendations + URL in chat** as the always-valid fallback. **The chat fallback is staged too**: if the page never opens or a wait times out with no fresh result, present Stage 1 in chat → get confirmation → re-derive → present Stage 2 → get confirmation → re-derive → present Stage 3 → 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). `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:
@@ -394,7 +399,7 @@ When the confirmed `image_usage` does not include `ai` (and no legacy custom pro
**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 **three-stage page path this is already handled** (Step 3 re-derives Stage 2 from the user's actual anchors; Step 4 re-derives Stage 3 from the confirmed anchors + design system). It still applies whenever anchors and downstream fields are confirmed **together** — the staged 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.
| Anchor the user changed | Re-derive (only the downstream fields the user left at your recommendation) |
|---|---|
@@ -406,20 +411,20 @@ When the confirmed `image_usage` does not include `ai` (and no legacy custom pro
Reconcile **without a new blocking wait** — fold the coherent values into `design_spec.md` / `spec_lock.md` and state the adjustment in the §8 next-step handoff (e.g. "you switched to `dark-tech`; the light palette you had left no longer fit, so background / accent were re-derived — tell me if you wanted the original"). Canvas is the explicit exception: font sizes are deliberately **not** rescaled on a canvas change (see strategist §g).
**Opt-out**: if the user has said they don't want the page (e.g. "不要网页" / "just confirm in chat" / "纯聊天确认"), skip the launch entirely (step 2) and present the Eight Confirmations in chat as before — steps 1, 3, 4 still apply (recommendations summary in chat; wait; take chat values).
**Opt-out**: if the user has said they don't want the page (e.g. "不要网页" / "just confirm in chat" / "纯聊天确认"), skip the launch entirely (step 2) and present the Strategist confirmation stage in chat as before — steps 1, 3, 4 still apply (recommendations summary in chat; wait; take chat values).
The page is a **confirmation surface only** — Strategist still authors every recommendation; the page never generates content.
**Mandatory — split-mode note** (not a ninth confirmation): after listing the eight confirmation details, you MUST append exactly one short line (rendered in the user's language, prefixed with 💡) about generation mode. Pick the variant by qualitative read of Phase A signals — recommended page count, source-material bulk, whether `topic-research` ran with substantial web-fetch accumulation:
**Mandatory — split-mode note** (not a separate confirmation): after listing the Strategist confirmation stage details, you MUST append exactly one short line (rendered in the user's language, prefixed with 💡) about generation mode. Pick the variant by qualitative read of upstream-load signals — recommended page count, source-material bulk, whether `topic-research` ran with substantial web-fetch accumulation:
| Signal read | Line content |
|---|---|
| Heavy (long page count / bulky sources / heavy web-fetch accumulation) | State estimated page count and large source size; recommend switching to [split mode](workflows/resume-execute.md) after Step 5 — stop this chat, open a fresh window and input `继续生成 projects/<project_name>` to enter Phase B (SVG generation + export); no response or "continue" = default continuous mode. |
| Heavy (long page count / bulky sources / heavy web-fetch accumulation) | State estimated page count and large source size; recommend switching to [split mode](workflows/resume-execute.md) after Step 5 — stop this chat, open a fresh window and input `继续生成 projects/<project_name>` to enter the execution session (SVG generation + export); no response or "continue" = default continuous mode. |
| Normal (default) | State scale is moderate, default continuous mode generates in one go; if mid-way window switch is desired, input `继续生成 projects/<project_name>` after Step 5 to switch to [split mode](workflows/resume-execute.md). |
This line is required output every run — the user must always see the mode choice exists. Whether to act on it is the user's call. When the Confirm UI is used, this choice also appears as the in-page generation-mode toggle and is captured in `result.json` (`generation_mode`); the chat-summary fallback still prints this line.
**Mandatory — spec-refinement note** (not a ninth confirmation): after the split-mode line, you MUST append one short opt-in line (rendered in the user's language, prefixed with 💡) telling the user they may **refine the spec first** — Strategist will produce the full design spec, then stop for review/revision of any part of it before any generation, via the [refine-spec](workflows/refine-spec.md) workflow. Default is OFF: no request → the spec is written in one go and the pipeline auto-proceeds as usual. Only when the user explicitly asks in chat (e.g. "refine the spec first") or confirms `refine_spec: true` through Confirm UI does the [refine-spec](workflows/refine-spec.md) workflow take over after the Eight Confirmations. This line, like the split-mode line, is required output every run — the user must see the choice exists; whether to act on it is theirs. When the Confirm UI is used, this choice also appears as the in-page refine-spec toggle and is captured in `result.json` (`refine_spec`); the chat-summary fallback still prints this line.
**Mandatory — spec-refinement note** (not a separate confirmation): after the split-mode line, you MUST append one short opt-in line (rendered in the user's language, prefixed with 💡) telling the user they may **refine the spec first** — Strategist will produce the full design spec, then stop for review/revision of any part of it before any generation, via the [refine-spec](workflows/refine-spec.md) workflow. Default is OFF: no request → the spec is written in one go and the pipeline auto-proceeds as usual. Only when the user explicitly asks in chat (e.g. "refine the spec first") or confirms `refine_spec: true` through Confirm UI does the [refine-spec](workflows/refine-spec.md) workflow take over after the Strategist confirmation stage. This line, like the split-mode line, is required output every run — the user must see the choice exists; whether to act on it is theirs. When the Confirm UI is used, this choice also appears as the in-page refine-spec toggle and is captured in `result.json` (`refine_spec`); the chat-summary fallback still prints this line.
**Formula rendering policy lives inside item 7 (Typography plan)**:
@@ -429,7 +434,7 @@ This line is required output every run — the user must always see the mode cho
| `render-all` | Strategist renders every formula-worthy expression as PNG assets |
| `text-only` | No formula rendering; formulas remain editable text / Unicode |
After the Eight Confirmations are approved and **before outputting `design_spec.md` / `spec_lock.md`**, if the confirmed formula policy is `mixed` or `render-all` and the content contains formula-worthy expressions, Strategist MUST:
After the Strategist confirmation stage is approved and **before outputting `design_spec.md` / `spec_lock.md`**, if the confirmed formula policy is `mixed` or `render-all` and the content contains formula-worthy expressions, Strategist MUST:
1. Identify explicit LaTeX and any source expressions that should be faithfully structured as formulas.
2. Write `<project_path>/images/formula_manifest.json` with only the formulas selected for rendering.
@@ -457,9 +462,9 @@ python3 ${SKILL_DIR}/scripts/analyze_images.py <project_path>/images
**✅ Checkpoint — Phase deliverables complete, auto-proceed to next step**:
```markdown
## ✅ Strategist Phase Complete
- [x] Read the auto-extracted facts already in `analysis/` (e.g. `source_profile.json`) before the Eight Confirmations
- [x] Eight Confirmations completed (user confirmed via Confirm UI `result.json` or chat fallback)
- [x] Split-mode note appended below the eight items (heavy or normal variant)
- [x] Read the auto-extracted facts already in `analysis/` (e.g. `source_profile.json`) before the Strategist confirmation stage
- [x] Strategist confirmation stage completed (user confirmed via Confirm UI `result.json` or chat fallback)
- [x] Split-mode note appended below the confirmation fields (heavy or normal variant)
- [x] Spec-refinement opt-in line appended (default OFF; only the user's explicit request enters the refine-spec workflow)
- [x] Design Specification & Content Outline generated
- [x] Execution lock (spec_lock.md) generated
@@ -520,13 +525,13 @@ Workflow:
- [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 planning-session handoff below and stop this conversation:
```markdown
## ✅ Phase A Complete
## ✅ Planning Session Complete
- [x] Spec: `design_spec.md`, `spec_lock.md`
- [x] Resources: `sources/`, `images/`, `templates/`
- [ ] **Next**: open a fresh chat window and input `继续生成 projects/<project_name>` to enter Phase B via the [`resume-execute`](workflows/resume-execute.md) workflow.
- [ ] **Next**: open a fresh chat window and input `继续生成 projects/<project_name>` to enter the execution session via the [`resume-execute`](workflows/resume-execute.md) workflow.
```
> On acquisition failure, do NOT halt — follow the Failure Handling rule in [image-base.md](references/image-base.md) §5: retry once, then mark the row `Needs-Manual`, report to user, and continue to the checkpoint above.
@@ -556,6 +561,7 @@ Read references/visual-styles/<locked-style>.md # aesthetic (spec_lock.md `vis
python3 ${SKILL_DIR}/scripts/svg_editor/server.py <project_path> --live --daemon
```
- Start it immediately when Executor begins; `svg_output/` may be empty. Editor opens at `http://localhost:5050`; if another project already holds it, the launcher **auto-advances to the next free port** — read the actual URL from the launch log and report that.
- Treat the launch URL as a checkpoint value: before writing the first SVG, either report the actual URL from the launcher or state the launch failure explicitly. Do not silently continue while claiming preview is available.
- Run it as a long-running side process/session; do not wait for it to exit before generating SVG pages. Do not wait for user confirmation after startup.
- **Service must keep running** until one of: (a) the user clicks **Exit preview** in the browser, or (b) the user explicitly asks in chat to stop it. Generation continues even if the user closes the editor.
- **Do NOT read or apply submitted annotations during generation.** Users may annotate at any time, but Executor proceeds without touching them. The window to apply annotations opens only after Step 7 completes — see [`workflows/live-preview.md`](workflows/live-preview.md).
@@ -585,7 +591,7 @@ python3 ${SKILL_DIR}/scripts/svg_quality_checker.py <project_path>
**✅ Checkpoint — Confirm all SVGs and notes are fully generated and quality-checked. Proceed directly to Step 7 post-processing**:
```markdown
## ✅ Executor Phase Complete
- [x] Live preview started and kept available at the reported URL
- [x] Live preview started before the first SVG and kept available at the reported URL
- [x] All SVGs generated to svg_output/
- [x] svg_quality_checker.py passed (0 errors)
- [x] Speaker notes generated at notes/total.md
@@ -603,7 +609,7 @@ python3 ${SKILL_DIR}/scripts/svg_quality_checker.py <project_path>
🚧 **Image readiness GATE** (when Step 5 left ai rows in `Needs-Manual`): every expected file must exist at `project/images/<filename>` before running 7.1.
**Failure recovery**: if a Step 7 command fails, fix the owning source artifact and resume from the failed sub-step per [`workflows/failure-recovery.md`](workflows/failure-recovery.md). Do not restart Phase A unless the owning source changed.
**Failure recovery**: if a Step 7 command fails, fix the owning source artifact and resume from the failed sub-step per [`workflows/failure-recovery.md`](workflows/failure-recovery.md). Do not restart the planning session unless the owning source changed.
> 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.
@@ -634,6 +640,8 @@ python3 ${SKILL_DIR}/scripts/svg_to_pptx.py <project_path>
#
# Add --svg-snapshot to additionally emit the SVG-image preview pptx alongside the native pptx:
# exports/<project_name>_<timestamp>_svg.pptx ← SVG preview pptx (reads svg_final/)
# Add --native-objects to emit real editable chart/table objects instead of flattened shapes:
# exports/<project_name>_<timestamp>_native_charts.pptx ← native chart/table objects (data-pptx-native markers)
```
> The native pptx consumes `svg_output/` directly so the converter can preserve
@@ -661,6 +669,16 @@ python3 ${SKILL_DIR}/scripts/svg_to_pptx.py <project_path>
> layout-tight page must keep every dy-stacked line as its own text frame. The
> merge detector is conservative; mixed-layout text falls back to per-line frames.
> **Native table/chart objects** — supported data charts and pure text-grid
> tables carry `data-pptx-native` markers by default (Executor transcribes
> them at draw time; see `references/executor-base.md` §3.2) and the markers
> stay dormant.
> Add `--native-objects` only when the user explicitly wants
> PowerPoint-editable native tables/charts and accepts that those objects may
> render differently across PowerPoint / Keynote / LibreOffice / WPS. Without
> the flag, marked groups export through their SVG fallback children like
> ordinary SVG content.
**Optional animation flags** (page transitions are on by default; per-element entrance is off by default — turn it on only when the user asks for it):
- `-t <effect>` — page transition. Default `fade`. Options: `fade` / `push` / `wipe` / `split` / `strips` / `cover` / `random` / `none`.
- `-a <effect>` — per-element entrance animation. **Default `none`** — pages appear as a whole, no auto-firing element builds (the unsolicited cascade reads as the "AI deck" tell). Opt in with `auto` (map effect from group id: chart→wipe, card-/step-/pillar-→fly, title/takeaway→fade; image-like ids `hero` / `figure-` / `image` / `img-` / `kpi` cycle a richer pool — zoom / dissolve / circle / box / diamond / wheel — so multiple images vary across the deck), a specific effect like `fade`, or `mixed` for the legacy 16-effect cycle. Requires top-level `<g id="...">` groups (already required by Executor).
@@ -10,8 +10,9 @@ Global artifact ownership rules for PPT Master projects.
| Artifact | Owner | Role | Read/write contract |
|---|---|---|---|
| `sources/<stem>.md` | Content contract | Main pipeline source for text, tables, and chart data values | Strategist reads for content; do not replace values with PPTX geometry JSON in the main pipeline |
| `sources/` originals | Source archive | Imported source files and source-adjacent extracted assets | Project manager imports here; downstream reads by route |
| `sources/` content-type files | Content contract | Main pipeline source for text, tables, and chart data values | Strategist reads content-type files (`.md` / `.markdown` / `.txt` / `.csv` / `.tsv` / `.json` / `.jsonl` / `.yaml` / `.yml`) and judges by content; do not replace values with PPTX geometry JSON in the main pipeline |
| `sources/` converted-source originals | Source archive | Imported source files that have a converted content contract (`.pdf` / `.pptx` / `.docx` / `.xlsx` / `.html` / `.epub` / `.tex` / `.rst` / `.ipynb` / `.typ`, etc.) and source-adjacent extracted assets | Read via the converted `<stem>.md` in the main pipeline; direct-PPTX workflows read the `.pptx` by route |
| `sources/*.conversion_profile.json`, `sources/*_files/image_manifest.json` | Pipeline sidecar | Conversion audit record / asset index | NOT read as slide content; open only to audit a conversion or resolve assets |
| `analysis/source_profile.json` | Machine fact index | Compact Strategist-facing PPTX intake digest | Main pipeline reads as factual context and recommendation candidates |
| `analysis/<stem>.identity.json` | Native deck identity facts | Canvas, theme palette/fonts, observed usage | Read selectively when detailed identity facts are needed |
| `analysis/<stem>.slide_library.json` | Native PPTX structure facts | Text slots, geometry, native tables, native chart caches | Direct PPTX workflows use as native fill/structure contract |
@@ -21,7 +22,7 @@ Global artifact ownership rules for PPT Master projects.
| `images/` | Runtime image pool | User, extracted, AI, web, formula, slice, EMF/WMF assets | Step 5 writes here; `analysis/image_analysis.csv` derives from current contents |
| `icons/` | Project icon inventory | Icons copied by `icon_sync.py` for this project | Executor uses locked project icons; exporter may fall back to global library only as documented |
| `templates/` | Project template reference | Step 3 imported specs, template SVGs, and non-image assets | Strategist/Executor read only when Step 3 is triggered |
| `confirm_ui/recommendations.json` | Confirmation proposal | Strategist-authored confirmation payload | Confirm UI reads; rewritten between Tier 1 and Tier 2 |
| `confirm_ui/recommendations.json` | Confirmation proposal | Strategist-authored confirmation payload | Confirm UI reads; rewritten between Stage 1, Stage 2, and Stage 3 |
| `confirm_ui/result.json` | Confirmation result | User-confirmed values | Strategist treats final result as authoritative over recommendations |
| `svg_output/` | Author source | Main-agent handwritten SVG pages | Quality checker and native PPTX export read this as canonical page source |
| `notes/total.md` | Speaker-note source | Complete notes before splitting | Step 6 writes; Step 7.1 splits |
@@ -37,7 +38,8 @@ Global artifact ownership rules for PPT Master projects.
| Invariant | Rule |
|---|---|
| Content values | Main pipeline text, tables, and chart values come from `sources/<stem>.md`, not from `slide_library.json`. |
| Content values | Main pipeline text, tables, and chart values come from content-type files in `sources/` (`.md` / `.markdown` / `.txt` / `.csv` / `.tsv` / `.json` / `.jsonl` / `.yaml` / `.yml`), not from `slide_library.json`. |
| Sources read policy | In `sources/`, read content-type files (`.md` / `.markdown` / `.txt` / `.csv` / `.tsv` / `.json` / `.jsonl` / `.yaml` / `.yml`) and judge by content — a `.json` / `.csv` may be core content or just data. Exclude known sidecars: `*.conversion_profile.json` and `*_files/image_manifest.json`. `analysis/` facts (`source_profile.json`, `<stem>.slide_library.json`) are read per Step 4 / direct-PPTX workflow, not in the `sources/` content scan. |
| PPTX structure | `slide_library.json` owns native geometry and slot facts for direct PPTX workflows. |
| Design contract | `design_spec.md` explains; `spec_lock.md` executes. Executor must not infer execution values from prose. |
| Image facts | `images/` is live state; `analysis/image_analysis.csv` is a regenerated view, not a durable cache. |
@@ -95,7 +95,7 @@ Before generating each page, output which template is used:
## 2. Design Parameter Confirmation (Mandatory Step)
Before the first SVG page, output a confirmation listing: canvas dimensions, body font size, color scheme (primary/secondary/accent HEX), font plan. Prevents spec/execution drift.
Before the first SVG page, output a confirmation listing: canvas dimensions, body font size, color scheme (primary/secondary/accent HEX), font plan, and the live-preview URL reported by the launcher. If the preview launch failed, state that failure before generating SVGs instead of silently proceeding. Prevents spec/execution drift.
### 2.1 Per-page spec_lock re-read (Mandatory)
@@ -171,7 +171,7 @@ Before drawing each page, look up its entry in `page_charts` to decide which cha
- **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):
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 — and **native object metadata** per §3.2 on every eligible data-chart page.
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.
3. **Logic Construction Phase**: after SVGs pass the quality check, batch-generate speaker notes for narrative continuity.
@@ -218,6 +218,33 @@ grep "chart-plot-area" <project_path>/svg_output/<current_page>.svg
- **Reference — prefer semantic shapes over preset stacks (not a constraint)**: when a slide needs to express "ascending / converging / breaking through / stacking" — i.e., a relationship that goes beyond a generic arrow — prefer a single custom `<polygon>` or `<path>` that encodes the semantics geometrically, rather than stacking multiple preset arrows. A converging-tip path or a podium polygon reads faster than three arrows pointing at a label. Examples of this technique appear in many imported corporate decks; see `projects/01_template_import/svg_output/slide_01.svg` shape-158 for a reference (gradient-filled inward-pointing arrow). Do not codify these as templates — they are page-specific; the rule is just "consider polygon before stacking presets."
- **Reference — visual depth through restraint (not a constraint)**: layered depth comes from rhythm (flat vs lifted, dense vs spacious), not from shadows everywhere. Shadow typically suits 2-3 genuinely floating elements per page (cards on photos, primary CTA, overlays); keep peer-grid cards, dividers, body containers flat. Reach for typography weight, spacing, accent bars, subtle tints **before** shadow. Full rules in shared-standards.md §6.
### 3.2 Native Object Metadata Marker (MANDATORY on eligible data-chart and text-grid table pages)
> `svg_to_pptx.py --native-objects` converts marked groups into real PowerPoint chart/table objects (charts get an embedded Excel workbook). Markers stay dormant in the default export — pages render from their SVG children — but a deck without markers can never form native objects. Write the marker at draw time: the data is already in hand, and recovering it later costs a full re-read pass.
**Hard rule**: every data chart whose type appears in the **Supported chart types** list of [shared-standards.md](shared-standards.md) "Native PPTX Table / Chart Markers" (the single authority for the eligible set, marker contract, and JSON schemas) gets `data-pptx-native="chart"` plus a `<metadata data-pptx-native="chart">` JSON child on its top-level `<g>`, transcribing the same data just plotted. Every pure text-grid data table gets `data-pptx-native="table"` the same way, transcribing all visible cell text into `columns` / `rows`.
- Chart types absent from that list and conceptual/diagrammatic graphics (process flows, cycles, quadrant cards, timelines, KPI cards) get **no marker**`svg_quality_checker.py` rejects unsupported marker types.
- Tables with merged, spanning, or graphical cells (icons, harvey balls, rating dots) get **no table marker** — the exporter rejects merged-cell metadata; they stay on the SVG fallback route.
- Transcribe, don't restyle: `categories` / `series[].values` are the numbers just plotted; `style.colors` carries the series HEX values already used on the page (from `spec_lock.colors`).
- Data-point color: when a single column/bar series uses data-point colors in the fallback, copy those fills into `series[].point_colors` in category order.
- Data labels: when visible point values are part of the fallback chart, write `data_labels` instead of companion text; use `data_labels.points` for selected labels, and use `number_format`, `font_size`, `font_family`, and per-point `colors` / `color` when the fallback labels carry suffixes or color-coded text.
- Line markers: when the fallback line chart draws visible point nodes, set `line_style: "lineMarker"`; leave the default `line` only for line charts without nodes.
- Area-under-line: when a combo plot is drawn as a filled area under a line, keep `type: "line"`, add `area_fill: true`, and copy the area transparency into `series[].fill_opacity`; copy visible line `stroke-width` into `series[].line_width` for line/area series.
- Native chrome: write `title`, `subtitle`, axis titles, or `show_legend: true` only when the fallback visibly renders the same chrome inside the native chart's replacement scope. `title` is the PowerPoint chart title, not an object name; use `name` for page-semantic object naming (e.g. `p03-revenue-chart`). Write explicit `x`/`y`/`width`/`height` read from the drawn plot area; omission is the fallback — the exporter then infers the frame from the drawn fallback geometry.
- Value-axis labels: when the fallback keeps category labels but intentionally omits numeric value-axis tick labels, set `show_value_axis_labels: false`.
- Freeform chart text: transcribe center labels, source notes, and other in-chart annotations as companion `caption` / `note` / `notes` entries with explicit slide-coordinate bounds; do not rely on fallback `<text>` children to survive native export.
- Native chart typography mirrors the SVG fallback. Copy the fallback's shared chart font into `style.font_family` and visible chart text sizes into the matching metadata fields (`title_font_size`, `subtitle_font_size`, `axis_font_size`, `note_font_size`, etc.) when role sizes differ; if omitted, the exporter infers shared font family and base chart text size from visible fallback text inside the native marker. When a visible chart title, subtitle, or axis title needs its own size/color/font, write that field as an object with `text`, `font_size`, `font_family`, and `color`. Use `axis_title_font_size`, `legend_font_size`, or companion per-entry `font_size` only when the fallback visibly uses a separate size. If a fallback role has no explicit size, use the compact three-tier chart defaults from [shared-standards.md](shared-standards.md).
- Native table typography mirrors the SVG fallback. Write `style.font_family` and `style.font_size` from the visible table text; use `header_font_size` or per-cell `font_size` only when the fallback visibly does so. If the fallback has no explicit table font, fall back to the deck body family and locked body size from `spec_lock.md typography`.
- The marker group's transform stays translate/scale only (no rotate / matrix / skew).
- Visual parity is not a goal: the SVG drawing remains the designed visual; the native object is a data-editable counterpart with PowerPoint-default styling that users restyle by hand after export. Never simplify the SVG design to match what a native object could show.
**Per-page verification** — after writing each eligible data-chart or text-grid table page, confirm the marker exists:
```bash
grep "data-pptx-native" <project_path>/svg_output/<current_page>.svg
```
### SVG File Naming Convention
Format: `<NN>_<page_name>.svg` (two-digit number from 01; name matches the deck's language and the page title in the Design Spec).
@@ -313,7 +340,7 @@ Chart SVGs referenced in **VII. Visualization Reference List** are loaded once v
- **Freely adjust**: composition, axis ranges, grid, legend, spacing, decoration — as long as the chart stays accurate and readable
- **Forbidden**: changing visualization type without spec justification; omitting data points or structural elements from the outline
> Templates: `templates/charts/` (70 types). Index: `templates/charts/charts_index.json`
> Templates: `templates/charts/` (76 types). Index: `templates/charts/charts_index.json`
### 5.1 Chart Coordinate Calibration
@@ -374,7 +401,7 @@ Source of truth: `spec_lock.md typography`. Use `font_family` as default; overri
If `spec_lock.md` is absent, consult [`strategist.md`](strategist.md) §g — do not invent a stack.
**Hard rule**: every SVG `font-family` stack MUST end with a pre-installed family (Microsoft YaHei / SimHei / SimSun / Arial / Calibri / Segoe UI / Times New Roman / Georgia / Consolas / Courier New / Impact / Arial Black). PPTX has no runtime fallback — missing fonts degrade to Calibri.
**Hard rule**: every SVG `font-family` stack MUST resolve to pre-installed exported Latin / EA typefaces (Microsoft YaHei / SimHei / SimSun / Arial / Calibri / Segoe UI / Times New Roman / Georgia / Consolas / Courier New / Impact / Arial Black). PPTX has no runtime fallback — missing fonts degrade to Calibri.
---
@@ -69,7 +69,7 @@ After all rows reach terminal status:
- `image_prompts.json` exists when ≥1 ai row processed; every entry has `status ∈ {Generated, Needs-Manual}` (no `Pending` or `Failed` remaining)
- `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) §7 Offline Manual Mode.
---
@@ -82,7 +82,7 @@ After all rows reach terminal status:
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>`). 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) §7 Offline Manual Mode.
Path-specific retry policies (provider chain, backend chain) live in the path's own reference.
@@ -557,7 +557,7 @@ Triggered automatically when `IMAGE_BACKEND` is not configured (or Path A fails)
- 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 (~34), not the whole manifest at once — so their latency overlaps without flooding the host's image quota. When the host only runs tools serially, generate one row at a time. This mirrors Path A's default concurrency of 3.
- Outputs **must** land at `project/images/<filename-from-resource-list>` with dimensions matching the Image Resource List
- Outputs **must** land at `project/images/<filename-from-resource-list>`. Match the Image Resource List dimensions when the host supports arbitrary sizes. Hosts with **fixed native resolutions** (common — e.g. ~1672x941 landscape / ~1086x1448 portrait) generate at the closest native size and backfill the actual pixels into the resource list `Dimensions` column — same convention as formula rows ("actual dimensions from formula manifest") and slice rows ("dimensions filled after slicing"). Do **not** upscale the file to fake the requested size (interpolation adds no detail); minor display-side upscaling (up to ~1.3x in practice) surfaces as a quality-checker warning — acknowledge and release per the warning policy.
- Mark each item's `status` `Generated` in the manifest the moment its file lands — as each completes, not in one pass at the end (so an interrupted batch leaves accurate state)
- Executor downstream is path-agnostic — no spec change required between Path A and Path B
@@ -154,6 +154,8 @@ Stack any of these freely on top of a Primary structure. Multiple Modifiers per
## Overlay & Masking Treatments
> **Crop displacement (HARD rule for text over images).** `preserveAspectRatio="xMidYMid slice"` center-crops whatever the source aspect ratio does not cover — when source and display aspects differ, the subject can land under the text column even if the prompt asked for it on the "focal side". Before layering text on a slice-cropped image: estimate the crop from the aspect-ratio difference, and keep the **entire text column on the scrim's opaque plateau** — text must never start inside a gradient's transition zone. When the subject position is unverified, fall back to an opaque treatment (`#30` at high opacity, or a solid panel) instead of a two-stop scrim (`#29`).
27. **Linear gradient mask for text legibility**`<linearGradient>` in `<defs>` (set `x1/y1/x2/y2` for direction) + overlay `<rect fill="url(#grad)">`. Most common is top-to-bottom darkening on full-bleed cover images.
28. **Radial gradient vignette**`<radialGradient cx cy r>` with dark outer stops; overlay `<rect>`. Focuses attention by darkening the periphery.
@@ -22,7 +22,7 @@ Layout rules for pages where the image is placed **side-by-side with body text**
7. Fill results into the Design Specification's image resource list
```
**When to run**: if image approach includes "B) User-provided", run the scan and populate the image resource list after the Strategist's Eight Confirmations and before content analysis / outlining.
**When to run**: if image approach includes "B) User-provided", run the scan and populate the image resource list after the Strategist's confirmation stage and before content analysis / outlining.
---
@@ -169,10 +169,10 @@ One offending character invalidates the file and aborts export. Numeric refs (`&
## 4. Basic SVG Rules
- **viewBox** must match the canvas dimensions (`width`/`height` must match `viewBox`)
- **viewBox** MUST match the canvas dimensions; it is the single source of truth for canvas size. Root `width`/`height` are optional compatibility attributes and are not used as PPT Master canvas authority.
- **Background**: Use `<rect>` to define the page background color
- **`<tspan>`** has two purposes: (1) manual line breaks (use `dy` or explicit `y`); (2) inline run formatting on the same line (color/weight/size). `<foreignObject>` is FORBIDDEN. See "Single logical line" rule below.
- **Fonts**: every `font-family` stack MUST end with a pre-installed family (Microsoft YaHei / SimSun / Arial / Times New Roman / Consolas …); `@font-face` is FORBIDDEN. Full rule: [`strategist.md §g`](strategist.md).
- **Fonts**: every `font-family` stack MUST resolve to pre-installed exported Latin / EA typefaces (Microsoft YaHei / SimSun / Arial / Times New Roman / Consolas …); `@font-face` is FORBIDDEN. Full rule: [`strategist.md §g`](strategist.md).
- **Styles**: inline only (`fill=""`, `font-size=""`); `<style>`/`class` FORBIDDEN (`id` inside `<defs>` is fine)
- **Colors**: HEX only; transparency via `fill-opacity`/`stroke-opacity`
- **Images**: `<image href="../images/xxx.png" preserveAspectRatio="xMidYMid slice"/>`
@@ -690,6 +690,229 @@ The child `<path>`'s `stroke` becomes the foreground color (the pattern's line c
> `svg_quality_checker.py` warns on missing `data-pptx-pattern` and errors on values outside the enum. Catch these pre-export — PowerPoint's repair dialog hides which pattern broke.
### Native PPTX Table / Chart Markers (Experimental)
Native PowerPoint tables and Excel-backed charts activate at export time only. The default chart/table route remains hand-authored SVG geometry so the deck stays pixel-stable across PowerPoint / Keynote / LibreOffice / WPS.
**Authoring — markers are standard on supported data charts and text-grid tables**: Executor writes the marker at draw time on every data chart whose type falls in the supported set and on every pure text-grid data table ([executor-base.md §3.2](executor-base.md)), so any deck can later form native objects without regeneration. Tables with merged or graphical cells stay unmarked on the SVG fallback route. The marker group supplies both: visible SVG fallback children for browser/live-preview rendering, and JSON metadata for `svg_to_pptx` native export.
**Hard rule — activation is the opt-in, dormant unless exported with `--native-objects`**: A marker only declares that a group is eligible for native export. Normal `svg_to_pptx.py` runs keep the fallback SVG children. Pass `--native-objects` only when editability in PowerPoint matters more than cross-renderer layout fidelity: it emits the PowerPoint object and skips the fallback children to avoid duplicates. Native styling preserves the core palette, text, axis, grid, and background colors where possible, but it is still a PowerPoint chart/table object rather than a pixel-identical SVG drawing.
| Marker | Native output | Required metadata |
|---|---|---|
| `<g data-pptx-native="table">` | `<p:graphicFrame>` with `<a:tbl>` | bounds + `columns` or `rows` |
| `<g data-pptx-native="chart">` | `<p:graphicFrame>` with `c:chart` / `cx:chart` + chart part + embedded workbook | bounds + `type`, plus chart data |
**Metadata placement**: Put JSON in a child `<metadata data-pptx-native="...">`. Attribute JSON (`data-pptx-json="..."`) is supported but harder to XML-escape correctly.
**Bounds**: Provide `x`, `y`, `width`, and `height` in metadata, or as
`data-pptx-x` / `data-pptx-y` / `data-pptx-width` / `data-pptx-height` on the
marker group. If any bound is omitted, the exporter infers the object frame
from the visible fallback geometry; this keeps SVG fallback and native object
placement aligned.
**Validation**: `svg_quality_checker.py` validates native marker kind, JSON
metadata, bounds/fallback availability, table rows/columns, supported chart
type, and chart data shape before export.
```xml
<g id="p03-revenue-chart" data-pptx-native="chart">
<metadata data-pptx-native="chart">
{
"x": 120, "y": 150, "width": 520, "height": 320,
"type": "column",
"title": "Revenue by Segment",
"categories": ["Q1", "Q2", "Q3"],
"series": [
{"name": "Cloud", "values": [12, 15, 19]},
{"name": "Services", "values": [8, 9, 11]}
]
}
</metadata>
<!-- Visible SVG fallback for live preview / non-native export goes here. -->
</g>
```
**Table schema**: Native tables are rectangular DrawingML grids. Use `columns`
for the optional header row and `rows` for body rows; shorter rows are padded
with blank cells unless `strict_grid: true` is set. Use `column_widths` and
`row_heights` as relative weights. Cell objects accept `text`, `fill`, `color`,
`align`, `valign`, `bold`, `font_size`, `padding`, `border_color`, and
`border_width`; the same `padding`, `border_color`, and `border_width` keys may
also live under `style` as table defaults. Native table typography mirrors the
visible SVG fallback: put `style.font_family` and `style.font_size` on the
marker from the table text already drawn, then use `style.header_font_size` or
per-cell `font_size` only when the fallback visibly differs. If the fallback
has no explicit table font, use the deck body family and locked body size from
`spec_lock.md`.
**Hard rule — table metadata is the native source of truth**: Every row,
summary line, value, and cell-level style that must survive
`--native-objects` must be present in `columns` / `rows`. SVG fallback text is
discarded during native export. `svg_quality_checker.py` warns when visible
fallback `<text>` inside a native table marker does not appear in metadata.
For numeric or currency columns, use cell objects with `align: "r"`; SVG
`text-anchor="end"` does not carry into the native table.
**Forbidden — native merged table cells**: Do not use `rowSpan`, `colSpan`,
`gridSpan`, `hMerge`, `vMerge`, or top-level merge lists in native table
metadata. `svg_to_pptx.py --native-objects` rejects them so merged-cell tables
do not silently degrade into incorrect grids. Keep merged-cell tables on the
default SVG fallback route, or merge cells manually in PowerPoint after native
export.
**Category chart schema**: `column`, `bar`, `line`, `area`, `pie`,
`doughnut`, `pieOfPie`, `barOfPie`, and `radar` use `categories` plus
`series[].values`. Pie-family charts (`pie`, `doughnut`, `pieOfPie`, and
`barOfPie`) must have exactly one series; the exporter assigns per-category
slice colors so single-series charts do not collapse into one solid color.
Column and bar charts may set per-point colors with `series[].point_colors`
or `series[].pointColors`; the list must match `series[].values` length.
Classic category charts may set native PowerPoint data labels with
`data_labels`. Use `data_labels: true` for default value labels, or an object
with `show_value`, `position`, `number_format`, `font_size`, `font_family`,
`bold`, `color`, and optional per-point `colors`. Supported label positions
depend on chart type: clustered column/bar labels may use `outside_end`,
`inside_end`, `inside_base`, or `center`; stacked / percent-stacked column/bar
labels may use `inside_end`, `inside_base`, or `center`; line labels may use
`above`, `center`, or `best_fit`; area labels do not emit a native label
position. To label only selected data points, use `data_labels.points` with
zero-based `idx` plus optional per-point `position`, `number_format`,
`font_size`, `font_family`, `bold`, and `color`.
**Combo chart schema**: `combo` uses shared `categories` plus either `plots[]`
or typed `series[]`. Each plot supports `type: "column" | "line" | "area"`,
its own `series`, and optional `axis: "secondary"` for a right-side value axis.
Typed `series[]` accepts the same `type` and `axis` fields per series, and
adjacent compatible series are grouped into the same PowerPoint plot. Area
series may set `fill_opacity` / `fillOpacity` as a `0..1` SVG opacity value
when the SVG fallback uses a transparent area fill under an opaque line. A line plot with `area_fill: true`
is exported as a PowerPoint area chart under the hood; `fill_opacity` only sets
the fill style and does not trigger conversion by itself. Combo export layers
area plots below columns and lines while preserving the original series indices.
Line and area series may set `line_width` / `lineWidth` in SVG px units to
match fallback `stroke-width`.
**XY chart schema**: `scatter` and `bubble` use `series[].x` + `series[].y`; `bubble` also requires one `series[].size` / `series[].sizes` value per point. `series[].points` is also accepted as `[x, y]` / `[x, y, size]` tuples or `{x, y, size}` objects.
**Chart typography**: Native classic chart typography mirrors the visible SVG
fallback. Copy fallback chart text sizes into metadata using the same px-style
unit as SVG text (`1px = 0.75pt`). Put the shared visible chart font in
`style.font_family`, and override local chart text objects or `data_labels`
with `font_family` only when the fallback visibly differs. If omitted, the
exporter infers the shared font family and base chart text size from visible
fallback text inside the native marker, but explicit metadata remains the
stable contract when roles differ. Typical mappings are chart title
(`title_font_size`), chart subtitle (`subtitle_font_size`), chart labels
(`axis_font_size`, shared by axis titles / ticks / legend unless the fallback
differs), and notes (`note_font_size`). Use `axis_title_font_size`,
`legend_font_size`, or per-entry companion `font_size` only when the fallback
visibly uses a separate size. When no explicit fallback size exists for a role,
default to compact PowerPoint text: `title_font_size: 16` (12pt),
`subtitle_font_size: 12` (9pt), `axis_font_size: 12` (9pt), and
`note_font_size: 12` (9pt).
**Chart chrome metadata**: Text that is visually part of the chart must be in
metadata, not only in SVG fallback children; metadata MUST still match visible
fallback chrome. `title` becomes the native chart title on classic charts; it
is not an object name, so use `name` for semantic object naming. `subtitle`
becomes the second rich-text line of that classic chart title. `title`,
`subtitle`, and axis-title values may be strings or objects with `text`,
`font_size`, `font_family`, and `color` when the fallback uses local role
typography. `svg_quality_checker.py` rejects `title`, `subtitle`, or axis-title
metadata whose text is not visible inside the native marker's fallback. Direct
`--native-objects` export keeps the chart native but omits that inconsistent
chrome with a warning. chartEx keeps PowerPoint's empty `<cx:title>` and emits
the title / subtitle as companion editable text boxes until chartEx rich titles
are validated. Axis
titles are optional and explicit: use `axis_titles` with
`category`, `value`, `x`, `y`, or `secondary_value` keys, or the root aliases
`category_axis_title`, `value_axis_title`, `x_axis_title`, `y_axis_title`, and
`secondary_value_axis_title`; do not add semantic axis titles that are not
visible in the fallback. Set `show_value_axis_labels: false` when the fallback
keeps category labels but omits numeric value-axis tick labels, such as a radar
chart without radial coordinates. Native legends are metadata-controlled: use
`show_legend: true` and `legend_position` only when the fallback's legend is
meant to be replaced by PowerPoint's native legend.
Companion text such as `caption`, `source`, `note`, `notes`, `footnote`, and
`footnotes` is exported as editable PPT text boxes next to the native chart. A
companion entry may be a string or an object with `text`, `x`, `y`, `width`,
`height`, `font_size`, `color`, `align`, and `bold`; explicit bounds are
recommended so the native export matches the SVG fallback placement. Explicit
companion bounds are slide coordinates, not local coordinates inside a
transformed marker group. Use companion text for chart captions, source notes,
center labels, and freeform annotations; use `data_labels` for values that
belong to chart points.
**Chart color styling**: For classic native charts, `style.colors` sets series
colors. The exporter also writes explicit chart-area fill, plot-area fill,
axis line, gridline, and label text colors so PowerPoint does not substitute a
white/default-theme chart. If omitted, the exporter infers these colors from
the visible SVG fallback: the largest panel-like `<rect>` becomes the chart
background, fallback text supplies label color, and fallback strokes supply
axis/grid colors. Override any of them explicitly under `style` with
`chart_area_fill`, `plot_area_fill`, `text_color`, `axis_color`, and
`grid_color`; use `"none"` for transparent chart or plot area fill. Color
values may be `#RRGGBB`, `#RGB`, `rgb(...)` / `rgba(...)`, or common CSS names
such as `white`, `black`, and `gray`; the exporter normalizes them to 6-digit
OOXML RGB. Bar and column series also disable PowerPoint's negative-value
inversion so negative bars keep the same series fill instead of turning into
white/theme fill.
**PowerPoint chartEx schema**: `treemap`, `sunburst`, `histogram`, `pareto`,
`boxWhisker`, `waterfall`, and `funnel` use Office 2016+ chartEx parts. Use
these input shapes:
| Type | Required data |
|---|---|
| `treemap`, `sunburst` | `values` plus either `levels` (`levels[level][point]`) or path-style `categories` (`[["Region", "Group", "Leaf"], ...]`) |
| `treemap` display note | Top-level group labels default to `overlapping`; override with `parent_label_layout: "banner" \| "overlapping" \| "none"`. PowerPoint labels only the top level and leaves — intermediate levels group tiles spatially without labels (sunburst shows every ring). |
| `histogram` | `values` |
| `pareto`, `waterfall`, `funnel` | `categories` + `values`; `waterfall` also accepts `subtotals` / `subtotal_indices` point indexes |
| `boxWhisker` | `series[].values`; optional `series[].categories` per value |
> Note: chartEx files are valid PPTX and editable in PowerPoint; non-Microsoft
> renderers can display a limited subset.
**Stock chart schema**: `stock` uses numeric Excel date serials in
`categories` or `dates`, plus exactly four series in open / high / low / close
order. Use either `series` with four entries, or top-level `open`, `high`,
`low`, and `close` arrays.
**Deferred chart types**: Exploded pie / doughnut variants, `map`, `heatmap`,
`bullet`, and `gantt` are intentionally outside the current native-object
support boundary. The exporter fails fast for these types until each mapping is
implemented and validated one by one.
**Supported chart types**:
- `column`, `bar`: `clustered`, `stacked`, or `percentStacked` (`grouping`)
- `line`: `standard`, `stacked`, or `percentStacked` (`grouping`); `line` or `lineMarker` (`line_style`, default `line` / no markers)
- `area`: `standard`, `stacked`, or `percentStacked` (`grouping`)
- `pie`: exactly one series, per-slice colors
- `doughnut`: exactly one series, per-slice colors
- `pieOfPie`, `barOfPie`: exactly one series, per-slice colors
- `radar`, `radarMarkers`, `radarFilled`
- `scatter`: `marker` (default), `lineMarker`, `line`, `smoothMarker`, or `smooth` (`scatter_style`)
- `bubble`: x/y/size series
- `combo`: `column`, `line`, and `area` plots, optional secondary value axis
- `treemap`, `sunburst`: hierarchical chartEx charts
- `histogram`, `pareto`
- `boxWhisker`
- `waterfall`, `funnel`
- `stock`: open / high / low / close series
3D chart aliases (`3DColumn`, `3DBar`, `3DLine`, `3DArea`, `3DPie`, cone,
cylinder, pyramid variants, and `surface`) are intentionally unsupported. They
add compatibility risk without meaningful presentation value.
Native chart legends are off by default because right-side legends routinely
steal plot area in PPT. Add `show_legend: true` only when the legend is needed;
`legend_position` defaults to `bottom` and also accepts `top`, `left`, or
`right`.
**Forbidden — native marker transforms**: Do not rotate, skew, or matrix-transform native table/chart marker groups. Translate / scale is accepted; complex transforms fail export because PowerPoint native table/chart frames do not preserve arbitrary SVG transforms.
### transform: rotate — Element Rotation
Rotation converts to native PPTX `<a:xfrm rot="...">`. Supported on all element types: `rect`, `circle`, `ellipse`, `line`, `path`, `polygon`, `polyline`, `image`, and `text`.
@@ -8,7 +8,7 @@ As a top-tier AI presentation strategist, receive source documents, perform cont
| Previous Step | Current | Next Step |
|--------------|---------|-----------|
| Project creation + Template option confirmed | **Strategist**: Eight Confirmations + Design Spec | Image_Generator or Executor |
| Project creation + Template option confirmed | **Strategist**: Strategist confirmation stage + Design Spec | Image_Generator or Executor |
---
@@ -18,20 +18,21 @@ As a top-tier AI presentation strategist, receive source documents, perform cont
---
## 1. Eight Confirmations Process
## 1. Strategist Confirmation Stage
🚧 **GATE — Mandatory read first**: `read_file templates/design_spec_reference.md` before any analysis or writing. The design_spec.md output MUST follow that template's 11-section structure exactly. After writing, self-check each section is present: I Project Info → II Canvas → III Visual Theme → IV Typography → V Layout → VI Icon → VII Visualization → VIII Image → IX Outline → X Speaker Notes → XI Tech Constraints.
**BLOCKING**: After the read, present professional recommendations for the eight items below and wait for explicit user confirmation.
**BLOCKING**: After the read, present professional recommendations for the confirmation fields below and wait for explicit user confirmation.
**Two-tier confirmation (the default Confirm UI flow; chat mirrors it).** The eight items split into a dependency order, confirmed in two passes:
**Three-stage confirmation (the default Confirm UI flow; chat mirrors it).** The confirmation fields split into a dependency order, confirmed in three passes:
| Tier | Items | Role |
| Stage | Items | Role |
|---|---|---|
| **1 — anchors** | `a` canvas · `c` key info — audience + `content_divergence` + **delivery purpose** *(PPT only)* (promoted out of `g`) · `d` mode + visual_style | confirmed first |
| **2 — realization** (re-derived from the user's *actual* Tier 1) | `b` page count · `e` color · `f` icon · `g` typography (font + size) · `h` image | derived from Tier 1 |
| **1 — direction anchors** | `a` canvas · `c` key info — audience + `content_divergence` + **delivery purpose** *(PPT only)* (promoted out of `g`) · `d` mode + visual_style | confirmed first |
| **2 — design system** (re-derived from the user's *actual* Stage 1) | `b` page count · `e` color · `f` icon · `g` typography (font + size, formula policy) | derived from Stage 1 |
| **3 — images / execution** (re-derived from the user's *actual* Stage 1 + Stage 2) | `h` image source + generated-image style · generation mode · refine-spec toggle | derived from confirmed direction + design system |
The realization items are anchored by Tier 1 — `visual_style` governs `e` / `f` / `g` / `h` (§d Layer 2), and delivery purpose sets the §g body size (one fixed value per purpose), page density, **and** the `b` page-count recommendation. So author Tier 2 *after* Tier 1 is confirmed, against the user's real choices. **Page count is derived, not an anchor** — it follows content volume × delivery purpose, which is why it is Tier 2. The launch / re-derive / wait mechanics live in [SKILL.md Step 4](../SKILL.md); the item specs below keep their `a``h` letters.
The design-system items are anchored by Stage 1 — `visual_style` governs `e` / `f` / `g` (§d Layer 2), and delivery purpose sets the §g body size (one fixed value per purpose), page density, **and** the `b` page-count recommendation. So author Stage 2 *after* Stage 1 is confirmed, against the user's real choices. Image strategy is anchored by both Stage 1 and Stage 2: h.5 rendering follows the locked visual direction, while h.5 palette is color behavior only and must follow the confirmed §e colors. So author Stage 3 *after* Stage 2 is confirmed. **Page count is derived, not an anchor** — it follows content volume × delivery purpose, which is why it is Stage 2. The launch / re-derive / wait mechanics live in [SKILL.md Step 4](../SKILL.md); the item specs below keep their `a``h` letters.
> **Execution discipline**: This is the last BLOCKING checkpoint in the pipeline. After confirmation, complete the Design Spec and proceed to image generation / SVG / post-processing without further pauses.
>
@@ -45,13 +46,13 @@ Recommend format based on scenario (see [`canvas-formats.md`](canvas-formats.md)
### b. Page Count Confirmation
**Tier-2 (derived).** Page count is not an anchor — recommend it only after the Tier-1 delivery purpose is confirmed, since the same source yields a different count by purpose. Provide a specific page count recommendation based on source document content volume **and the confirmed delivery purpose** (`text` packs denser → the same source fits in fewer pages; `presentation` is one-idea-per-page → the same source may need more) — see §6.1 Content Planning Strategy. The user's confirmed count still wins; delivery purpose governs density and per-page treatment within it.
**Stage-2 (derived).** Page count is not an anchor — recommend it only after the Stage-1 delivery purpose is confirmed, since the same source yields a different count by purpose. Provide a specific page count recommendation based on source document content volume **and the confirmed delivery purpose** (`text` packs denser → the same source fits in fewer pages; `presentation` is one-idea-per-page → the same source may need more) — see §6.1 Content Planning Strategy. The user's confirmed count still wins; delivery purpose governs density and per-page treatment within it.
### c. Key Information Confirmation
Confirm target audience, usage occasion, and core message; provide initial assessment based on document nature.
**Delivery purpose** (PPT only) is confirmed here, beside audience, as part of the key information — the deck's consumption mode: `text` (read-close) / `balanced` (business, default) / `presentation`. It is a Tier-1 anchor: it sets the §g body size to one fixed value per purpose, plus the type character, page density, and the §b page-count recommendation (the size and page count re-derived in Tier 2). Recommend one (`recommend.delivery_purpose`, default `balanced`) and let the user confirm. The fixed body value per purpose lives in §g; the density / treatment side lives in §6.1 — here it is surfaced as a key-information choice, not a separate typography step.
**Delivery purpose** (PPT only) is confirmed here, beside audience, as part of the key information — the deck's consumption mode: `text` (read-close) / `balanced` (business, default) / `presentation`. It is a Stage-1 anchor: it sets the §g body size to one fixed value per purpose, plus the type character, page density, and the §b page-count recommendation (the size and page count re-derived in Stage 2). Recommend one (`recommend.delivery_purpose`, default `balanced`) and let the user confirm. The fixed body value per purpose lives in §g; the density / treatment side lives in §6.1 — here it is surfaced as a key-information choice, not a separate typography step.
**Material divergence** — a **free-text** intent the user states beside audience (same content-strategy cluster): in their own words, how closely the deck should follow the source vs how freely it may reshape it. This is the user's own call — a free prose field (`content_divergence`), **not** a fixed set of options and **not** something you recommend from analyzing the source. Surface the question (in the confirm UI it is a text box under audience; in chat, ask it plainly); leave it for the user to fill. Blank = a balanced default.
@@ -85,11 +86,11 @@ Write the locked value to `spec_lock.md` `- mode:` and record the rationale in `
🚧 **GATE**: read [`visual-styles/_index.md`](./visual-styles/_index.md) before recommending.
The deck's **visual aesthetic** — shape language, decoration density, whitespace rhythm, typographic character, texture. Anchors the downstream confirmations e (Color), f (Icon), g (Typography), h (Image). Lock one preset from the catalog, or `custom`.
The deck's **visual aesthetic** — shape language, decoration density, whitespace rhythm, typographic character, texture. Anchors downstream fields e (Color), f (Icon), g (Typography), h (Image). Lock one preset from the catalog, or `custom`.
**Source**:
- User named a style (chat / template / beautify) → it is truth: map to the closest preset (or `custom` with a `visual_style_behavior` paragraph) and lock directly. **Skip the spectrum below** — do not re-offer choice they already made.
- No user description → **present a personality spectrum, not one safe pick** (this is the lever against "every deck looks the same" — the visual style is what most determines a deck's character, so it gets real choice, same hard rule and thinking as h.5). Author **≥3 distinct styles** from the index's auto-selection table spanning *safe* (the industry-norm recommendation) → *shifted* (an alternate one tick more expressive) → *bold* (a characterful style that challenges the default — `brutalist` / `zine` / `memphis` / `ink-wash` / `vintage-poster` etc., whenever the content can carry it). Give each a one-line **temperament tag + real-world analogy** (like h.5's "like an Economist feature"). Write the three to `recommendations.json` `visual_style_spectrum` (each `{id, tag_zh/en, note_zh/en}`) **and present the same three in chat** as the always-valid fallback; set `recommend.visual_style` to the *safe* pick as the pre-selected default. The user may pick any of the three, a style outside them, or Custom. Honest-shortfall exception (mirrors h.5): if the content genuinely supports fewer than 3 non-gimmicky directions, present the smaller set and say why — never pad with a style that fights the content.
- No user description → **present a personality spectrum, not one safe pick** (this is the lever against "every deck looks the same" — the visual style is what most determines a deck's character, so it gets real choice, same hard rule and thinking as h.5). Author **≥3 distinct styles** from the index's auto-selection table spanning *safe* (the industry-norm recommendation) → *shifted* (an alternate one tick more expressive) → *bold* (a characterful style that challenges the default — `brutalist` / `zine` / `memphis` / `ink-wash` / `vintage-poster` etc., whenever the content can carry it). Give each a one-line **temperament tag + real-world analogy** (like h.5's "like an Economist feature"). Write the three to `recommendations.json` `visual_style_spectrum` (each `{id, tag_zh/en/ja, note_zh/en/ja}` — include the `_ja` variants whenever the page `lang` is `ja`) **and present the same three in chat** as the always-valid fallback; set `recommend.visual_style` to the *safe* pick as the pre-selected default. The user may pick any of the three, a style outside them, or Custom. Honest-shortfall exception (mirrors h.5): if the content genuinely supports fewer than 3 non-gimmicky directions, present the smaller set and say why — never pad with a style that fights the content.
**Forbidden — a non-catalog name as `visual_style`**: the value MUST be an `id` from the visual-styles catalog (or genuine `custom` prose). A name that is **not** in that catalog is not a visual style — most often it is an image-rendering name from the `_index` "Paired rendering" column (`flat`, `vector-illustration`, `digital-dashboard`, `3d-isometric`, `corporate-photo`, …), which names the §h *illustration* family, not the deck's layout aesthetic. Do not borrow it. (Names that are intentionally **both** a style and its paired rendering — `glassmorphism`, `blueprint`, `editorial`, `dark-tech` — are valid styles because they *are* in the catalog.) Generic baseline words — `flat` / flat-design / 扁平 / modern / clean / simple / minimal — are **not** custom-worthy either: the whole system is flat by default (shadows discouraged), so map them to the closest preset (flat + grid → `swiss-minimal`; flat + rounded → `soft-rounded`; flat + dense → `brutalist`). Reserve `custom` for an aesthetic no preset covers.
@@ -143,7 +144,7 @@ See [`../templates/icons/README.md`](../templates/icons/README.md) for the curre
> **Mandatory rules when choosing C**:
>
> **At the eight-confirmation stage — decide the library only. Do NOT run `ls | grep` yet.**
> **At the Strategist confirmation stage — decide the library only. Do NOT run `ls | grep` yet.**
>
> 1. **Pick exactly one stylistic library** — read the source material, then choose the library whose visual character best serves the deck:
> - **`chunk-filled`** — fill, straight-line geometry (M/L/H/V/Z only); sharp right angles; heavy, solid, architectural
@@ -154,7 +155,7 @@ See [`../templates/icons/README.md`](../templates/icons/README.md) for the curre
> - **Brand-logo exception**: `simple-icons` is NOT a stylistic library. Add it to the deck's icon inventory **only when** the deck genuinely contains real company / product / service brand marks (customer logos, tech-stack icons, social handles). Never substitute it for a missing generic icon.
> 2. **Stroke weight lock (stroke-style libraries only)** — for stroke-based libraries (currently `tabler-outline`), pick one deck-wide value from `{1.5, 2, 3}` (default `2`). For heavier presence, switch library instead of going above `3`.
>
> **After all eight confirmations are approved — when writing `design_spec.md` §VI / `spec_lock.md`**, then materialize the icon inventory:
> **After the Strategist confirmation stage is approved — when writing `design_spec.md` §VI / `spec_lock.md`**, then materialize the icon inventory:
>
> 3. Enumerate the concepts the deck actually needs (home, chart, users, …) based on the confirmed outline.
> 4. Search for each concept's filename in the chosen library: `ls skills/ppt-master/templates/icons/<chosen-library>/ | grep <keyword>`
@@ -172,16 +173,16 @@ See [`../templates/icons/README.md`](../templates/icons/README.md) for the curre
#### Font Combinations
> Same-deck fonts must form **contrast** (different family, weight, or proportion) or **concord** (one family throughout). "Similar but not identical" pairings *across roles* are forbidden — see blacklist below. *Within one stack*, pairing a Windows font with a macOS counterpart (e.g. `Microsoft YaHei` + `PingFang SC`) is encouraged as a browser-preview nicety; converter writes only the first into PPTX.
> Same-deck fonts must form **contrast** (different family, weight, or proportion) or **concord** (one family throughout). "Similar but not identical" pairings *across roles* are forbidden — see blacklist below. *Within one stack*, pairing a Windows font with a macOS counterpart (e.g. `Microsoft YaHei` + `PingFang SC`) is a browser-preview nicety; converter writes resolved Latin / EA typefaces into PPTX, not the CSS fallback tail.
> **⚠️ PPT-safe font discipline (HARD rule).** PPTX has no runtime fallback — missing fonts substitute to Calibri. Every stack MUST end with a pre-installed font:
> **⚠️ PPT-safe font discipline (HARD rule).** PPTX has no runtime fallback — missing fonts substitute to Calibri. Each stack's exported Latin / EA typefaces MUST resolve to pre-installed fonts:
> - CJK → `"Microsoft YaHei"` / `SimHei` / `SimSun` / `FangSong` / `KaiTi`
> - Latin sans → `Arial` / `Calibri` / `Segoe UI` / `Verdana` / `Trebuchet MS`
> - Latin serif → `"Times New Roman"` / `Georgia` / `Cambria` / `Palatino` / `Garamond`
> - Mono → `Consolas` / `"Courier New"`
> - Display → `Impact` / `"Arial Black"`
>
> Stacks led by non-pre-installed fonts (Inter / HarmonyOS Sans / Source Han / brand typefaces like McKinsey Bower) are only acceptable when the Design Spec notes "requires install or PPTX embed".
> Stacks that export non-pre-installed typefaces (Inter / HarmonyOS Sans / Source Han / brand typefaces like McKinsey Bower) are only acceptable when the Design Spec notes "requires install or PPTX embed".
**Forbidden — similar-but-not-identical pairings across roles** (do not split title vs body across these; within one stack as cross-platform fallback they remain encouraged):
@@ -221,7 +222,7 @@ See [`../templates/icons/README.md`](../templates/icons/README.md) for the curre
| Tech / developer | `Arial, "Microsoft YaHei", sans-serif` | same | `Consolas, "Courier New", monospace` |
| Concord (single family — pick the family by subject + `visual_style`) | `<family by subject>, …, sans-serif / serif` | same | — |
> **Stack length discipline (soft rule).** ≤4 fonts per stack. The **first** CJK and first Latin font MUST be pre-installed — the converter writes only those, and a non-installed lead substitutes to Calibri ([`drawingml_utils.py parse_font_family`](../scripts/svg_to_pptx/drawingml_utils.py)). Choose that lead from the safe set **by the locked style's character**: `Microsoft YaHei` / `Arial` are the *neutral* members — perfect as the tail fallback and as the lead only when the style is plain-sans, but **not the automatic lead for every deck**. For a styled title, lead with `SimHei` / `KaiTi` / `FangSong` / `SimSun` / `Georgia` / `Cambria` / `Impact` / `Consolas` as the character asks. Keep at most **one** macOS-exclusive family (typically `"PingFang SC"`); macOS→Windows fallback is auto-mapped via `FONT_FALLBACK_WIN`.
> **Stack length discipline (soft rule).** ≤4 fonts per stack. The exported CJK / Latin typefaces MUST be pre-installed after converter resolution ([`drawingml/utils.py parse_font_family`](../scripts/svg_to_pptx/drawingml/utils.py)). Choose those exported faces from the safe set **by the locked style's character**: `Microsoft YaHei` / `Arial` are the *neutral* members — perfect as the tail fallback and as the lead only when the style is plain-sans, but **not the automatic lead for every deck**. For a styled title, lead with `SimHei` / `KaiTi` / `FangSong` / `SimSun` / `Georgia` / `Cambria` / `Impact` / `Consolas` as the character asks. Keep at most **one** macOS-exclusive family (typically `"PingFang SC"`); macOS→Windows fallback is auto-mapped via `FONT_FALLBACK_WIN`.
> **Non-pre-installed directions** (require install or PPTX embed; note the constraint in Design Spec):
> - **Retro / pixel** — Press Start 2P / VT323 / Silkscreen
@@ -240,7 +241,7 @@ See [`../templates/icons/README.md`](../templates/icons/README.md) for the curre
> **Unit boundary (HARD rule).** The system is **px-only**. `recommendations.json`, the Confirm UI, `result.json`, `design_spec.md`, `spec_lock.md`, and SVG **all carry unitless px** — there is no pt layer anywhere, and no pt→px conversion step. pt exists only as the size PowerPoint happens to show *after export* (`px × 0.75`, rounded to 1 decimal); it is never an input, a confirmation value, or a provenance field (no `body_size_pt` / `sizes_pt`). Never write `pt` / `px` / `em` units; every layer carries bare px numbers. Geometry — margins, gaps, card sizes — is px too. (Beautify reads a source deck's pt at intake but converts to px **before** any recommendation — see [`beautify-pptx.md`](../workflows/beautify-pptx.md); pt never re-enters the contract.)
**Baseline — one fixed value per delivery purpose, not a range.** Delivery purpose is a **Tier-1 anchor confirmed in §c key information** (beside audience, not as a separate typography step — see §1 Two-tier confirmation); it is the primary driver because the same canvas reads very differently when read close vs. projected. It is a **deck-wide** axis — beyond the body baseline it also drives page density / count / rhythm; see §6.1 for that side. Here, with the purpose confirmed, it sets the body baseline to a single value:
**Baseline — one fixed value per delivery purpose, not a range.** Delivery purpose is a **Stage-1 anchor confirmed in §c key information** (beside audience, not as a separate typography step — see §1 Three-stage confirmation); it is the primary driver because the same canvas reads very differently when read close vs. projected. It is a **deck-wide** axis — beyond the body baseline it also drives page density / count / rhythm; see §6.1 for that side. Here, with the purpose confirmed, it sets the body baseline to a single value:
| Delivery purpose (PPT 16:9) | Body (px) | Reads as |
|---|---:|---|
@@ -303,7 +304,7 @@ Formula rendering is part of Typography confirmation. Recommend one policy and l
**Forbidden — invented math**: formula assets must faithfully structure source content. Do not create a new equation just to make a slide look more academic.
**Manifest step**: After the Eight Confirmations and before writing `design_spec.md`, if policy is `mixed` or `render-all` and formulas are selected:
**Manifest step**: After the Strategist confirmation stage and before writing `design_spec.md`, if policy is `mixed` or `render-all` and formulas are selected:
```bash
mkdir -p <project_path>/images
@@ -352,6 +353,8 @@ The script renders PNGs into `images/`, trying `codecogs`, `quicklatex`, `mathpa
> **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.
> **Default — human-scale topics lean illustrated (may override when factual assets dominate)**: When the source is about personal finance, family life, daily routines, education, wellness, healing, or children, and there are no supplied assets that already carry the story, recommend `image_usage: "ai"` instead of `none`. Use `image_notes.value` to name a warm illustration motif (for example a home ledger, school-day routine, care scene, or life-planning metaphor). Do not apply this default to regulated investor decks, B2B finance reports, or data-only dashboards; those stay eligible for `none` / `web` / `provided` by judgment.
> **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.
@@ -388,7 +391,7 @@ The script renders PNGs into `images/`, trying `codecogs`, `quicklatex`, `mathpa
| **Path B** | `IMAGE_BACKEND` not configured AND host has a native image tool (Codex / Antigravity / Claude Code / similar) — auto-selected, no user prompting needed | Host-native generation |
| **Offline Manual** | `IMAGE_BACKEND` not configured AND host has no native image tool | Prompts written to `images/image_prompts.json`; user generates externally and places files in `project/images/` |
Selection is automatic in Step 5 (A → B → Manual). Detailed contract: [`image-generator.md`](./image-generator.md) §3.2.
Selection is automatic in Step 5 (A → B → Manual). Detailed contract: [`image-generator.md`](./image-generator.md) §7 Path Selection (Deterministic).
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.
@@ -419,6 +422,10 @@ After the candidates, append one line:
> Reference images: see references/ai-image-comparison/ for matching PNGs by name.
```
**Confirm UI packaging**: when writing `recommendations.json` Stage 3, put **exactly three non-custom** generated-image recommendations in `image_strategy.candidates`. The page appends one built-in **Custom** card after those three recommendations, so do not use a `custom` candidate as a fourth option or as a slot filler in the UI payload.
**Confirmed Custom card prose**: when `result.json.image_strategy.custom` is non-empty, treat it as a confirmed deck-wide image-direction constraint. Write the prose into `design_spec.md §III` image direction notes and carry it into every `Acquire Via: ai` prompt brief / `images/image_prompts.json` prompt guidance that it applies to. It augments the locked `rendering` + `palette` ids; preserve those ids for catalog / comparison-reference behavior, and use the custom prose for subject, composition, texture, avoidance, or other prompt specifics.
**Hard rules for candidate construction**:
| Rule | Behavior |
@@ -429,7 +436,7 @@ After the candidates, append one line:
| `Mood` line MUST include a real-world analogy | Company / publication / event the user can picture. Adjective stacks alone are forbidden. |
| Adapt labels to chat language | Schema is English by default. Chinese chat → render as 「方案 A / 视觉 / 色彩 / 情绪」. Structure stays the same; only the labels translate. |
| Skip presentation when user has specified | User-named rendering or palette (chat / brand / template), **or a Confirm UI pick in `result.json.image_strategy`** (same shape as color / typography honoring their confirmed candidate), bypasses the candidate flow — lock *that chosen candidate's* `rendering` + `palette` directly per the truth-precedence rule; do not re-pick. |
| `custom` is a tail-case, not a default | When no preset fits, a candidate may set `rendering: custom` and / or `palette: custom` (rules: [`image-renderings/_index.md`](../image-renderings/_index.md) §1.5, [`image-palettes/_index.md`](../image-palettes/_index.md) §2). At most one candidate per dimension may carry `custom`; one candidate may carry both dimensions as `custom`. `Visual` / `Color` lines describe the behavior in prose, never by naming a competing preset. |
| `custom` is a tail-case, not a default | When no preset fits, a candidate may set `rendering: custom` and / or `palette: custom` (rules: [`image-renderings/_index.md`](./image-renderings/_index.md) §1.5, [`image-palettes/_index.md`](./image-palettes/_index.md) §2). At most one candidate per dimension may carry `custom`; one candidate may carry both dimensions as `custom`. `Visual` / `Color` lines describe the behavior in prose, never by naming a competing preset. |
**Forbidden — padding with conflicts**: if e.'s HEX cannot find ≥3 compatible palettes, present the smaller set (2 candidates) and state "your color is unusual — only N palettes can carry it without conflict." A `custom` candidate is allowed only when its prose genuinely describes a tail-case the presets cannot — not as a slot-filler. Never fill remaining slots with known-conflicting options.
@@ -548,7 +555,7 @@ After auto-selecting, cross-check `image-palettes/_index.md` compatibility matri
- image_palette: cool-corporate
```
**Hard rule — `custom` recording**: when the picked candidate has `rendering: custom` or `palette: custom`, also write the sibling `*_behavior` row. Source: the candidate's `Visual` line (for rendering) / `Color` line (for palette), expanded to cover the prose requirements in [`image-renderings/_index.md`](../image-renderings/_index.md) §1.5 / [`image-palettes/_index.md`](../image-palettes/_index.md) §2 (chat candidates are compressed; spec_lock prose covers all axes). Both `design_spec.md` and `spec_lock.md` must carry the behavior line. Example for the `custom × custom` candidate above:
**Hard rule — `custom` recording**: when the picked candidate has `rendering: custom` or `palette: custom`, also write the sibling `*_behavior` row. Source: the candidate's `Visual` line (for rendering) / `Color` line (for palette), expanded to cover the prose requirements in [`image-renderings/_index.md`](./image-renderings/_index.md) §1.5 / [`image-palettes/_index.md`](./image-palettes/_index.md) §2 (chat candidates are compressed; spec_lock prose covers all axes). Both `design_spec.md` and `spec_lock.md` must carry the behavior line. Example for the `custom × custom` candidate above:
```
- image_rendering: custom
@@ -670,11 +677,11 @@ The catalog covers **both data charts and structural information designs**. A "m
The most common Strategist failure mode is missing the structural half — treating "chart" as "numeric chart only" and leaving team / agenda / principles / journey pages as text-only when a template would fit. Read the catalog with both lenses.
> **Reading is mandatory; the catalog is a starting point, not a copy target.**
> - Fully read `templates/charts/charts_index.json` **before drafting the Eight Confirmations** — the read happens up front, not when you sit down to write Section VII. The file contains `meta` + `charts.<key>.summary` only; each `summary` is a selection rule (`"Pick for … Skip if …"`), not a description. There is **no category, quickLookup, or keyword index** — selection is done by semantically matching each page's content shape against all 71 summaries in one pass.
> - Fully read `templates/charts/charts_index.json` **before drafting the Strategist confirmation stage** — the read happens up front, not when you sit down to write Section VII. The file contains `meta` + `charts.<key>.summary` only; each `summary` is a selection rule (`"Pick for … Skip if …"`), not a description. There is **no category, quickLookup, or keyword index** — selection is done by semantically matching each page's content shape against all 76 summaries in one pass.
> - Not every page needs a chart. When a page's information structure matches a catalog entry, **use that template as a structural starting point** — keep the visualization type and core layout logic, then adapt composition, density, color, decoration, and accompanying elements to fit this deck's content and visual tone. Free adjustment is encouraged; what is forbidden is (a) generating without reading the catalog, and (b) blind verbatim mimicry that ignores the page's actual content weight.
>
> **Workflow**:
> 1. Read all 71 summaries; for each page, identify the Pick clause that matches the page's content shape AND does not match any Skip clause.
> 1. Read all 76 summaries; for each page, identify the Pick clause that matches the page's content shape AND does not match any Skip clause.
> 2. Prefer specificity (`vertical_list` over generic `numbered_steps`).
> 3. One primary visualization per page; a supporting layout may accompany it.
> 4. List selections in Design Spec section VII; section IX only notes the visualization type name per page.
@@ -683,11 +690,11 @@ The most common Strategist failure mode is missing the structural half — treat
>
> **Read-audit (mandatory, section VII format)** — single combined table; `summary-quote` column is the anti-fabrication audit, `path` + `usage` serve Executor lookup. Format defined in [`templates/design_spec_reference.md`](../templates/design_spec_reference.md) §VII:
> ```
> Catalog read: 71 templates
> Catalog read: 76 templates
>
> | Page | Template | Path | Summary-quote (verbatim) | Usage |
> | ---- | ------------- | --------------------------------- | ------------------------ | ----- |
> | P03 | bar_chart | templates/charts/bar_chart.svg | "<verbatim first sentence>" | <intent> |
> | P03 | column_chart | templates/charts/column_chart.svg | "<verbatim first sentence>" | <intent> |
> | P07 | line_chart | templates/charts/line_chart.svg | "<verbatim first sentence>" | <intent> |
> | P11 | pie_chart | templates/charts/pie_chart.svg | "<verbatim first sentence>" | <intent> |
>
@@ -792,7 +799,7 @@ Templates are starting points. The Strategist may adjust based on content and au
Content-outline and speaker-notes strategy follow the deck's locked **mode** — see [`modes/_index.md`](./modes/_index.md) and the locked mode's file. The guidance below applies within any mode:
**Delivery purpose drives the whole plan, not just type size.** `result.json delivery_purpose``text` (read-close) / `balanced` (business, default) / `presentation`, confirmed as a Tier-1 anchor (§1) — is a **deck-wide consumption mode**. It seeds the body baseline (§g) **and** governs how content is distributed:
**Delivery purpose drives the whole plan, not just type size.** `result.json delivery_purpose``text` (read-close) / `balanced` (business, default) / `presentation`, confirmed as a Stage-1 anchor (§1) — is a **deck-wide consumption mode**. It seeds the body baseline (§g) **and** governs how content is distributed:
| Delivery purpose | Per-page density & treatment | §IX content per page | page_rhythm lean |
|---|---|---|---|
@@ -8,7 +8,7 @@ Technical spec and workflow for adding images to SVG files.
## Image Resource List Format
Defined in the Design Specification & Content Outline; each image carries an `Acquire Via` field plus a status annotation. This file is authoritative for status names and SVG embedding behavior. If image approach includes "B) User-provided": run `analyze_images.py` right after the Eight Confirmations and complete the list before outputting the design spec.
Defined in the Design Specification & Content Outline; each image carries an `Acquire Via` field plus a status annotation. This file is authoritative for status names and SVG embedding behavior. If image approach includes "B) User-provided": run `analyze_images.py` right after the Strategist confirmation stage and complete the list before outputting the design spec.
```markdown
| Filename | Dimensions | Purpose | Type | Acquire Via | Status | Reference |
@@ -135,16 +135,19 @@ python3 scripts/finalize_svg.py <project_path>
python3 scripts/svg_to_pptx.py <project_path>
```
### Standalone: embed_images.py (advanced)
### Standalone: align_embed_images.py (advanced)
For processing specific SVGs without the full pipeline:
```bash
python3 scripts/svg_finalize/embed_images.py <svg_file> # Single file
python3 scripts/svg_finalize/embed_images.py <project_path>/svg_output/*.svg # Batch
python3 scripts/svg_finalize/embed_images.py --dry-run <project_path>/svg_output/*.svg # Preview
python3 scripts/svg_finalize/align_embed_images.py <svg_file>
python3 scripts/svg_finalize/align_embed_images.py --dry-run <svg_file>
```
Use `finalize_svg.py --only align-images` for project-level batches. The old
`crop-images`, `fix-aspect`, and `embed-images` step names are compatibility
aliases only when invoked through `finalize_svg.py --only`.
---
## Best Practices
@@ -194,7 +194,7 @@ When rewriting an existing template that contains the omitted sections, delete t
### 2. Inherit Design Specification
Templates must strictly follow the finalized template brief and the generated `design_spec.md`:
- **Canvas dimensions**: `canvas_format` is not enough; root SVG `width` / `height` / `viewBox` match `canvas_width`, `canvas_height`, and `canvas_viewbox` in the design spec
- **Canvas dimensions**: `canvas_format` is not enough; root SVG `viewBox` matches `canvas_viewbox` in the design spec. Root `width` / `height` are optional compatibility attributes and are not PPT Master canvas authority.
- **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
- **Font plan**: Uses the per-role font families declared in the spec
@@ -15,6 +15,7 @@
# Uses Office-compatible mode (PNG + SVG dual format) by default, supports all Office versions.
# 默认使用 Office 兼容模式(PNG + SVG 双格式),支持所有 Office 版本
python-pptx>=0.6.21
XlsxWriter>=3.0.0
# Recorded narration / 录制计时和旁白
# notes_to_audio.py generates per-slide narration audio on macOS/Linux/Windows.
@@ -5,7 +5,8 @@ This directory contains user-facing scripts for conversion, project setup, direc
## Directory Layout
- Top-level `scripts/`: runnable entry scripts
- `scripts/source_to_md/`: source-document → Markdown converters (`pdf_to_md.py`, `doc_to_md.py`, `excel_to_md.py`, `ppt_to_md.py`, `web_to_md.py`)
- `scripts/source_to_md.py`: unified source-document → Markdown dispatcher
- `scripts/source_to_md/`: source-document → Markdown routing/batch helpers and backend converters (`_dispatcher.py`, `_batch.py`, `pdf_to_md.py`, `doc_to_md.py`, `excel_to_md.py`, `ppt_to_md.py`, `web_to_md.py`)
- `scripts/image_backends/`: internal provider implementations used by `image_gen.py`
- `scripts/tts_backends/`: internal TTS provider implementations used by `notes_to_audio.py`
- `scripts/template_import/`: internal PPTX reference-preparation helpers used by `pptx_template_import.py`
@@ -18,12 +19,14 @@ This directory contains user-facing scripts for conversion, project setup, direc
Typical end-to-end workflow:
```bash
python3 scripts/source_to_md.py <file-or-url-or-dir> [<file-or-url-or-dir> ...]
# or direct backend calls:
python3 scripts/source_to_md/pdf_to_md.py <file.pdf>
# or
python3 scripts/source_to_md/ppt_to_md.py <deck.pptx>
python3 scripts/source_to_md/excel_to_md.py <workbook.xlsx>
python3 scripts/project_manager.py init <project_name> --format ppt169
python3 scripts/project_manager.py import-sources <project_path> <source_files...> --move
python3 scripts/project_manager.py import-sources <project_path> <source_files_or_dirs...> --move
python3 scripts/total_md_split.py <project_path>
python3 scripts/finalize_svg.py <project_path>
python3 scripts/animation_config.py scaffold <project_path> # optional object-level animation overrides
@@ -40,7 +43,7 @@ python3 scripts/update_repo.py
| Area | Primary scripts | Documentation |
|------|-----------------|---------------|
| Conversion | `source_to_md/pdf_to_md.py`, `source_to_md/doc_to_md.py`, `source_to_md/excel_to_md.py`, `source_to_md/ppt_to_md.py`, `source_to_md/web_to_md.py`, `pptx_intake.py` | [docs/conversion.md](./docs/conversion.md) |
| Conversion | `source_to_md.py`, `source_to_md/pdf_to_md.py`, `source_to_md/doc_to_md.py`, `source_to_md/excel_to_md.py`, `source_to_md/ppt_to_md.py`, `source_to_md/web_to_md.py`, `pptx_intake.py` | [docs/conversion.md](./docs/conversion.md) |
| Project management | `project_manager.py`, `batch_validate.py`, `generate_examples_index.py`, `error_helper.py`, `pptx_template_import.py`, `template_fill_pptx.py`, `native_enhance_pptx.py` | [docs/project.md](./docs/project.md) |
| SVG pipeline | `finalize_svg.py`, `svg_to_pptx.py`, `total_md_split.py`, `svg_quality_checker.py`, `extract_svg_assets.py`, `animation_config.py`, `notes_to_audio.py` | [docs/svg-pipeline.md](./docs/svg-pipeline.md) |
| Spec maintenance | `update_spec.py` | [docs/update_spec.md](./docs/update_spec.md) |
@@ -53,6 +56,7 @@ python3 scripts/update_repo.py
Conversion:
```bash
python3 scripts/source_to_md.py <file-or-url-or-dir> [<file-or-url-or-dir> ...]
python3 scripts/source_to_md/pdf_to_md.py <file.pdf>
python3 scripts/source_to_md/ppt_to_md.py <deck.pptx>
python3 scripts/source_to_md/doc_to_md.py <file.docx>
@@ -64,7 +68,7 @@ Project setup:
```bash
python3 scripts/project_manager.py init <project_name> --format ppt169
python3 scripts/project_manager.py import-sources <project_path> <source_files...> --move
python3 scripts/project_manager.py import-sources <project_path> <source_files_or_dirs...> --move
python3 scripts/project_manager.py validate <project_path>
```
@@ -203,8 +203,8 @@ class BatchValidator:
f" Reference: examples/google_annual_report_ppt169_20251116/README.md")
if self.summary['svg_issues'] > 0:
print(f" 2. Check and fix SVG viewBox settings")
print(f" Ensure consistency with canvas format")
print(f" 2. Check SVG root viewBox settings")
print(f" The SVG root viewBox is the export canvas authority")
if self.summary['missing_spec'] > 0:
print(f" 3. Add design specification files")
@@ -1 +1 @@
"""PPT Master - interactive Eight Confirmations UI (Step 4)."""
"""PPT Master - interactive Strategist confirmation stage UI (Step 4)."""
@@ -1,8 +1,8 @@
#!/usr/bin/env python3
"""
PPT Master - Eight Confirmations UI Server (Step 4)
PPT Master - Strategist confirmation stage UI Server (Step 4)
Lightweight Flask backend for the interactive, visual Eight Confirmations page.
Lightweight Flask backend for the interactive, visual Strategist confirmation stage page.
Strategist writes its recommendations to
``<project>/confirm_ui/recommendations.json``; this server renders them as a
clickable page (color swatches, live font previews, candidate picks). On
@@ -11,7 +11,7 @@ submit it writes the user's final choices to
This is the confirmation surface only. The chat fallback always remains valid:
if the browser cannot open (remote / headless / web host), the AI presents the
same Eight Confirmations in chat.
same Strategist confirmation stage in chat.
See scripts/docs/confirm_ui.md for the round-trip data contract and schema.
@@ -33,6 +33,7 @@ import atexit
import json
import logging
import os
import re
import signal
import subprocess
import sys
@@ -75,6 +76,14 @@ RESULT_NAME = 'result.json'
# Static option universe served at /api/catalogs (canvas synced live from config).
_CATALOGS_PATH = Path(__file__).resolve().parent / 'static' / 'catalogs.json'
_ICON_LIBRARY_DIR = Path(__file__).resolve().parents[2] / 'templates' / 'icons'
_AI_IMAGE_COMPARISON_DIR = Path(__file__).resolve().parents[2] / 'references' / 'ai-image-comparison'
_ICON_PREVIEW_SAMPLES = {
'chunk-filled': ('home', 'chart-line', 'users', 'target'),
'tabler-filled': ('home', 'chart-dots', 'user', 'bulb'),
'tabler-outline': ('home', 'chart-line', 'users', 'bulb'),
'phosphor-duotone': ('house', 'chart-line', 'users', 'target'),
}
# Shares port 5050 with the live preview server (svg_editor/server.py). The two
# never run at once: confirm is Step 4 and shuts down on confirm (or idle),
@@ -128,26 +137,56 @@ def _wait_for_result(
def _result_stage(result_file: Path) -> Optional[str]:
"""Return the ``stage`` field of result.json (``tier1`` / ``final``), or None."""
"""Return the canonical ``stage`` field of result.json, or None."""
try:
data = json.loads(result_file.read_text(encoding='utf-8'))
except (OSError, json.JSONDecodeError):
return None
return data.get('stage') if isinstance(data, dict) else None
return _stage_key(data.get('stage')) if isinstance(data, dict) else None
# Tier-1 anchors. On the Tier-2 page these sections are not rendered (they were
# confirmed in Tier 1), so their values live only in browser STATE — lost on a
# refresh. Folding them from result.json into the served Tier-2 recommendations
# lets the page re-initialize them from the user's actual choices.
def _stage_key(value: object) -> Optional[str]:
"""Normalize current stage names while accepting legacy tier values."""
if value is None:
return None
raw = str(value).strip().lower()
if raw in {'1', 'stage1', 'tier1'}:
return 'stage1'
if raw in {'2', 'stage2', 'tier2'}:
return 'stage2'
if raw in {'3', 'stage3', 'tier3'}:
return 'stage3'
if raw == 'final':
return 'final'
return None
def _recommendation_stage(data: dict) -> int:
"""Return recommendations.json stage number, with legacy tier fallback."""
stage = _stage_key(data.get('stage'))
if not stage and 'tier' in data:
stage = _stage_key(data.get('tier'))
if stage == 'stage1':
return 1
if stage == 'stage2':
return 2
if stage == 'stage3':
return 3
return 0
# Stage-1 anchors and Stage-2 design-system choices. On later pages these sections
# are not rendered (they were already confirmed), so their values live only in
# browser STATE — lost on a refresh. Folding them from result.json into the
# served recommendations lets a refresh / reopen re-initialize from the user's
# actual choices instead of catalog defaults.
_ANCHOR_RECOMMEND_KEYS = ('canvas', 'mode', 'visual_style', 'delivery_purpose')
_ANCHOR_VALUE_KEYS = ('audience', 'content_divergence')
_DESIGN_RECOMMEND_KEYS = ('icons', 'formula_policy')
def _merge_confirmed_anchors(data: dict, result_file: Path) -> None:
"""Fold the Tier-1 anchors confirmed in result.json into a Tier-2
recommendations payload, so a refresh / reopen re-initializes them. The
confirmed value is truth it overrides any stale recommend entry."""
def _merge_confirmed_choices(data: dict, result_file: Path) -> None:
"""Fold already-confirmed choices into later-stage recommendations."""
try:
res = json.loads(result_file.read_text(encoding='utf-8'))
except (OSError, json.JSONDecodeError):
@@ -163,42 +202,52 @@ def _merge_confirmed_anchors(data: dict, result_file: Path) -> None:
for key in _ANCHOR_VALUE_KEYS:
if key in res:
data[key] = {'value': res.get(key) or ''}
if _recommendation_stage(data) < 3:
return
for key in _DESIGN_RECOMMEND_KEYS:
if res.get(key) not in (None, ''):
recommend[key] = res[key]
if 'page_count' in res:
data['page_count'] = {'value': res.get('page_count') or ''}
if isinstance(res.get('color'), dict):
data['color'] = {'selected': 0, 'candidates': [res['color']]}
if isinstance(res.get('typography'), dict):
typography = {'selected': 0, 'candidates': [res['typography']]}
if res.get('formula_policy') not in (None, ''):
typography['formula_policy'] = {'value': res['formula_policy']}
data['typography'] = typography
def _wait_only_for_result(
result_file: Path,
lock_file: Path,
timeout: int,
target_stage: str = 'final',
) -> int:
"""Attach to an already-running confirm server and wait for the **final**
(tier-2) result.json the second leg of the two-tier flow. No child is
launched here: the page is still open from the first ``--wait`` launch, so
liveness is tracked via the recorded pid, not a ``proc`` handle. Only the
``stage == 'final'`` guard is used (no mtime gate): this run's tier-1 confirm
already left result.json at stage ``tier1``, so any ``final`` is the tier-2
submit, and a mtime gate would race-miss a user who confirms before this wait.
"""Attach to an already-running confirm server and wait for a target stage.
No child is launched here: the page is still open from the first ``--wait``
launch, so liveness is tracked via the recorded pid, not a ``proc`` handle.
Only the stage guard is used (no mtime gate), because intermediate submits
may happen before this wait command is issued.
"""
logger.info('waiting for tier-2 browser confirmation...')
logger.info('waiting for browser confirmation stage=%s...', target_stage)
deadline = None if timeout <= 0 else time.time() + timeout
while True:
# `stage == 'final'` alone is the signal: this run's tier-1 confirm already
# overwrote result.json to stage 'tier1', so any 'final' here is the tier-2
# submit we are waiting for. Gating on mtime >= started_at would race-miss a
# user who confirms tier 2 before this wait is even issued.
if _result_stage(result_file) == 'final':
logger.info('tier-2 confirmation received: %s', result_file)
if _result_stage(result_file) == target_stage:
logger.info('confirmation stage=%s received: %s', target_stage, result_file)
return 0
lock = _read_lock(lock_file)
pid = int((lock or {}).get('pid', 0) or 0)
if not pid or not _process_alive(pid):
logger.error('confirm server is no longer running before tier 2 was confirmed')
logger.error('confirm server is no longer running before stage=%s was confirmed', target_stage)
return 1
if deadline is not None and time.time() >= deadline:
logger.error(
'timed out waiting for tier-2 confirmation — the page may still '
'be open; re-check %s before falling back to chat', result_file,
'timed out waiting for confirmation stage=%s — the page may still '
'be open; re-check %s before falling back to chat', target_stage, result_file,
)
return 124
@@ -254,7 +303,7 @@ def _build_catalogs() -> dict:
"""Return the static catalog set with the canvas list synced live from
``config.CANVAS_FORMATS`` the single source of truth for canvas formats
so the confirm page can never drift from the pipeline's real formats. The
set of formats and their dimensions come from config; bilingual labels and
set of formats and their dimensions come from config; trilingual labels and
use text are kept from catalogs.json (with a plain fallback for any new id).
"""
data = json.loads(_CATALOGS_PATH.read_text(encoding='utf-8'))
@@ -285,6 +334,61 @@ def _build_catalogs() -> dict:
return data
def _icon_preview_svg(library: str, name: str) -> str:
"""Read a trusted sample SVG from the bundled icon templates."""
icon_path = _ICON_LIBRARY_DIR / library / f'{name}.svg'
raw = icon_path.read_text(encoding='utf-8')
raw = re.sub(r'<\?xml[^>]*>\s*', '', raw)
raw = re.sub(r'<!--.*?-->\s*', '', raw, flags=re.S)
return raw.strip()
def _build_icon_previews() -> dict:
previews = {}
for library, names in _ICON_PREVIEW_SAMPLES.items():
items = []
for name in names:
try:
items.append({'name': name, 'svg': _icon_preview_svg(library, name)})
except OSError as exc:
logger.warning('icon preview sample missing: %s/%s (%s)', library, name, exc)
previews[library] = items
return previews
def _ai_comparison_items(kind: str) -> list[dict[str, str]]:
manifest = _AI_IMAGE_COMPARISON_DIR / kind / '_manifest.json'
if not manifest.exists():
return []
data = json.loads(manifest.read_text(encoding='utf-8'))
items = []
for item in data.get('items', []):
filename = item.get('filename')
if not isinstance(filename, str) or not filename.endswith('.png'):
continue
if not re.fullmatch(r'[A-Za-z0-9_.-]+\.png', filename):
continue
if not (_AI_IMAGE_COMPARISON_DIR / kind / filename).exists():
continue
item_id = Path(filename).stem
items.append({
'id': item_id,
'label': item.get('type') or item_id,
'filename': filename,
'purpose': item.get('purpose') or '',
'alt_text': item.get('alt_text') or '',
})
return items
def _build_ai_image_comparison() -> dict:
return {
'rendering': _ai_comparison_items('rendering'),
'palette': _ai_comparison_items('palette'),
'type': _ai_comparison_items('type'),
}
# --- app --------------------------------------------------------------------
def create_app(
@@ -352,6 +456,32 @@ def create_app(
except (OSError, json.JSONDecodeError) as exc:
return jsonify({'error': f'invalid catalogs.json: {exc}'}), 500
@app.route('/api/icon-previews')
def get_icon_previews():
"""Serve real sample icons from templates/icons for the icon chooser."""
resp = jsonify(_build_icon_previews())
resp.headers['Cache-Control'] = 'no-store'
return resp
@app.route('/api/ai-image-comparison')
def get_ai_image_comparison_manifest():
"""Serve generated-image reference options from ai-image-comparison."""
try:
resp = jsonify(_build_ai_image_comparison())
resp.headers['Cache-Control'] = 'no-store'
return resp
except (OSError, json.JSONDecodeError) as exc:
return jsonify({'error': f'invalid ai-image-comparison manifest: {exc}'}), 500
@app.route('/ai-image-comparison/<kind>/<filename>')
def get_ai_image_comparison(kind: str, filename: str):
"""Serve reference images for generated-image strategy candidates."""
if kind not in {'rendering', 'palette', 'type'}:
return jsonify({'error': 'invalid comparison kind'}), 404
if not re.fullmatch(r'[A-Za-z0-9_.-]+\.png', filename or ''):
return jsonify({'error': 'invalid comparison filename'}), 404
return send_from_directory(_AI_IMAGE_COMPARISON_DIR / kind, filename)
@app.route('/api/recommendations')
def get_recommendations():
"""Serve the Strategist-authored recommendations for this project."""
@@ -365,14 +495,13 @@ def create_app(
# Report whether a result already exists (re-open after confirm).
result_file = confirm_dir / RESULT_NAME
data['_already_confirmed'] = result_file.exists()
# Tier 2 renders only realization sections, so fold the confirmed Tier-1
# anchors from result.json back in — a refresh / reopen then re-inits
# canvas / audience / mode / visual_style / delivery_purpose from the
# user's choices instead of catalog defaults.
if data.get('tier') == 2 and result_file.exists():
_merge_confirmed_anchors(data, result_file)
# The page polls this endpoint after a tier-1 confirm until the AI
# overwrites the file with the re-derived tier-2 recommendations, so it
# Later stages render only downstream sections, so fold earlier confirmed
# choices from result.json back in. A refresh / reopen then re-inits from
# the user's choices instead of catalog defaults.
if _recommendation_stage(data) >= 2 and result_file.exists():
_merge_confirmed_choices(data, result_file)
# The page polls this endpoint after a stage-1 confirm until the AI
# overwrites the file with the re-derived stage-2 recommendations, so it
# must never be served from a cache.
resp = jsonify(data)
resp.headers['Cache-Control'] = 'no-store'
@@ -386,19 +515,23 @@ def create_app(
return jsonify({'error': 'invalid payload'}), 400
confirm_dir.mkdir(parents=True, exist_ok=True)
result = dict(payload)
# Two-tier flow: a tier-1 submit records the anchor choices but does NOT
# close the page (the AI re-derives tier 2, the same browser session
# renders it). Only a final submit is a full confirmation. A payload with
# no stage is a single-pass confirmation (chat-opt-out parity).
stage = result.get('stage')
result['status'] = 'tier1-confirmed' if stage == 'tier1' else 'confirmed'
# Staged flow: stage-1 / stage-2 submits record intermediate choices but do
# NOT close the page. Only a final submit is a full confirmation. A
# payload with no stage is a single-pass confirmation (chat-opt-out parity).
stage = _stage_key(result.get('stage'))
if stage in {'stage1', 'stage2'}:
result['stage'] = stage
result['status'] = f'{stage}-confirmed'
else:
result['stage'] = 'final'
result['status'] = 'confirmed'
result['confirmed_at'] = time.strftime('%Y-%m-%dT%H:%M:%S')
result_file = confirm_dir / RESULT_NAME
result_file.write_text(
json.dumps(result, ensure_ascii=False, indent=2),
encoding='utf-8',
)
logger.info('%s confirmation written to %s', stage or 'final', result_file)
logger.info('%s confirmation written to %s', result['stage'], result_file)
return jsonify({'status': 'ok'})
return app
@@ -406,7 +539,7 @@ def create_app(
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description='PPT Master Eight Confirmations UI',
description='PPT Master Strategist confirmation stage UI',
formatter_class=argparse.RawDescriptionHelpFormatter,
)
parser.add_argument('project_dir', help='Path to project directory')
@@ -426,9 +559,12 @@ def build_parser() -> argparse.ArgumentParser:
parser.add_argument(
'--wait-only', action='store_true',
help='Do not launch. Attach to the already-running confirm server for '
'this project and wait for the final (tier-2) result.json. Used for '
'the second leg of the two-tier flow after the tier-2 '
'recommendations have been re-derived and written.',
'this project and wait for an already-open page to write result.json.',
)
parser.add_argument(
'--wait-stage', default='final', metavar='{stage2,final}',
help='With --wait-only, wait for this result.json stage (default: final). '
'Use stage2 for the middle handoff in the three-stage flow.',
)
parser.add_argument(
'--wait-timeout', type=int, default=WAIT_TIMEOUT_DEFAULT,
@@ -462,19 +598,24 @@ def main(argv: Optional[list[str]] = None) -> int:
if not project_path.is_dir():
logger.error('%s is not a directory', project_path)
return 1
wait_stage = _stage_key(args.wait_stage)
if wait_stage not in {'stage2', 'final'}:
logger.error('--wait-stage must be stage2 or final')
return 2
# Step 4 cleanup: stop any lingering confirm server and exit. Independent of
# recommendations.json (the page may never have been confirmed).
if args.shutdown:
return _shutdown_existing(project_path / LOCK_FILE_NAME)
# Tier-2 wait: attach to the server already serving this project (launched by
# the first --wait) and block until the page writes the final result.json.
# Staged wait: attach to the server launched by the first --wait and block
# until the page writes the requested intermediate or final result.json.
if args.wait_only:
return _wait_only_for_result(
project_path / CONFIRM_DIR_NAME / RESULT_NAME,
project_path / LOCK_FILE_NAME,
args.wait_timeout,
wait_stage,
)
rec_file = project_path / CONFIRM_DIR_NAME / RECOMMENDATIONS_NAME
File diff suppressed because it is too large Load Diff
@@ -1,85 +1,101 @@
{
"_comment": "Enumerable (finite) option universe for the confirm page. Served by Flask static at /static/catalogs.json; the front-end prefers /api/catalogs when the server provides it. These fields list ALL options and the AI marks one as recommended via recommendations.json `recommend`. Open/generative fields (color, typography, generated-image style) are NOT here — the AI authors >=3 candidates each for those (creative recommendations always offer real choice). 'canvas' mirrors scripts/config.py CANVAS_FORMATS — keep in sync. User-facing catalog text supports label_zh/label_en, desc_zh/desc_en, group_zh/group_en.",
"_comment": "Enumerable (finite) option universe for the confirm page. Served by Flask static at /static/catalogs.json; the front-end prefers /api/catalogs when the server provides it. These fields list ALL options and the AI marks one as recommended via recommendations.json `recommend`. Open/generative fields (color, typography, generated-image style) are NOT here — the AI authors >=3 candidates each for those (creative recommendations always offer real choice). 'canvas' mirrors scripts/config.py CANVAS_FORMATS — keep in sync. User-facing catalog text supports label_zh/label_en/label_ja, desc_zh/desc_en/desc_ja, group_zh/group_en/group_ja.",
"canvas": [
{
"id": "ppt169",
"label": "PPT 16:9",
"label_zh": "PPT 16:9",
"label_en": "PPT 16:9",
"label_ja": "PPT 16:9",
"dim": "1280×720",
"viewbox": "0 0 1280 720",
"use_zh": "现代投影 / 在线演示",
"use_en": "Modern projectors / online"
"use_en": "Modern projectors / online",
"use_ja": "現代的なプロジェクター/オンライン発表"
},
{
"id": "ppt43",
"label": "PPT 4:3",
"label_zh": "PPT 4:3",
"label_en": "PPT 4:3",
"label_ja": "PPT 4:3",
"dim": "1024×768",
"viewbox": "0 0 1024 768",
"use_zh": "传统投影",
"use_en": "Traditional projectors"
"use_en": "Traditional projectors",
"use_ja": "従来型プロジェクター"
},
{
"id": "wechat",
"label": "公众号头图 2.35:1",
"label_zh": "公众号头图 2.35:1",
"label_en": "WeChat Header 2.35:1",
"label_ja": "WeChat ヘッダー 2.35:1",
"dim": "900×383",
"viewbox": "0 0 900 383",
"use_zh": "微信公众号文章封面",
"use_en": "WeChat article cover"
"use_en": "WeChat article cover",
"use_ja": "WeChat記事のカバー画像"
},
{
"id": "xiaohongshu",
"label": "小红书 3:4",
"label_zh": "小红书 3:4",
"label_en": "RED 3:4",
"label_ja": "小紅書(RED3:4",
"dim": "1242×1660",
"viewbox": "0 0 1242 1660",
"use_zh": "知识分享 / 种草封面",
"use_en": "Knowledge sharing"
"use_en": "Knowledge sharing",
"use_ja": "知識共有/おすすめ紹介カバー"
},
{
"id": "moments",
"label": "社交方图 1:1",
"label_zh": "社交方图 1:1",
"label_en": "Social Square 1:1",
"label_ja": "SNS正方形 1:1",
"dim": "1080×1080",
"viewbox": "0 0 1080 1080",
"use_zh": "社交媒体方图",
"use_en": "Social square"
"use_en": "Social square",
"use_ja": "SNS用正方形画像"
},
{
"id": "story",
"label": "竖屏 9:16",
"label_zh": "竖屏 9:16",
"label_en": "Vertical 9:16",
"label_ja": "縦型 9:16",
"dim": "1080×1920",
"viewbox": "0 0 1080 1920",
"use_zh": "短视频 / 竖屏封面",
"use_en": "Short-video / story cover"
"use_en": "Short-video / story cover",
"use_ja": "ショート動画/ストーリーカバー"
},
{
"id": "banner",
"label": "横幅 16:9",
"label_zh": "横幅 16:9",
"label_en": "Banner 16:9",
"label_ja": "バナー 16:9",
"dim": "1920×1080",
"viewbox": "0 0 1920 1080",
"use_zh": "网页横幅 / 大屏",
"use_en": "Web banner / big screen"
"use_en": "Web banner / big screen",
"use_ja": "Webバナー/大画面"
},
{
"id": "a4",
"label": "A4 1:1.41",
"label_zh": "A4 1:1.41",
"label_en": "A4 1:1.41",
"label_ja": "A4 1:1.41",
"dim": "1240×1754",
"viewbox": "0 0 1240 1754",
"use_zh": "打印 / 文档导出",
"use_en": "Print / PDF export"
"use_en": "Print / PDF export",
"use_ja": "印刷/PDF出力"
}
],
"modes": [
@@ -88,40 +104,50 @@
"label": "pyramid",
"label_zh": "金字塔",
"label_en": "pyramid",
"label_ja": "ピラミッド",
"desc_zh": "结论先行、相互独立且完全穷尽的论证、每个数据都带比较。适合决策、分析、战略、汇报。",
"desc_en": "Lead with the answer, use MECE reasoning, and compare every key data point. Best for decisions, analysis, strategy, and reporting."
"desc_en": "Lead with the answer, use MECE reasoning, and compare every key data point. Best for decisions, analysis, strategy, and reporting.",
"desc_ja": "結論を先に示し、MECEな論理構成で、主要データは必ず比較を添える。意思決定・分析・戦略・報告に最適。"
},
{
"id": "narrative",
"label": "narrative",
"label_zh": "叙事",
"label_en": "narrative",
"label_ja": "ナラティブ",
"desc_zh": "情境、张力、解决方案形成故事弧线,保留悬念与转折。适合路演、案例、品牌、融资。",
"desc_en": "Build a story arc from context to tension to resolution, with suspense and turns. Best for roadshows, cases, brand stories, and fundraising."
"desc_en": "Build a story arc from context to tension to resolution, with suspense and turns. Best for roadshows, cases, brand stories, and fundraising.",
"desc_ja": "状況設定から緊張、解決へとストーリーアークを描き、意外性や転換を残す。ロードショー・事例紹介・ブランドストーリー・資金調達向け。"
},
{
"id": "instructional",
"label": "instructional",
"label_zh": "教学",
"label_en": "instructional",
"label_ja": "教育・解説",
"desc_zh": "概念拆解、分步推进、并列讲解。适合培训、教程、科普、知识分享。",
"desc_en": "Break concepts down, advance step by step, and explain parallel ideas clearly. Best for training, tutorials, popular science, and knowledge sharing."
"desc_en": "Break concepts down, advance step by step, and explain parallel ideas clearly. Best for training, tutorials, popular science, and knowledge sharing.",
"desc_ja": "概念を分解し、段階的に進め、並列する要素を明確に説明する。研修・チュートリアル・科学解説・知識共有に最適。"
},
{
"id": "showcase",
"label": "showcase",
"label_zh": "展演",
"label_en": "showcase",
"label_ja": "ショーケース",
"desc_zh": "视觉主导,大图、大数和情绪节奏优先。适合发布、品牌秀、活动。",
"desc_en": "Use visual rhythm, large images, and strong numbers as the lead. Best for launches, brand showcases, and events."
"desc_en": "Use visual rhythm, large images, and strong numbers as the lead. Best for launches, brand showcases, and events.",
"desc_ja": "ビジュアル主導で、大きな画像・大きな数字・感情のリズムを優先する。発表会・ブランドショー・イベント向け。"
},
{
"id": "briefing",
"label": "briefing",
"label_zh": "简报",
"label_en": "briefing",
"label_ja": "ブリーフィング",
"desc_zh": "中性完整、便于速览,采用主题式标题和均匀权重。适合周报、参考材料、常见问题、会议包。",
"desc_en": "Stay neutral, complete, and scannable with topical headings and balanced weight. Best for weekly reports, reference packs, FAQs, and meeting decks."
"desc_en": "Stay neutral, complete, and scannable with topical headings and balanced weight. Best for weekly reports, reference packs, FAQs, and meeting decks.",
"desc_ja": "中立的かつ網羅的で一覧しやすく、トピック見出しと均等な重み付けを用いる。週次報告・参考資料・FAQ・会議資料に最適。"
}
],
"visual_styles": [
@@ -129,46 +155,57 @@
"group": "Corporate / product",
"group_zh": "企业 / 产品",
"group_en": "Corporate / product",
"group_ja": "企業/プロダクト",
"items": [
{
"id": "swiss-minimal",
"label": "swiss-minimal",
"label_zh": "瑞士极简",
"label_en": "swiss-minimal",
"label_ja": "スイス・ミニマル",
"desc_zh": "网格锁定、锐利层级、大留白、少装饰。",
"desc_en": "Grid-locked layout, sharp hierarchy, generous whitespace, and minimal decoration."
"desc_en": "Grid-locked layout, sharp hierarchy, generous whitespace, and minimal decoration.",
"desc_ja": "グリッドに沿ったレイアウト、明快な階層、余白を活かした装飾の少ないデザイン。"
},
{
"id": "soft-rounded",
"label": "soft-rounded",
"label_zh": "柔和圆角",
"label_en": "soft-rounded",
"label_ja": "ソフト・ラウンド",
"desc_zh": "圆角卡片、柔和投影、亲和现代。",
"desc_en": "Rounded cards, soft shadows, and a friendly modern tone."
"desc_en": "Rounded cards, soft shadows, and a friendly modern tone.",
"desc_ja": "角丸のカード、柔らかい影、親しみやすいモダンな雰囲気。"
},
{
"id": "glassmorphism",
"label": "glassmorphism",
"label_zh": "玻璃拟态",
"label_en": "glassmorphism",
"label_ja": "グラスモーフィズム",
"desc_zh": "半透明玻璃面板、渐变光感、漂浮层次。",
"desc_en": "Translucent glass panels, gradient light, and floating depth."
"desc_en": "Translucent glass panels, gradient light, and floating depth.",
"desc_ja": "半透明のガラスパネル、グラデーションの光、浮遊感のある奥行き。"
},
{
"id": "dark-tech",
"label": "dark-tech",
"label_zh": "深色科技",
"label_en": "dark-tech",
"label_ja": "ダークテック",
"desc_zh": "深色画布、发光强调、几何精确。",
"desc_en": "Dark canvas, glowing accents, and precise geometry."
"desc_en": "Dark canvas, glowing accents, and precise geometry.",
"desc_ja": "ダークな背景、光るアクセント、精密な幾何学表現。"
},
{
"id": "blueprint",
"label": "blueprint",
"label_zh": "蓝图",
"label_en": "blueprint",
"label_ja": "ブループリント",
"desc_zh": "深底蓝图线稿、等距结构、工程标注。",
"desc_en": "Dark blueprint linework, isometric structure, and engineering annotations."
"desc_en": "Dark blueprint linework, isometric structure, and engineering annotations.",
"desc_ja": "濃紺の設計図風の線画、アイソメトリック構造、技術的な注記。"
}
]
},
@@ -176,38 +213,47 @@
"group": "Editorial / publication",
"group_zh": "编辑 / 出版",
"group_en": "Editorial / publication",
"group_ja": "エディトリアル/出版",
"items": [
{
"id": "editorial",
"label": "editorial",
"label_zh": "编辑出版",
"label_en": "editorial",
"label_ja": "エディトリアル",
"desc_zh": "杂志式层级、栏线、衬线与无衬线交织。",
"desc_en": "Magazine hierarchy, column rules, and a serif/sans-serif mix."
"desc_en": "Magazine hierarchy, column rules, and a serif/sans-serif mix.",
"desc_ja": "雑誌のような階層構造、段組み罫線、セリフ・サンセリフの混在。"
},
{
"id": "photo-editorial",
"label": "photo-editorial",
"label_zh": "摄影编辑",
"label_en": "photo-editorial",
"label_ja": "フォトエディトリアル",
"desc_zh": "满版摄影主导,文字点缀与图注配合。",
"desc_en": "Full-bleed photography leads, supported by light text and captions."
"desc_en": "Full-bleed photography leads, supported by light text and captions.",
"desc_ja": "裁ち落とし写真を主役に、控えめなテキストとキャプションで補う。"
},
{
"id": "data-journalism",
"label": "data-journalism",
"label_zh": "数据新闻",
"label_en": "data-journalism",
"label_ja": "データジャーナリズム",
"desc_zh": "多栏微图表、侧栏、来源线、信息密度高。",
"desc_en": "Multi-column microcharts, sidebars, source lines, and dense information."
"desc_en": "Multi-column microcharts, sidebars, source lines, and dense information.",
"desc_ja": "多段組みの小型チャート、サイドバー、出典表記、高い情報密度。"
},
{
"id": "brutalist",
"label": "brutalist",
"label_zh": "粗野主义",
"label_en": "brutalist",
"label_ja": "ブルータリズム",
"desc_zh": "报纸密度、粗线框、原始结构、扁平表达。",
"desc_en": "Newspaper-like density, heavy rules, raw structure, and flat treatment."
"desc_en": "Newspaper-like density, heavy rules, raw structure, and flat treatment.",
"desc_ja": "新聞のような情報密度、太い罫線、素朴な構造、フラットな表現。"
}
]
},
@@ -215,38 +261,47 @@
"group": "Expressive / print",
"group_zh": "表现 / 印刷",
"group_en": "Expressive / print",
"group_ja": "表現的/印刷",
"items": [
{
"id": "memphis",
"label": "memphis",
"label_zh": "孟菲斯",
"label_en": "memphis",
"label_ja": "メンフィス",
"desc_zh": "撞色块、几何碎片、粗描边。",
"desc_en": "Contrasting color blocks, geometric scraps, and heavy outlines."
"desc_en": "Contrasting color blocks, geometric scraps, and heavy outlines.",
"desc_ja": "コントラストの強い色面、幾何学的な断片、太いアウトライン。"
},
{
"id": "zine",
"label": "zine",
"label_zh": "小刊",
"label_en": "zine",
"label_ja": "ジン(自主制作誌)",
"desc_zh": "孔版印刷错位、半调、有限色、印刷颗粒。",
"desc_en": "Riso offsets, halftones, limited colors, and print grain."
"desc_en": "Riso offsets, halftones, limited colors, and print grain.",
"desc_ja": "リソグラフのズレ、ハーフトーン、限られた色数、印刷の粒感。"
},
{
"id": "vintage-poster",
"label": "vintage-poster",
"label_zh": "复古海报",
"label_en": "vintage-poster",
"label_ja": "ヴィンテージポスター",
"desc_zh": "中世纪扁平色块、半调、复古几何暖意。",
"desc_en": "Mid-century flat color blocks, halftones, and warm vintage geometry."
"desc_en": "Mid-century flat color blocks, halftones, and warm vintage geometry.",
"desc_ja": "ミッドセンチュリー調のフラットな色面、ハーフトーン、温かみのあるレトロな幾何学模様。"
},
{
"id": "paper-cut",
"label": "paper-cut",
"label_zh": "剪纸",
"label_en": "paper-cut",
"label_ja": "切り絵",
"desc_zh": "层叠剪纸、层间柔影、手作质感。",
"desc_en": "Layered cut paper, soft inter-layer shadows, and a handmade feel."
"desc_en": "Layered cut paper, soft inter-layer shadows, and a handmade feel.",
"desc_ja": "重なり合う切り紙、層間の柔らかな影、手作り感のある質感。"
}
]
},
@@ -254,38 +309,47 @@
"group": "Hand-drawn / brush",
"group_zh": "手绘 / 笔触",
"group_en": "Hand-drawn / brush",
"group_ja": "手描き/筆致",
"items": [
{
"id": "sketch-notes",
"label": "sketch-notes",
"label_zh": "手绘笔记",
"label_en": "sketch-notes",
"label_ja": "手描きノート",
"desc_zh": "暖纸底、涂鸦线条、柔和彩块。",
"desc_en": "Warm paper, doodle lines, and soft color blocks."
"desc_en": "Warm paper, doodle lines, and soft color blocks.",
"desc_ja": "温かみのある紙の質感、落書き風の線、柔らかい色面。"
},
{
"id": "ink-notes",
"label": "ink-notes",
"label_zh": "墨迹笔记",
"label_en": "ink-notes",
"label_ja": "インクノート",
"desc_zh": "浅底、黑色手写墨迹、稀疏语义强调。",
"desc_en": "Light background, black handwritten ink, and sparse semantic emphasis."
"desc_en": "Light background, black handwritten ink, and sparse semantic emphasis.",
"desc_ja": "明るい背景に黒の手書きインク、要所を絞った強調表現。"
},
{
"id": "chalkboard",
"label": "chalkboard",
"label_zh": "黑板",
"label_en": "chalkboard",
"label_ja": "黒板",
"desc_zh": "深板岩底、粉笔笔触、粉彩强调。",
"desc_en": "Dark slate surface, chalk strokes, and pastel emphasis."
"desc_en": "Dark slate surface, chalk strokes, and pastel emphasis.",
"desc_ja": "濃いスレート調の背景に、チョークの筆致とパステルの強調色。"
},
{
"id": "ink-wash",
"label": "ink-wash",
"label_zh": "水墨",
"label_en": "ink-wash",
"label_ja": "水墨画",
"desc_zh": "宣纸留白、笔触、印章、静气,新中式表达。",
"desc_en": "Rice-paper whitespace, brushwork, seals, and a quiet contemporary Chinese tone."
"desc_en": "Rice-paper whitespace, brushwork, seals, and a quiet contemporary Chinese tone.",
"desc_ja": "画仙紙の余白、筆致、落款印、静謐な現代中国風の表現。"
}
]
},
@@ -293,14 +357,17 @@
"group": "Specialty",
"group_zh": "特殊风格",
"group_en": "Specialty",
"group_ja": "特殊スタイル",
"items": [
{
"id": "pixel-art",
"label": "pixel-art",
"label_zh": "像素风",
"label_en": "pixel-art",
"label_ja": "ピクセルアート",
"desc_zh": "严格像素网格、块状形体、有限色、扁平表达。",
"desc_en": "Strict pixel grid, blocky shapes, limited colors, and flat treatment."
"desc_en": "Strict pixel grid, blocky shapes, limited colors, and flat treatment.",
"desc_ja": "厳密なピクセルグリッド、ブロック状の形状、限られた色数、フラットな表現。"
}
]
}
@@ -311,46 +378,57 @@
"label": "chunk-filled",
"label_zh": "几何实心图标",
"label_en": "chunk-filled",
"label_ja": "幾何学的塗りつぶしアイコン",
"desc_zh": "实心、直线几何、锐利厚重,适合科技、工程、企业结构图。",
"desc_en": "Solid, straight, geometric, sharp, and heavy. Good for tech, engineering, and corporate structure diagrams."
"desc_en": "Solid, straight, geometric, sharp, and heavy. Good for tech, engineering, and corporate structure diagrams.",
"desc_ja": "塗りつぶし・直線的な幾何形状で、鋭くしっかりした印象。テック、エンジニアリング、企業組織図に適する。"
},
{
"id": "tabler-filled",
"label": "tabler-filled",
"label_zh": "圆润实心图标",
"label_en": "tabler-filled",
"label_ja": "丸みのある塗りつぶしアイコン",
"desc_zh": "实心、圆润曲线、中等视觉重量,适合亲和现代风格。",
"desc_en": "Solid, rounded, and medium-weight. Good for friendly modern styles."
"desc_en": "Solid, rounded, and medium-weight. Good for friendly modern styles.",
"desc_ja": "塗りつぶし・丸みのある曲線で、中程度の視覚的な重み。親しみやすいモダンなスタイルに適する。"
},
{
"id": "tabler-outline",
"label": "tabler-outline",
"label_zh": "线性描边图标",
"label_en": "tabler-outline",
"label_ja": "線形アウトラインアイコン",
"desc_zh": "线性描边,轻盈精致,适合屏幕阅读和编辑型图标。",
"desc_en": "Linear stroke, light and refined. Good for screen reading and editable icons."
"desc_en": "Linear stroke, light and refined. Good for screen reading and editable icons.",
"desc_ja": "線形のストロークで、軽やかで洗練された印象。画面表示や編集用アイコンに適する。"
},
{
"id": "phosphor-duotone",
"label": "phosphor-duotone",
"label_zh": "双色层次图标",
"label_en": "phosphor-duotone",
"label_ja": "デュオトーン階調アイコン",
"desc_zh": "双色层次、中等重量,适合现代产品和平台类表达。",
"desc_en": "Two-tone layering with medium weight. Good for modern product and platform narratives."
"desc_en": "Two-tone layering with medium weight. Good for modern product and platform narratives.",
"desc_ja": "2色の階調表現で中程度の重み。モダンなプロダクトやプラットフォームの説明に適する。"
},
{
"id": "emoji",
"label": "Emoji",
"label_zh": "表情符号",
"label_en": "Emoji",
"label_ja": "絵文字",
"desc_zh": "系统表情符号,适合轻量、社交、教学或快速识别场景;正式企业 / 咨询风谨慎使用。",
"desc_en": "System emoji for lightweight, social, instructional, or quick-recognition contexts; use carefully in formal corporate or consulting decks."
"desc_en": "System emoji for lightweight, social, instructional, or quick-recognition contexts; use carefully in formal corporate or consulting decks.",
"desc_ja": "システム絵文字。軽やかなSNS・教育・素早い視認が求められる場面向き。フォーマルな企業/コンサル資料では慎重に使用すること。"
},
{
"id": "none",
"label": "不用图标",
"label_zh": "不用图标",
"label_en": "No icons"
"label_en": "No icons",
"label_ja": "アイコンなし"
}
],
"image_usage": [
@@ -358,31 +436,36 @@
"id": "ai",
"label": "AI 生成",
"label_zh": "生成配图",
"label_en": "AI-generated"
"label_en": "AI-generated",
"label_ja": "AI生成"
},
{
"id": "web",
"label": "Web 来源",
"label_zh": "网络来源",
"label_en": "Web-sourced"
"label_en": "Web-sourced",
"label_ja": "Web由来"
},
{
"id": "provided",
"label": "用户提供",
"label_zh": "用户提供",
"label_en": "User-provided"
"label_en": "User-provided",
"label_ja": "ユーザー提供"
},
{
"id": "placeholder",
"label": "占位符",
"label_zh": "占位符",
"label_en": "Placeholder"
"label_en": "Placeholder",
"label_ja": "プレースホルダー"
},
{
"id": "none",
"label": "不使用图片",
"label_zh": "不使用图片",
"label_en": "No images"
"label_en": "No images",
"label_ja": "画像なし"
}
],
"image_ai_path": [
@@ -390,31 +473,38 @@
"id": "auto",
"label": "Automatic Path A → Path B → Offline Manual",
"label_zh": "自动选择",
"label_en": "Automatic Path A → Path B → Offline Manual"
"label_en": "Automatic Path A → Path B → Offline Manual",
"label_ja": "自動選択(Path A → Path B → オフライン手動)"
},
{
"id": "api",
"label": "Path A",
"label_zh": "接口后端",
"label_en": "Path A",
"label_ja": "Path A",
"desc_zh": "强制走接口后端,第 5 步运行 image_gen.py(需已配置 IMAGE_BACKEND)。",
"desc_en": "Force the API backend — image_gen.py runs in Step 5 (requires IMAGE_BACKEND)."
"desc_en": "Force the API backend — image_gen.py runs in Step 5 (requires IMAGE_BACKEND).",
"desc_ja": "APIバックエンドを強制使用。ステップ5でimage_gen.pyを実行(IMAGE_BACKENDの設定が必要)。"
},
{
"id": "host-native",
"label": "Path B",
"label_zh": "宿主原生工具",
"label_en": "Path B",
"label_ja": "Path B",
"desc_zh": "强制使用当前环境的原生图像工具生成(即使配置了 IMAGE_BACKEND 也跳过接口);需宿主提供原生图像工具(Codex / Claude Code 等)。",
"desc_en": "Force the host's native image tool (skips the API even if IMAGE_BACKEND is set); requires a host with a native image tool (Codex / Claude Code / similar)."
"desc_en": "Force the host's native image tool (skips the API even if IMAGE_BACKEND is set); requires a host with a native image tool (Codex / Claude Code / similar).",
"desc_ja": "現在の環境のネイティブ画像ツールを強制使用(IMAGE_BACKENDが設定されていてもAPIをスキップ)。ネイティブ画像ツールを持つホスト(Codex/Claude Codeなど)が必要。"
},
{
"id": "manual",
"label": "Offline Manual",
"label_zh": "离线手动",
"label_en": "Offline Manual",
"label_ja": "オフライン手動",
"desc_zh": "机制:写入图片提示词文件;用户在外部生成后放入项目图片目录。",
"desc_en": "Prompts written to images/image_prompts.json; user generates externally and places files in project/images/."
"desc_en": "Prompts written to images/image_prompts.json; user generates externally and places files in project/images/.",
"desc_ja": "images/image_prompts.jsonにプロンプトを書き出し、ユーザーが外部で生成した画像をproject/images/に配置する方式。"
}
],
"formula_policy": [
@@ -423,24 +513,30 @@
"label": "mixed",
"label_zh": "混合",
"label_en": "mixed",
"label_ja": "混合",
"desc_zh": "复杂公式渲染为图片;简单行内公式保持为可编辑文本或字符。",
"desc_en": "Render complex formula-worthy expressions to PNG; keep simple inline math as editable text / Unicode."
"desc_en": "Render complex formula-worthy expressions to PNG; keep simple inline math as editable text / Unicode.",
"desc_ja": "複雑な数式はPNG画像として描画し、簡単な行内数式は編集可能なテキスト/Unicode文字のまま残す。"
},
{
"id": "render-all",
"label": "render-all",
"label_zh": "全部渲染",
"label_en": "render-all",
"label_ja": "すべて画像化",
"desc_zh": "所有需要按公式处理的表达式都渲染为图片。",
"desc_en": "Render every formula-worthy expression to PNG."
"desc_en": "Render every formula-worthy expression to PNG.",
"desc_ja": "数式として扱うべき表現はすべてPNG画像として描画する。"
},
{
"id": "text-only",
"label": "text-only",
"label_zh": "仅文本",
"label_en": "text-only",
"label_ja": "テキストのみ",
"desc_zh": "不渲染公式;表达式保持为可编辑文本或字符。",
"desc_en": "Do not render formulas; keep expressions as editable text / Unicode."
"desc_en": "Do not render formulas; keep expressions as editable text / Unicode.",
"desc_ja": "数式は画像化せず、編集可能なテキスト/Unicode文字のまま保持する。"
}
],
"generation_mode": [
@@ -448,13 +544,15 @@
"id": "continuous",
"label": "continuous",
"label_zh": "连续模式",
"label_en": "continuous"
"label_en": "continuous",
"label_ja": "連続モード"
},
{
"id": "split",
"label": "split",
"label_zh": "分段模式",
"label_en": "split"
"label_en": "split",
"label_ja": "分割モード"
}
],
"delivery_purpose": [
@@ -463,24 +561,30 @@
"label": "text",
"label_zh": "文字型 · 近读",
"label_en": "text / read-close",
"label_ja": "文書型・近距離閲覧",
"desc_zh": "当文件读、近距离看(报告、数据密集 brief、留底材料)。正文 20px。",
"desc_en": "Read close as a file (report, data-dense brief, leave-behind). Body 20px."
"desc_en": "Read close as a file (report, data-dense brief, leave-behind). Body 20px.",
"desc_ja": "資料として手元で読む用途(報告書、データ密度の高いブリーフ、配布資料)。本文20px。"
},
{
"id": "balanced",
"label": "balanced",
"label_zh": "均衡 · 商务",
"label_en": "balanced / business",
"label_ja": "バランス型・ビジネス",
"desc_zh": "既投影也阅读(路演、业务评审)。默认档,正文 24px。",
"desc_en": "Both projected and read (roadshow, business review). The default tier, body 24px."
"desc_en": "Both projected and read (roadshow, business review). The default option, body 24px.",
"desc_ja": "投影でも閲覧でも使う用途(ロードショー、事業レビュー)。デフォルトの選択、本文24px。"
},
{
"id": "presentation",
"label": "presentation",
"label_zh": "展示型 · 演讲",
"label_en": "presentation / keynote",
"label_ja": "プレゼン型・講演",
"desc_zh": "投影、远距离扫一眼、内容稀疏(keynote、发布、课堂)。正文 32px。",
"desc_en": "Projected, glanced from the back, sparse (keynote, launch, classroom). Body 32px."
"desc_en": "Projected, glanced from the back, sparse (keynote, launch, classroom). Body 32px.",
"desc_ja": "投影用で後方からも一目で分かる、情報量を絞った構成(キーノート、発表会、講義)。本文32px。"
}
]
}
@@ -7,26 +7,47 @@
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<header id="topbar">
<div id="topbar-inner">
<div class="topbar-titles">
<h1 data-i18n="page_title">PPT Master - Confirm Design</h1>
<p id="topbar-hint" data-i18n="topbar_hint">After confirming, return to the chat and say “done”.</p>
<div id="app">
<aside id="preview-panel" aria-label="Design preview">
<div class="brand-block">
<div class="brand-mark">PM</div>
<div>
<div class="panel-kicker">PPT Master</div>
<div class="panel-title" data-i18n="page_title">PPT Master - Confirm Design</div>
</div>
</div>
<button id="btn-lang-toggle" class="btn-lang-toggle" title="Switch language"></button>
</div>
</header>
<div id="topbar-preview"></div>
</aside>
<main id="form">
<div id="loading" data-i18n="loading">Loading recommendations…</div>
<div id="error" style="display:none;"></div>
<div id="sections" style="display:none;"></div>
</main>
<section id="control-panel">
<header id="topbar">
<div id="topbar-inner">
<div class="topbar-titles">
<h1 data-i18n="page_title">PPT Master - Confirm Design</h1>
<p id="topbar-hint" data-i18n="topbar_hint">After confirming, return to the chat and say “done”.</p>
</div>
<div class="lang-select">
<button id="btn-lang-toggle" class="lang-select-btn" type="button" title="Switch language" aria-haspopup="listbox" aria-expanded="false"><span id="lang-current"></span><span class="lang-caret" aria-hidden="true"></span></button>
<ul id="lang-menu" class="lang-menu" role="listbox" hidden>
<li role="option" tabindex="0" aria-selected="false" data-lang="zh">中文</li>
<li role="option" tabindex="0" aria-selected="false" data-lang="en">English</li>
<li role="option" tabindex="0" aria-selected="false" data-lang="ja">日本語</li>
</ul>
</div>
</div>
</header>
<footer id="actionbar" style="display:none;">
<span id="confirm-status"></span>
<button id="btn-confirm" data-i18n="btn_confirm">Confirm</button>
</footer>
<main id="form">
<div id="loading" data-i18n="loading">Loading recommendations…</div>
<div id="error" style="display:none;"></div>
<div id="sections" style="display:none;"></div>
</main>
<footer id="actionbar" style="display:none;">
<span id="confirm-status"></span>
<button id="btn-confirm" data-i18n="btn_confirm">Confirm</button>
</footer>
</section>
</div>
<div id="confirmed-overlay" style="display:none;">
<div class="cf-card">
@@ -1,38 +1,107 @@
:root {
--bg: #f4f5f7;
--bg: #12121f;
--card: #ffffff;
--ink: #1d2430;
--muted: #6b7280;
--line: #e3e6ea;
--accent: #2f6df0;
--accent-soft: #eaf1fe;
--radius: 12px;
--shadow: 0 1px 3px rgba(20, 30, 50, 0.08), 0 4px 16px rgba(20, 30, 50, 0.05);
--accent: #4a9eff;
--accent-soft: #eaf4ff;
--shell: #16162a;
--shell-2: #12121f;
--shell-line: #2a2a4a;
--shell-ink: #e0e0e0;
--shell-muted: #8a8aaa;
--shell-hover: #2a2a4a;
--warning: #f5c542;
--radius: 10px;
--shadow: 0 4px 24px rgba(0, 0, 0, 0.22);
}
* { box-sizing: border-box; }
html { height: 100%; }
body {
margin: 0;
background: var(--bg);
height: 100%;
background: #1a1a2e;
color: var(--ink);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Microsoft YaHei",
"PingFang SC", "Hiragino Sans GB", sans-serif;
line-height: 1.5;
padding-bottom: 88px;
overflow: hidden;
}
/* ---- fullscreen shell ---- */
#app {
display: flex;
width: 100vw;
height: 100vh;
min-width: 0;
}
#preview-panel {
flex: 0 0 clamp(360px, 38vw, 560px);
min-width: 0;
background: var(--shell);
color: var(--shell-ink);
border-right: 1px solid var(--shell-line);
display: flex;
flex-direction: column;
gap: 18px;
padding: 0;
overflow: hidden;
}
#control-panel {
flex: 1;
min-width: 0;
height: 100vh;
display: flex;
flex-direction: column;
background: var(--shell-2);
}
.brand-block {
display: flex;
align-items: center;
gap: 12px;
padding: 14px 16px;
border-bottom: 1px solid var(--shell-line);
flex: none;
}
.brand-mark {
flex: none;
width: 38px;
height: 38px;
border-radius: 9px;
display: grid;
place-items: center;
background: #1e3a5f;
color: #fff;
font-weight: 760;
letter-spacing: .03em;
}
.panel-kicker {
font-size: 11px;
letter-spacing: .08em;
text-transform: uppercase;
color: var(--shell-muted);
}
.panel-title {
margin-top: 2px;
font-size: 14px;
font-weight: 650;
line-height: 1.35;
}
/* ---- top bar ---- */
#topbar {
position: sticky;
top: 0;
flex: none;
z-index: 10;
background: var(--card);
border-bottom: 1px solid var(--line);
background: var(--shell);
border-bottom: 1px solid var(--shell-line);
}
#topbar-inner {
max-width: 880px;
margin: 0 auto;
max-width: none;
margin: 0;
padding: 16px 24px;
display: flex;
align-items: center;
@@ -40,23 +109,119 @@ body {
gap: 16px;
}
.topbar-titles h1 { margin: 0; font-size: 18px; font-weight: 650; }
.topbar-titles p { margin: 2px 0 0; font-size: 13px; color: var(--muted); }
.btn-lang-toggle {
flex: none;
width: 38px; height: 38px;
border-radius: 50%;
border: 1px solid var(--line);
background: var(--card);
.topbar-titles h1 { color: var(--shell-ink); }
.topbar-titles p { margin: 2px 0 0; font-size: 13px; color: var(--shell-muted); }
#topbar-preview:empty { display: none; }
#topbar-preview {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 0 16px 16px;
}
#topbar-preview .style-preview {
position: static;
background: transparent;
padding: 0;
margin: 0;
color: var(--ink);
}
#topbar-preview .style-preview-label {
margin-bottom: 10px;
}
#topbar-preview .style-preview-card {
box-shadow: none;
flex-direction: column;
align-items: stretch;
gap: 14px;
padding: 18px;
min-height: 260px;
background: #ffffff;
}
#topbar-preview .spl-title {
color: var(--shell-ink);
}
#topbar-preview .spl-note {
color: var(--shell-muted);
}
#topbar-preview .sp-title,
#topbar-preview .sp-body-wrap {
white-space: normal;
}
#topbar-preview .sp-chip {
align-self: stretch;
justify-content: flex-start;
}
#topbar-preview .sp-icon-mark {
width: 34px;
height: 34px;
}
#topbar-preview .sp-icon-mark svg {
width: 30px;
height: 30px;
max-width: 30px;
max-height: 30px;
}
.lang-select {
position: relative;
flex: none;
}
.lang-select-btn {
display: flex;
align-items: center;
justify-content: space-between;
gap: 6px;
height: 38px;
min-width: 118px;
padding: 0 12px;
border-radius: 4px;
border: 1px solid #3a3a5a;
background: #1e1e3a;
color: #c0c0d8;
font-size: 14px;
cursor: pointer;
}
.btn-lang-toggle:hover { background: var(--bg); }
.lang-select-btn:hover { color: #ffffff; background: var(--shell-hover); border-color: #5a5a7a; }
.lang-caret { font-size: 10px; color: #8a8aa8; }
.lang-menu {
position: absolute;
right: 0;
top: calc(100% + 6px);
margin: 0;
padding: 4px;
list-style: none;
min-width: 110px;
background: #1e1e3a;
border: 1px solid #3a3a5a;
border-radius: 6px;
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.4);
z-index: 60;
}
.lang-menu li {
padding: 8px 12px;
border-radius: 6px;
font-size: 14px;
color: #c0c0d8;
cursor: pointer;
}
.lang-menu li:hover,
.lang-menu li:focus { background: var(--shell-hover); color: #ffffff; }
.lang-menu li.selected { color: #ffffff; font-weight: 650; }
/* ---- form ---- */
#form { max-width: 880px; margin: 0 auto; padding: 24px; }
#loading, #error { padding: 40px 0; text-align: center; color: var(--muted); }
#form {
flex: 1;
min-height: 0;
max-width: none;
margin: 0;
padding: 24px 32px;
overflow-y: auto;
}
#sections {
max-width: 1220px;
margin: 0 auto;
}
#loading, #error { padding: 40px 0; text-align: center; color: var(--shell-muted); }
#error { color: #b42318; }
.section {
@@ -79,7 +244,7 @@ body {
width: 22px; height: 22px;
border-radius: 50%;
background: var(--accent-soft);
color: var(--accent);
color: #1e4fa3;
font-size: 12px; font-weight: 700;
display: grid; place-items: center;
}
@@ -108,6 +273,92 @@ body {
font-weight: 600;
}
.chip-text { min-width: 0; }
.chip-copy { min-width: 0; }
.chip-with-preview {
width: 230px;
padding: 9px;
display: flex;
flex-direction: column;
gap: 8px;
align-items: stretch;
text-align: left;
}
.chip-with-preview .chip-text {
display: block;
font-size: 12.8px;
}
.chip-with-preview .rec-badge {
display: inline-block;
margin: 4px 0 0;
}
.chip-preview-visual_style {
width: 320px;
}
.option-preview {
width: 100%;
aspect-ratio: 16 / 9;
border: 1px solid var(--line);
border-radius: 7px;
overflow: hidden;
background: #fff;
}
.option-preview svg {
display: block;
width: 100%;
height: 100%;
}
.option-preview img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
.option-preview-icons { aspect-ratio: 16 / 7; }
.real-icon-preview {
height: 100%;
display: flex;
align-items: center;
justify-content: center;
padding: 8px;
background: #fff;
color: #2563eb;
}
.real-icon-sample {
min-width: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 6px;
}
.real-icon-mark {
width: 38px;
height: 38px;
display: grid;
place-items: center;
color: currentColor;
}
.real-icon-mark svg {
display: block;
width: 34px;
height: 34px;
max-width: 34px;
max-height: 34px;
overflow: visible;
}
.real-icon-preview-chunk-filled .real-icon-mark svg {
shape-rendering: crispEdges;
}
.real-icon-label {
max-width: 100%;
color: #64748b;
font-size: 9px;
line-height: 1.1;
text-align: center;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.text-input, .num-input {
width: 100%;
@@ -171,10 +422,85 @@ body {
}
.font-card-name { font-size: 13.5px; font-weight: 600; }
.font-card-meta { font-size: 11.5px; color: var(--muted); }
.image-strategy-previews {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 8px;
margin: 8px 0 10px;
}
.image-strategy-preview {
position: relative;
aspect-ratio: 16 / 9;
border: 1px solid var(--line);
border-radius: 8px;
overflow: hidden;
background: #fff;
}
.image-strategy-preview img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
.image-strategy-preview-label {
position: absolute;
left: 6px;
bottom: 6px;
padding: 2px 6px;
border-radius: 999px;
background: rgba(17, 24, 39, 0.74);
color: #fff;
font-size: 10.5px;
line-height: 1.4;
}
.image-strategy-preview-card {
flex-direction: column;
align-items: stretch;
}
.image-strategy-preview-visual {
width: 100%;
}
.image-strategy-preview-visual .image-strategy-previews {
grid-template-columns: 1fr;
margin: 0 0 12px;
}
.image-strategy-preview-visual .image-strategy-preview {
min-height: 150px;
}
.image-strategy-preview-empty {
padding: 14px 0 4px;
}
.image-strategy-manual-card {
background: var(--card);
}
.image-strategy-manual-controls {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 10px;
margin: 10px 0;
}
.image-strategy-select-wrap {
display: flex;
flex-direction: column;
gap: 5px;
}
.image-strategy-select-label {
font-size: 11.5px;
color: var(--muted);
}
.image-strategy-select {
width: 100%;
min-height: 34px;
padding: 6px 8px;
}
.image-strategy-manual-preview-host > .image-strategy-previews {
margin-bottom: 0;
}
.font-sample-heading { font-size: 24px; font-weight: 700; line-height: 1.3; }
.font-sample-body { font-size: 14px; color: #333; margin-top: 4px; }
.custom-typography-input { margin-top: 8px; min-height: 58px; resize: vertical; }
.custom-color-input { margin-top: 8px; min-height: 58px; resize: vertical; }
.image-strategy-custom-input { margin-top: 8px; min-height: 72px; 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-input { width: 96px; }
@@ -188,31 +514,34 @@ body {
/* ---- action bar ---- */
#actionbar {
position: fixed;
bottom: 0; left: 0; right: 0;
background: var(--card);
border-top: 1px solid var(--line);
flex: none;
background: var(--shell);
border-top: 1px solid var(--shell-line);
padding: 14px 24px;
display: flex;
align-items: center;
justify-content: flex-end;
gap: 16px;
z-index: 10;
}
#confirm-status { font-size: 13px; color: var(--muted); }
#confirm-status {
min-height: 20px;
font-size: 13px;
color: var(--shell-muted);
}
#confirm-status.done { color: #067647; font-weight: 600; }
#btn-confirm {
border: none;
background: var(--accent);
color: #fff;
background: var(--warning);
color: #1a1a2e;
font-size: 14.5px;
font-weight: 600;
min-width: 132px;
padding: 10px 26px;
border-radius: 9px;
cursor: pointer;
}
#btn-confirm:hover { background: #2459cf; }
#btn-confirm:disabled { background: #9db4e8; cursor: default; }
#btn-confirm:hover { background: #e5b532; }
#btn-confirm:disabled { background: #6d6450; color: #b8b0a0; cursor: default; }
/* ---- custom input ---- */
.custom-input { margin-top: 8px; }
@@ -283,7 +612,7 @@ body {
#confirmed-overlay {
position: fixed;
inset: 0;
background: rgba(244, 245, 247, 0.94);
background: rgba(17, 18, 29, 0.86);
display: flex;
align-items: center;
justify-content: center;
@@ -306,7 +635,7 @@ body {
.style-preview {
position: sticky;
z-index: 5;
background: var(--bg);
background: var(--shell-2);
padding: 8px 0;
margin-bottom: 16px;
}
@@ -349,6 +678,42 @@ body {
text-overflow: ellipsis;
}
.sp-body-lat { margin-left: 10px; opacity: 0.92; }
.sp-content {
display: grid;
gap: 10px;
}
.sp-content-row {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 10px;
border: 1px solid rgba(0, 0, 0, 0.07);
border-radius: 8px;
background: rgba(255, 255, 255, 0.68);
}
.sp-content-icon {
flex: none;
width: 32px;
height: 32px;
display: grid;
place-items: center;
color: currentColor;
}
.sp-content-copy {
min-width: 0;
display: flex;
flex-direction: column;
gap: 3px;
}
.sp-content-copy b {
font-size: 13px;
line-height: 1.3;
}
.sp-content-copy small {
color: #64748b;
font-size: 11.5px;
line-height: 1.45;
}
.sp-chip {
flex: none;
display: inline-flex;
@@ -360,3 +725,104 @@ body {
}
.sp-chip-dot { width: 12px; height: 12px; border-radius: 50%; }
.sp-chip-label { font-size: 12.5px; }
.sp-icon-mark {
width: 24px;
height: 24px;
display: grid;
place-items: center;
color: currentColor;
}
.sp-icon-mark svg {
display: block;
width: 22px;
height: 22px;
max-width: 22px;
max-height: 22px;
overflow: visible;
}
.sp-icon-emoji {
font-size: 20px;
line-height: 1;
}
.sp-icon-none {
color: #64748b;
font-size: 11px;
white-space: nowrap;
}
.sp-icon-none-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: currentColor;
opacity: .55;
}
.direction-preview-card {
gap: 14px;
}
.direction-preview-visual {
overflow: hidden;
border: 1px solid var(--line);
border-radius: 9px;
background: #fff;
}
.direction-preview-visual svg,
.direction-preview-visual img {
display: block;
width: 100%;
aspect-ratio: 16 / 9;
object-fit: cover;
}
.direction-preview-title {
color: var(--ink);
font-size: 16px;
font-weight: 700;
}
.direction-preview-desc {
margin-top: 4px;
color: var(--muted);
font-size: 12.5px;
line-height: 1.55;
}
@media (max-width: 1040px) {
body {
height: auto;
overflow: auto;
background: var(--shell-2);
}
#app {
display: block;
width: auto;
height: auto;
min-height: 100vh;
}
#preview-panel {
width: auto;
border: 0;
border-bottom: 1px solid var(--shell-line);
overflow: visible;
padding: 0;
}
#control-panel {
height: auto;
min-height: 0;
}
#form {
overflow: visible;
padding: 20px;
}
#actionbar {
position: sticky;
bottom: 0;
padding: 14px 20px;
}
.chip-with-preview {
width: min(100%, 230px);
}
.chip-preview-visual_style {
width: min(100%, 320px);
}
.style-preview-card {
flex-wrap: wrap;
}
}
@@ -0,0 +1,39 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1280 720">
<defs>
<pattern id="bp-grid" width="40" height="40" patternUnits="userSpaceOnUse">
<path d="M40 0H0V40" fill="none" stroke="#2E6EA8" stroke-width="0.8" opacity="0.55"/>
</pattern>
<pattern id="bp-grid5" width="200" height="200" patternUnits="userSpaceOnUse">
<path d="M200 0H0V200" fill="none" stroke="#5AA0D6" stroke-width="1.2" opacity="0.5"/>
</pattern>
</defs>
<rect width="1280" height="720" fill="#0C3A63"/>
<rect width="1280" height="720" fill="url(#bp-grid)"/>
<rect width="1280" height="720" fill="url(#bp-grid5)"/>
<text x="80" y="108" font-family="Consolas, monospace" font-size="24" letter-spacing="3" fill="#CFE8FF">DWG / SECTION-04</text>
<text x="78" y="198" font-family="Helvetica Neue, Arial, sans-serif" font-size="76" font-weight="700" fill="#EAF4FF">Lorem ipsum</text>
<text x="80" y="248" font-family="Consolas, monospace" font-size="21" fill="#9CC9EC">DARK LINEWORK · ISOMETRIC · ANNOTATED</text>
<g stroke="#BAE0FF" stroke-width="2" fill="none">
<path d="M520 470L720 560L920 470L720 380Z"/>
<path d="M520 470L520 320L720 230L720 380M920 470L920 320L720 230M720 380L720 560M520 320L720 410L920 320M720 410L720 560"/>
</g>
<g stroke="#7FC4FF" stroke-width="1.5">
<path d="M520 620H920"/>
<path d="M520 610V630M920 610V630"/>
<path d="M520 620l16 -6v12zM920 620l-16 -6v12z" fill="#7FC4FF"/>
</g>
<text x="700" y="612" font-family="Consolas, monospace" font-size="20" fill="#CFE8FF">400.0</text>
<circle cx="720" cy="230" r="6" fill="#FBBF24"/>
<path d="M720 230L1010 156" stroke="#FBBF24" stroke-width="1.5"/>
<text x="1020" y="162" font-family="Consolas, monospace" font-size="18" fill="#FBBF24">NODE A-01</text>
<path d="M980 320H1120" stroke="#7FC4FF" stroke-width="1.5"/>
<text x="1000" y="308" font-family="Consolas, monospace" font-size="17" fill="#CFE8FF">R = 120</text>
<g stroke="#7FC4FF" stroke-width="1.5" fill="none">
<rect x="960" y="600" width="240" height="80"/>
<path d="M960 640H1200M1080 600V680"/>
</g>
<text x="972" y="628" font-family="Consolas, monospace" font-size="15" fill="#CFE8FF">SCALE 1:50</text>
<text x="1092" y="628" font-family="Consolas, monospace" font-size="15" fill="#CFE8FF">REV 04</text>
<text x="972" y="666" font-family="Consolas, monospace" font-size="15" fill="#CFE8FF">BLUEPRINT</text>
<text x="1092" y="666" font-family="Consolas, monospace" font-size="15" fill="#CFE8FF">SHT 1/3</text>
</svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

@@ -0,0 +1,31 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1280 720">
<defs>
<pattern id="bru-half" width="14" height="14" patternUnits="userSpaceOnUse">
<circle cx="4" cy="4" r="2.4" fill="#111"/><circle cx="11" cy="11" r="2.4" fill="#111"/>
</pattern>
</defs>
<rect width="1280" height="720" fill="#EDEAE0"/>
<rect x="40" y="40" width="1200" height="640" fill="none" stroke="#111" stroke-width="7"/>
<rect x="40" y="40" width="1200" height="118" fill="#111"/>
<text x="66" y="124" font-family="Arial Black, Arial, sans-serif" font-size="62" font-weight="900" fill="#EDEAE0">LOREM IPSUM</text>
<text x="1214" y="112" text-anchor="end" font-family="Courier New, monospace" font-size="20" fill="#EDEAE0">VOL.04</text>
<text x="1214" y="140" text-anchor="end" font-family="Courier New, monospace" font-size="20" fill="#EDEAE0">RAW/FLAT</text>
<path d="M40 300H1240M40 470H1240M470 158V680M820 158V680" stroke="#111" stroke-width="5"/>
<text x="66" y="208" font-family="Courier New, monospace" font-size="19" fill="#111">NEWSPAPER DENSITY · HEAVY RULES · NO SOFT CHROME</text>
<text x="66" y="252" font-family="Georgia, serif" font-size="21" fill="#111">Lorem ipsum dolor sit amet, consectetur</text>
<text x="66" y="282" font-family="Georgia, serif" font-size="21" fill="#111">adipiscing elit — a content page, not a poster.</text>
<rect x="490" y="180" width="310" height="270" fill="url(#bru-half)"/>
<rect x="490" y="180" width="310" height="270" fill="none" stroke="#111" stroke-width="5"/>
<rect x="510" y="330" width="270" height="100" fill="#EDEAE0"/>
<text x="524" y="410" font-family="Arial Black, Arial, sans-serif" font-size="82" font-weight="900" fill="#111">72</text>
<text x="662" y="410" font-family="Arial Black, Arial, sans-serif" font-size="42" fill="#DC2626">%</text>
<rect x="840" y="180" width="360" height="270" fill="#DC2626"/>
<text x="864" y="268" font-family="Arial Black, Arial, sans-serif" font-size="56" font-weight="900" fill="#fff">ONE</text>
<text x="864" y="328" font-family="Arial Black, Arial, sans-serif" font-size="56" font-weight="900" fill="#fff">LOUD</text>
<text x="864" y="388" font-family="Arial Black, Arial, sans-serif" font-size="56" font-weight="900" fill="#fff">SPOT</text>
<text x="66" y="514" font-family="Courier New, monospace" font-size="19" fill="#111">INDEX ------------------- VALUE -- TREND</text>
<text x="66" y="550" font-family="Courier New, monospace" font-size="19" fill="#111">A LOREM 124 UP</text>
<text x="66" y="586" font-family="Courier New, monospace" font-size="19" fill="#111">B IPSUM 092 FLAT</text>
<text x="66" y="622" font-family="Courier New, monospace" font-size="19" fill="#111">C DOLOR 051 DOWN</text>
<text x="66" y="658" font-family="Courier New, monospace" font-size="19" fill="#111">D AMET 148 UP</text>
</svg>

After

Width:  |  Height:  |  Size: 2.9 KiB

@@ -0,0 +1,23 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1280 720">
<defs>
<filter id="ck-rough"><feTurbulence type="fractalNoise" baseFrequency="0.04 0.09" numOctaves="2" result="n"/><feDisplacementMap in="SourceGraphic" in2="n" scale="3"/></filter>
</defs>
<rect width="1280" height="720" fill="#23423C"/>
<rect width="1280" height="720" fill="#1C3833" opacity="0.5"/>
<text x="96" y="172" font-family="'Segoe Print',Arial,sans-serif" font-size="78" font-weight="700" fill="#F4F7EF" filter="url(#ck-rough)">Lorem ipsum</text>
<path d="M96 198q220 -10 420 2" stroke="#F4F7EF" stroke-width="4" fill="none" opacity="0.8" filter="url(#ck-rough)"/>
<text x="98" y="252" font-family="Arial,sans-serif" font-size="26" fill="#BEE3D0">Dark slate · chalk strokes · pastel emphasis</text>
<g filter="url(#ck-rough)">
<circle cx="300" cy="480" r="90" fill="none" stroke="#FDE68A" stroke-width="4"/>
<text x="300" y="494" text-anchor="middle" font-family="'Segoe Print',sans-serif" font-size="40" fill="#FDE68A">72%</text>
<path d="M410 480h150" stroke="#F4F7EF" stroke-width="4" fill="none"/>
<path d="M560 480l-24 -10v20z" fill="#F4F7EF"/>
<rect x="580" y="420" width="190" height="120" rx="6" fill="none" stroke="#93C5FD" stroke-width="4"/>
<text x="602" y="490" font-family="'Segoe Print',sans-serif" font-size="26" fill="#93C5FD">Ipsum</text>
</g>
<g filter="url(#ck-rough)" stroke="#F4F7EF" stroke-width="3" fill="none">
<path d="M900 560h280M900 560v-200"/>
</g>
<g fill="#F9A8D4" opacity="0.85"><rect x="930" y="482" width="40" height="76"/><rect x="990" y="442" width="40" height="116"/><rect x="1050" y="402" width="40" height="156"/><rect x="1110" y="462" width="40" height="96"/></g>
<g fill="#F4F7EF" opacity="0.14"><circle cx="200" cy="640" r="3"/><circle cx="240" cy="650" r="2"/><circle cx="1000" cy="605" r="2"/><circle cx="700" cy="300" r="2"/></g>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

@@ -0,0 +1,31 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1280 720">
<defs>
<filter id="neon" x="-60%" y="-60%" width="220%" height="220%">
<feGaussianBlur stdDeviation="6" result="b"/><feMerge><feMergeNode in="b"/><feMergeNode in="SourceGraphic"/></feMerge>
</filter>
<linearGradient id="dt-fill" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#22D3EE" stop-opacity="0.4"/><stop offset="1" stop-color="#22D3EE" stop-opacity="0"/>
</linearGradient>
</defs>
<rect width="1280" height="720" fill="#070B16"/>
<g stroke="#16324D" stroke-width="1" opacity="0.6">
<path d="M0 180H1280M0 360H1280M0 540H1280M160 0V720M400 0V720M640 0V720M880 0V720M1120 0V720"/>
</g>
<text x="96" y="118" font-family="Consolas, monospace" font-size="24" letter-spacing="4" fill="#22D3EE">// SYSTEM.OVERVIEW_04</text>
<text x="94" y="248" font-family="Helvetica Neue, Arial, sans-serif" font-size="94" font-weight="700" fill="#F1F5F9">Lorem ipsum</text>
<text x="98" y="306" font-family="Arial, sans-serif" font-size="27" fill="#7DD3FC">Dark canvas · glowing accents · precise geometry</text>
<g transform="translate(96 360)">
<path d="M0 220L120 170L240 190L360 110L480 140L600 60L720 90L840 20" fill="none" stroke="#22D3EE" stroke-width="4" filter="url(#neon)" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M0 220L120 170L240 190L360 110L480 140L600 60L720 90L840 20L840 240L0 240Z" fill="url(#dt-fill)"/>
<circle cx="600" cy="60" r="8" fill="#A3E635" filter="url(#neon)"/>
<circle cx="840" cy="20" r="8" fill="#A3E635" filter="url(#neon)"/>
</g>
<g transform="translate(980 360)">
<rect x="0" y="0" width="200" height="240" rx="10" fill="#0C1526" stroke="#1E3A5F"/>
<text x="24" y="92" font-family="Helvetica Neue, Arial, sans-serif" font-size="74" font-weight="700" fill="#22D3EE" filter="url(#neon)">72%</text>
<text x="24" y="130" font-family="Consolas, monospace" font-size="18" fill="#64748B">UPTIME</text>
<rect x="24" y="162" width="152" height="10" rx="5" fill="#12233B"/>
<rect x="24" y="162" width="110" height="10" rx="5" fill="#A3E635"/>
<text x="24" y="214" font-family="Consolas, monospace" font-size="16" fill="#7DD3FC">+18% QoQ</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

@@ -0,0 +1,41 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1280 720">
<rect width="1280" height="720" fill="#FFFFFF"/>
<text x="72" y="86" font-family="Georgia, serif" font-size="20" letter-spacing="3" fill="#C2410C">DATA DESK · ANALYSIS</text>
<text x="70" y="150" font-family="Georgia, 'Times New Roman', serif" font-size="58" font-weight="700" fill="#111827">Lorem ipsum dolor</text>
<text x="72" y="192" font-family="Arial, sans-serif" font-size="23" fill="#6B7280">Multi-column microcharts, sidebars and source lines</text>
<path d="M72 220H1208" stroke="#111827" stroke-width="2"/>
<g transform="translate(72 250)">
<rect x="0" y="0" width="560" height="260" fill="#F8FAFC" stroke="#E2E8F0"/>
<text x="16" y="30" font-family="Arial, sans-serif" font-size="18" font-weight="700" fill="#111827">Trend · 20192026</text>
<g stroke="#E2E8F0"><path d="M16 210H544M16 160H544M16 110H544M16 60H544"/></g>
<path d="M16 190L100 170L184 176L268 120L352 130L436 70L520 40" fill="none" stroke="#2563EB" stroke-width="3"/>
<path d="M16 190L100 170L184 176L268 120L352 130L436 70L520 40L520 210L16 210Z" fill="#2563EB" opacity="0.08"/>
<circle cx="520" cy="40" r="5" fill="#C2410C"/>
</g>
<g transform="translate(660 250)">
<rect x="0" y="0" width="250" height="122" fill="#F8FAFC" stroke="#E2E8F0"/>
<text x="14" y="26" font-family="Arial, sans-serif" font-size="15" font-weight="700" fill="#111827">Share</text>
<g fill="#2563EB"><rect x="16" y="96" width="26" height="14"/><rect x="56" y="78" width="26" height="32"/><rect x="96" y="58" width="26" height="52"/><rect x="136" y="70" width="26" height="40"/><rect x="176" y="44" width="26" height="66"/><rect x="216" y="86" width="26" height="24"/></g>
</g>
<g transform="translate(940 250)">
<rect x="0" y="0" width="268" height="122" fill="#F8FAFC" stroke="#E2E8F0"/>
<text x="14" y="26" font-family="Arial, sans-serif" font-size="15" font-weight="700" fill="#111827">Mix</text>
<circle cx="70" cy="74" r="34" fill="none" stroke="#E2E8F0" stroke-width="16"/>
<circle cx="70" cy="74" r="34" fill="none" stroke="#2563EB" stroke-width="16" stroke-dasharray="150 214" transform="rotate(-90 70 74)"/>
<circle cx="70" cy="74" r="34" fill="none" stroke="#C2410C" stroke-width="16" stroke-dasharray="55 214" stroke-dashoffset="-150" transform="rotate(-90 70 74)"/>
<text x="132" y="62" font-family="Arial, sans-serif" font-size="16" fill="#2563EB">68% a</text>
<text x="132" y="92" font-family="Arial, sans-serif" font-size="16" fill="#C2410C">24% b</text>
</g>
<g transform="translate(660 396)">
<rect x="0" y="0" width="250" height="114" fill="#111827"/>
<text x="16" y="54" font-family="Georgia, serif" font-size="46" font-weight="700" fill="#fff">72%</text>
<text x="16" y="88" font-family="Arial, sans-serif" font-size="16" fill="#CBD5E1">Consectetur elit</text>
</g>
<g transform="translate(940 396)">
<rect x="0" y="0" width="268" height="114" fill="#F8FAFC" stroke="#E2E8F0"/>
<path d="M14 96L60 80L106 88L152 60L198 70L254 40L254 104L14 104Z" fill="#2563EB" opacity="0.15"/>
<path d="M14 96L60 80L106 88L152 60L198 70L254 40" fill="none" stroke="#2563EB" stroke-width="2.5"/>
</g>
<path d="M72 548H1208" stroke="#E2E8F0" stroke-width="1"/>
<text x="72" y="576" font-family="Arial, sans-serif" font-size="16" fill="#9CA3AF">Source: lorem ipsum operating data (20192026) · Chart: PM Data Desk</text>
</svg>

After

Width:  |  Height:  |  Size: 3.4 KiB

@@ -0,0 +1,22 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1280 720">
<rect width="1280" height="720" fill="#FBFAF7"/>
<text x="80" y="76" font-family="Georgia, serif" font-size="24" letter-spacing="8" fill="#111827">THE REVIEW</text>
<text x="1200" y="76" text-anchor="end" font-family="Georgia, serif" font-size="22" fill="#8A8377">FEATURE · No.04</text>
<path d="M80 96H1200" stroke="#111827" stroke-width="3"/>
<path d="M80 103H1200" stroke="#111827" stroke-width="1"/>
<text x="80" y="216" font-family="Georgia, 'Times New Roman', serif" font-size="98" font-weight="700" fill="#111827">Lorem ipsum</text>
<text x="80" y="272" font-family="Georgia, serif" font-size="30" font-style="italic" fill="#6B7280">A serif / sans-serif hierarchy set in columns</text>
<path d="M80 308H1200" stroke="#111827" stroke-width="1"/>
<path d="M450 336V648M820 336V648" stroke="#D8D2C4" stroke-width="1.5"/>
<text x="80" y="452" font-family="Georgia, serif" font-size="118" font-weight="700" fill="#B45309">L</text>
<g fill="#CFC9BC"><rect x="196" y="356" width="222" height="11" rx="2"/><rect x="196" y="384" width="222" height="11" rx="2"/><rect x="196" y="412" width="205" height="11" rx="2"/><rect x="80" y="470" width="338" height="11" rx="2"/><rect x="80" y="498" width="338" height="11" rx="2"/><rect x="80" y="526" width="300" height="11" rx="2"/><rect x="80" y="554" width="338" height="11" rx="2"/><rect x="80" y="582" width="250" height="11" rx="2"/></g>
<text x="490" y="404" font-family="Georgia, serif" font-size="33" font-style="italic" fill="#111827">“Lorem ipsum</text>
<text x="490" y="446" font-family="Georgia, serif" font-size="33" font-style="italic" fill="#111827">dolor sit amet,</text>
<text x="490" y="488" font-family="Georgia, serif" font-size="33" font-style="italic" fill="#111827">consectetur.”</text>
<g fill="#CFC9BC"><rect x="490" y="536" width="300" height="11" rx="2"/><rect x="490" y="564" width="300" height="11" rx="2"/><rect x="490" y="592" width="230" height="11" rx="2"/></g>
<g fill="#CFC9BC"><rect x="860" y="356" width="340" height="11" rx="2"/><rect x="860" y="384" width="340" height="11" rx="2"/><rect x="860" y="412" width="300" height="11" rx="2"/><rect x="860" y="440" width="340" height="11" rx="2"/><rect x="860" y="468" width="270" height="11" rx="2"/></g>
<rect x="860" y="506" width="340" height="110" fill="#EDE9DF"/>
<text x="882" y="558" font-family="Georgia, serif" font-size="46" font-weight="700" fill="#111827">72%</text>
<text x="882" y="594" font-family="Georgia, serif" font-size="20" fill="#6B7280">dolor sit amet</text>
<text x="80" y="686" font-family="Georgia, serif" font-size="20" font-style="italic" fill="#8A8377">By Strategy Desk · Illustration by PM</text>
</svg>

After

Width:  |  Height:  |  Size: 2.7 KiB

@@ -0,0 +1,31 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1280 720">
<defs>
<linearGradient id="g-bg" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#4F46E5"/><stop offset="0.5" stop-color="#7C3AED"/><stop offset="1" stop-color="#EC4899"/>
</linearGradient>
<filter id="g-blur" x="-60%" y="-60%" width="220%" height="220%"><feGaussianBlur stdDeviation="44"/></filter>
<linearGradient id="g-panel" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#FFFFFF" stop-opacity="0.38"/><stop offset="1" stop-color="#FFFFFF" stop-opacity="0.12"/>
</linearGradient>
</defs>
<rect width="1280" height="720" fill="url(#g-bg)"/>
<circle cx="240" cy="180" r="150" fill="#22D3EE" filter="url(#g-blur)" opacity="0.85"/>
<circle cx="1080" cy="560" r="185" fill="#F472B6" filter="url(#g-blur)" opacity="0.9"/>
<circle cx="980" cy="150" r="95" fill="#FDE68A" filter="url(#g-blur)" opacity="0.75"/>
<rect x="150" y="150" width="640" height="420" rx="36" fill="url(#g-panel)" stroke="#FFFFFF" stroke-opacity="0.55" stroke-width="1.5"/>
<rect x="200" y="205" width="150" height="48" rx="24" fill="#FFFFFF" fill-opacity="0.25" stroke="#fff" stroke-opacity="0.5"/>
<text x="224" y="238" font-family="Arial, sans-serif" font-size="24" fill="#fff">GLASS 04</text>
<text x="200" y="362" font-family="Helvetica Neue, Arial, sans-serif" font-size="80" font-weight="700" fill="#FFFFFF">Lorem ipsum</text>
<text x="202" y="414" font-family="Arial, sans-serif" font-size="26" fill="#EDE9FE">Translucent panels · gradient light · depth</text>
<rect x="200" y="452" width="270" height="92" rx="20" fill="#FFFFFF" fill-opacity="0.2" stroke="#fff" stroke-opacity="0.4"/>
<rect x="490" y="452" width="270" height="92" rx="20" fill="#FFFFFF" fill-opacity="0.2" stroke="#fff" stroke-opacity="0.4"/>
<text x="228" y="512" font-family="Arial, sans-serif" font-size="42" font-weight="700" fill="#fff">72%</text>
<text x="360" y="512" font-family="Arial, sans-serif" font-size="20" fill="#EDE9FE">dolor</text>
<text x="518" y="512" font-family="Arial, sans-serif" font-size="42" font-weight="700" fill="#fff">1.8x</text>
<text x="652" y="512" font-family="Arial, sans-serif" font-size="20" fill="#EDE9FE">amet</text>
<rect x="830" y="205" width="260" height="310" rx="26" fill="#FFFFFF" fill-opacity="0.16" stroke="#fff" stroke-opacity="0.4"/>
<circle cx="960" cy="308" r="54" fill="none" stroke="#fff" stroke-opacity="0.55" stroke-width="13"/>
<path d="M960 254a54 54 0 0 1 47 81" fill="none" stroke="#FDE68A" stroke-width="13" stroke-linecap="round"/>
<rect x="870" y="400" width="180" height="16" rx="8" fill="#fff" fill-opacity="0.55"/>
<rect x="870" y="432" width="130" height="14" rx="7" fill="#fff" fill-opacity="0.4"/>
</svg>

After

Width:  |  Height:  |  Size: 2.7 KiB

@@ -0,0 +1,16 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1280 720">
<rect width="1280" height="720" fill="#FAFAF7"/>
<text x="100" y="150" font-family="'Segoe Script','Bradley Hand',cursive" font-size="30" fill="#111827">04 —</text>
<text x="100" y="252" font-family="Georgia, serif" font-size="88" font-weight="700" fill="#111827">Lorem ipsum</text>
<path d="M100 278q200 -14 380 -2" fill="none" stroke="#111827" stroke-width="4" stroke-linecap="round"/>
<text x="100" y="332" font-family="Arial, sans-serif" font-size="26" fill="#4B5563">Light ground · handwritten ink · sparse emphasis</text>
<text x="100" y="432" font-family="Arial, sans-serif" font-size="30" fill="#1F2937">— Lorem ipsum dolor</text>
<text x="100" y="492" font-family="Arial, sans-serif" font-size="30" fill="#1F2937">— Sit amet</text>
<path d="M182 464q120 -22 210 6q34 42 -10 62q-150 26 -214 -8q-32 -34 14 -60Z" fill="none" stroke="#DC2626" stroke-width="4"/>
<text x="100" y="552" font-family="Arial, sans-serif" font-size="30" fill="#1F2937">— Consectetur adipiscing</text>
<path d="M820 170q120 40 60 160t80 210" fill="none" stroke="#111827" stroke-width="11" stroke-linecap="round" opacity="0.85"/>
<path d="M900 250q80 -10 140 40" fill="none" stroke="#111827" stroke-width="5" stroke-linecap="round" opacity="0.55"/>
<path d="M760 548q120 -30 250 -72" fill="none" stroke="#DC2626" stroke-width="4" stroke-linecap="round"/>
<path d="M1010 476l-30 4l16 24z" fill="#DC2626"/>
<text x="1030" y="600" text-anchor="end" font-family="Georgia, serif" font-size="64" font-weight="700" fill="#111827">72%</text>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

@@ -0,0 +1,18 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1280 720">
<defs>
<radialGradient id="iw-paper" cx="50%" cy="40%" r="75%"><stop offset="0" stop-color="#FBF8EF"/><stop offset="1" stop-color="#EEE6D2"/></radialGradient>
</defs>
<rect width="1280" height="720" fill="url(#iw-paper)"/>
<path d="M620 470C700 300 800 250 900 300C860 340 900 380 980 360C920 420 1000 460 1080 430C1000 500 900 520 800 500C720 486 660 500 620 470Z" fill="#1F2937" opacity="0.16"/>
<path d="M700 480C780 360 860 330 940 370C900 400 940 430 1010 418C960 470 1030 496 1110 470C1030 520 940 528 860 512C790 498 740 508 700 480Z" fill="#1F2937" opacity="0.3"/>
<path d="M640 560C760 500 900 540 1160 470" fill="none" stroke="#1F2937" stroke-width="14" stroke-linecap="round" opacity="0.85"/>
<path d="M660 600C820 560 980 588 1140 548" fill="none" stroke="#1F2937" stroke-width="5" stroke-linecap="round" opacity="0.4"/>
<text x="120" y="176" font-family="Georgia, serif" font-size="28" letter-spacing="4" fill="#7F1D1D">SCROLL · IV</text>
<text x="118" y="288" font-family="'Songti SC','STSong',Georgia,serif" font-size="90" font-weight="700" fill="#1F2937">Lorem ipsum</text>
<text x="120" y="346" font-family="Arial,sans-serif" font-size="24" fill="#4B5563">Rice-paper whitespace · brushwork · seal · calm</text>
<text x="120" y="446" font-family="Georgia,serif" font-size="30" fill="#374151">I. Lorem ipsum dolor</text>
<text x="120" y="500" font-family="Georgia,serif" font-size="30" fill="#374151">II. Sit amet consectetur</text>
<text x="120" y="554" font-family="Georgia,serif" font-size="30" fill="#374151">III. Adipiscing elit sed</text>
<rect x="470" y="474" width="72" height="72" rx="6" fill="#B91C1C"/>
<g stroke="#FBEAEA" stroke-width="6" stroke-linecap="round"><path d="M490 496h32M490 512h32M506 496v34"/></g>
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

@@ -0,0 +1,25 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1280 720">
<rect width="1280" height="720" fill="#FFF6EC"/>
<g stroke="#111827" stroke-width="6">
<circle cx="150" cy="140" r="52" fill="#FDE047"/>
<rect x="1050" y="90" width="110" height="110" transform="rotate(12 1105 145)" fill="#F472B6"/>
<path d="M1080 470l70 -96l70 96z" fill="#34D399"/>
<path d="M90 560h58M90 590h58M90 620h58" fill="none" stroke-linecap="round"/>
<path d="M980 610q26 -34 52 0t52 0" fill="none"/>
<circle cx="1180" cy="360" r="30" fill="#60A5FA"/>
<path d="M60 330q26 -30 52 0t52 0t52 0" fill="none"/>
</g>
<g fill="#F97316"><circle cx="620" cy="90" r="9"/><circle cx="660" cy="90" r="9"/><circle cx="700" cy="90" r="9"/><circle cx="620" cy="130" r="9"/><circle cx="660" cy="130" r="9"/><circle cx="700" cy="130" r="9"/></g>
<rect x="150" y="230" width="640" height="200" rx="18" fill="#FFFFFF" stroke="#111827" stroke-width="7"/>
<text x="186" y="326" font-family="Arial Black, Arial, sans-serif" font-size="72" font-weight="900" fill="#111827">Lorem</text>
<text x="186" y="392" font-family="Arial, sans-serif" font-size="24" fill="#111827">Color blocks · geometric scraps · bold outlines</text>
<circle cx="980" cy="300" r="130" fill="#A78BFA" stroke="#111827" stroke-width="7"/>
<text x="980" y="292" text-anchor="middle" font-family="Arial Black, Arial, sans-serif" font-size="72" font-weight="900" fill="#111827">72%</text>
<text x="980" y="338" text-anchor="middle" font-family="Arial, sans-serif" font-size="22" fill="#111827">dolor</text>
<g stroke="#111827" stroke-width="7">
<rect x="150" y="500" width="90" height="120" fill="#60A5FA"/>
<rect x="270" y="470" width="90" height="150" fill="#FDE047"/>
<rect x="390" y="510" width="90" height="110" fill="#34D399"/>
<rect x="510" y="450" width="90" height="170" fill="#F472B6"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

@@ -0,0 +1,16 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1280 720">
<defs>
<filter id="pc-sh" x="-20%" y="-45%" width="140%" height="190%"><feDropShadow dx="0" dy="-8" stdDeviation="10" flood-color="#1E3A5F" flood-opacity="0.18"/></filter>
<filter id="pc-sh2" x="-20%" y="-45%" width="140%" height="190%"><feDropShadow dx="0" dy="-11" stdDeviation="14" flood-color="#0F2A44" flood-opacity="0.2"/></filter>
</defs>
<rect width="1280" height="720" fill="#EAF3FB"/>
<circle cx="980" cy="220" r="112" fill="#FBBF6E" filter="url(#pc-sh)"/>
<path d="M0 430C220 370 420 470 660 420C900 370 1080 450 1280 400V720H0Z" fill="#BFE0F5" filter="url(#pc-sh)"/>
<path d="M0 520C260 460 460 560 720 500C980 440 1120 520 1280 480V720H0Z" fill="#7FBEE8" filter="url(#pc-sh2)"/>
<path d="M0 610C280 555 520 640 800 580C1060 525 1160 600 1280 570V720H0Z" fill="#3E8FCB" filter="url(#pc-sh2)"/>
<path d="M560 470l64 -30l-8 44z" fill="#F87171" filter="url(#pc-sh)"/>
<rect x="90" y="150" width="540" height="200" rx="18" fill="#FFFFFF" filter="url(#pc-sh)"/>
<text x="128" y="238" font-family="Arial, sans-serif" font-size="62" font-weight="800" fill="#1E4E79">Lorem ipsum</text>
<text x="130" y="292" font-family="Arial, sans-serif" font-size="24" fill="#5B7A94">Layered cut paper · soft shadows · handmade</text>
<rect x="130" y="316" width="120" height="12" rx="6" fill="#FBBF6E"/>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

@@ -0,0 +1,27 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1280 720">
<defs>
<linearGradient id="pe-sky" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#1B2A4A"/><stop offset="0.5" stop-color="#7C4A6B"/><stop offset="0.78" stop-color="#E08A63"/><stop offset="1" stop-color="#F3B77E"/>
</linearGradient>
<radialGradient id="pe-sun" cx="72%" cy="72%" r="36%">
<stop offset="0" stop-color="#FFE7B8"/><stop offset="0.35" stop-color="#FBC26B" stop-opacity="0.8"/><stop offset="1" stop-color="#FBC26B" stop-opacity="0"/>
</radialGradient>
<linearGradient id="pe-grad" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#000" stop-opacity="0"/><stop offset="1" stop-color="#000" stop-opacity="0.78"/>
</linearGradient>
</defs>
<rect width="1280" height="720" fill="url(#pe-sky)"/>
<rect width="1280" height="720" fill="url(#pe-sun)"/>
<circle cx="922" cy="516" r="72" fill="#FFEFC8" opacity="0.95"/>
<path d="M0 470L220 420L470 500L720 430L980 500L1280 440V720H0Z" fill="#5A4A6E" opacity="0.55"/>
<path d="M0 540L260 500L520 560L780 505L1040 570L1280 520V720H0Z" fill="#3E3352" opacity="0.72"/>
<path d="M0 610L300 575L600 630L900 580L1280 625V720H0Z" fill="#241C33"/>
<path d="M300 250q14 -12 28 0q14 -12 28 0" fill="none" stroke="#1B2A4A" stroke-width="3"/>
<path d="M362 286q10 -9 20 0q10 -9 20 0" fill="none" stroke="#1B2A4A" stroke-width="2.5"/>
<rect x="0" y="440" width="1280" height="280" fill="url(#pe-grad)"/>
<rect x="80" y="560" width="8" height="96" fill="#F3B77E"/>
<text x="112" y="548" font-family="Helvetica Neue, Arial, sans-serif" font-size="22" letter-spacing="5" fill="#F3B77E">FIELD NOTES · 04</text>
<text x="108" y="622" font-family="Helvetica Neue, Arial, sans-serif" font-size="76" font-weight="700" fill="#fff">Lorem ipsum</text>
<text x="112" y="666" font-family="Helvetica Neue, Arial, sans-serif" font-size="24" fill="#E7DCD2">Full-bleed photography with light text and captions</text>
<rect x="20" y="20" width="1240" height="680" fill="none" stroke="#fff" stroke-opacity="0.6" stroke-width="2"/>
</svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

@@ -0,0 +1,28 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1280 720" shape-rendering="crispEdges">
<rect width="1280" height="720" fill="#1A1C2C"/>
<rect x="0" y="600" width="1280" height="120" fill="#2A2F4A"/>
<rect x="0" y="600" width="1280" height="20" fill="#3B4670"/>
<text x="80" y="182" font-family="'Courier New',monospace" font-size="92" font-weight="700" fill="#FFCD75" letter-spacing="2">LOREM IPSUM</text>
<text x="84" y="238" font-family="'Courier New',monospace" font-size="27" fill="#41A6F6">STRICT PIXEL GRID · LIMITED PALETTE</text>
<g>
<rect x="160" y="340" width="160" height="40" fill="#EF7D57"/>
<rect x="200" y="300" width="80" height="40" fill="#FFCD75"/>
<rect x="200" y="380" width="120" height="120" fill="#41A6F6"/>
<rect x="160" y="420" width="40" height="80" fill="#41A6F6"/>
<rect x="320" y="420" width="40" height="80" fill="#41A6F6"/>
<rect x="200" y="500" width="40" height="60" fill="#38213A"/>
<rect x="280" y="500" width="40" height="60" fill="#38213A"/>
<rect x="220" y="320" width="16" height="16" fill="#1A1C2C"/>
<rect x="252" y="320" width="16" height="16" fill="#1A1C2C"/>
</g>
<g>
<rect x="640" y="480" width="60" height="80" fill="#38B764"/>
<rect x="720" y="420" width="60" height="140" fill="#38B764"/>
<rect x="800" y="360" width="60" height="200" fill="#FFCD75"/>
<rect x="880" y="440" width="60" height="120" fill="#38B764"/>
<rect x="960" y="300" width="60" height="260" fill="#EF7D57"/>
</g>
<path d="M620 560H1060" stroke="#94B0C2" stroke-width="8"/>
<g fill="#FFCD75"><rect x="1120" y="120" width="24" height="24"/><rect x="1096" y="144" width="72" height="24"/><rect x="1120" y="168" width="24" height="24"/></g>
<text x="1250" y="470" text-anchor="end" font-family="'Courier New',monospace" font-size="84" font-weight="700" fill="#FFCD75">72%</text>
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

@@ -0,0 +1,20 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1280 720">
<rect width="1280" height="720" fill="#FFFBEF"/>
<rect x="86" y="118" width="430" height="48" rx="8" fill="#FDE68A" opacity="0.8" transform="rotate(-1 300 142)"/>
<text x="96" y="172" font-family="'Comic Sans MS','Segoe Print',cursive" font-size="70" font-weight="700" fill="#1F2937">Lorem ipsum</text>
<text x="98" y="220" font-family="'Comic Sans MS',cursive" font-size="26" fill="#6B7280">Warm paper · doodle lines · soft color blocks</text>
<path d="M96 280q220 -8 300 0q6 90 0 150q-160 8 -300 2q-6 -80 0 -154Z" fill="#FEE2E2" stroke="#1F2937" stroke-width="3" stroke-linejoin="round"/>
<text x="128" y="342" font-family="'Comic Sans MS',cursive" font-size="30" font-weight="700" fill="#1F2937">Lorem</text>
<path d="M128 372h230M128 400h180" stroke="#9CA3AF" stroke-width="3" stroke-linecap="round"/>
<path d="M430 360q90 -30 150 10" fill="none" stroke="#2563EB" stroke-width="4" stroke-linecap="round"/>
<path d="M580 370l-22 -6l14 20z" fill="#2563EB"/>
<path d="M600 300q210 -8 290 4q6 80 0 140q-150 8 -290 0q-6 -74 0 -144Z" fill="#DBEAFE" stroke="#1F2937" stroke-width="3" stroke-linejoin="round"/>
<text x="632" y="358" font-family="'Comic Sans MS',cursive" font-size="30" font-weight="700" fill="#1F2937">Ipsum</text>
<circle cx="652" cy="394" r="8" fill="#34D399" stroke="#1F2937" stroke-width="2"/>
<circle cx="692" cy="394" r="8" fill="#FBBF24" stroke="#1F2937" stroke-width="2"/>
<path d="M1000 200l14 40l42 2l-33 26l12 41l-35 -24l-35 24l12 -41l-33 -26l42 -2z" fill="#FDE047" stroke="#1F2937" stroke-width="3" stroke-linejoin="round"/>
<g stroke="#1F2937" stroke-width="3" fill="none"><path d="M120 560v120h520" stroke-linecap="round"/></g>
<path d="M150 640q90 -60 180 -20t180 -70" fill="none" stroke="#EC4899" stroke-width="4" stroke-linecap="round"/>
<circle cx="510" cy="530" r="6" fill="#EC4899"/>
<text x="1000" y="300" font-family="'Comic Sans MS',cursive" font-size="40" font-weight="700" fill="#1F2937">72%!</text>
</svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

@@ -0,0 +1,32 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1280 720">
<defs>
<filter id="sr-sh" x="-30%" y="-30%" width="160%" height="160%">
<feDropShadow dx="0" dy="20" stdDeviation="28" flood-color="#1D4ED8" flood-opacity="0.16"/>
</filter>
<linearGradient id="sr-ic" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#60A5FA"/><stop offset="1" stop-color="#2563EB"/>
</linearGradient>
</defs>
<rect width="1280" height="720" fill="#EAF1FB"/>
<rect x="90" y="96" width="700" height="528" rx="48" fill="#FFFFFF" filter="url(#sr-sh)"/>
<rect x="140" y="150" width="176" height="54" rx="27" fill="#DBEAFE"/>
<text x="166" y="186" font-family="Arial, sans-serif" font-size="25" font-weight="600" fill="#2563EB">Overview 04</text>
<circle cx="184" cy="300" r="48" fill="url(#sr-ic)"/>
<path d="M162 300l16 16l28 -34" stroke="#fff" stroke-width="8" fill="none" stroke-linecap="round" stroke-linejoin="round"/>
<text x="256" y="292" font-family="Arial, sans-serif" font-size="60" font-weight="700" fill="#0F172A">Lorem ipsum</text>
<text x="258" y="344" font-family="Arial, sans-serif" font-size="26" fill="#64748B">Rounded cards · soft shadows · friendly</text>
<rect x="140" y="424" width="290" height="152" rx="30" fill="#F1F6FE"/>
<rect x="460" y="424" width="290" height="152" rx="30" fill="#F1F6FE"/>
<text x="172" y="494" font-family="Arial, sans-serif" font-size="54" font-weight="700" fill="#2563EB">72%</text>
<text x="172" y="536" font-family="Arial, sans-serif" font-size="22" fill="#64748B">dolor sit</text>
<text x="492" y="494" font-family="Arial, sans-serif" font-size="54" font-weight="700" fill="#2563EB">1.8x</text>
<text x="492" y="536" font-family="Arial, sans-serif" font-size="22" fill="#64748B">amet elit</text>
<rect x="840" y="150" width="350" height="182" rx="40" fill="#FFFFFF" filter="url(#sr-sh)"/>
<rect x="840" y="382" width="350" height="242" rx="40" fill="#2563EB" filter="url(#sr-sh)"/>
<circle cx="905" cy="241" r="30" fill="#BFDBFE"/>
<rect x="955" y="214" width="185" height="20" rx="10" fill="#E2E8F0"/>
<rect x="955" y="250" width="120" height="16" rx="8" fill="#EDF2F8"/>
<text x="885" y="474" font-family="Arial, sans-serif" font-size="32" font-weight="700" fill="#fff">Consectetur</text>
<rect x="885" y="506" width="200" height="64" rx="32" fill="#fff"/>
<text x="917" y="548" font-family="Arial, sans-serif" font-size="27" font-weight="600" fill="#2563EB">Get started →</text>
</svg>

After

Width:  |  Height:  |  Size: 2.4 KiB

@@ -0,0 +1,15 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1280 720">
<rect width="1280" height="720" fill="#FFFFFF"/>
<g stroke="#ECEFF3" stroke-width="1.5">
<path d="M96 0V720M448 0V720M800 0V720M1152 0V720"/>
<path d="M0 96H1280M0 624H1280"/>
</g>
<path d="M96 96H1184" stroke="#0F172A" stroke-width="3"/>
<text x="96" y="78" font-family="Helvetica Neue, Arial, sans-serif" font-size="27" letter-spacing="7" fill="#0F172A">SECTION 04</text>
<text x="1184" y="78" text-anchor="end" font-family="Helvetica Neue, Arial, sans-serif" font-size="27" letter-spacing="3" fill="#9AA3AF">SWISS · GRID</text>
<text x="94" y="322" font-family="Helvetica Neue, Arial, sans-serif" font-size="128" font-weight="700" fill="#0F172A" letter-spacing="-3">Lorem ipsum</text>
<text x="98" y="396" font-family="Helvetica Neue, Arial, sans-serif" font-size="38" font-weight="300" fill="#475569">Grid-locked hierarchy, generous whitespace</text>
<rect x="96" y="470" width="130" height="130" fill="#E4002B"/>
<text x="1184" y="556" text-anchor="end" font-family="Helvetica Neue, Arial, sans-serif" font-size="200" font-weight="700" fill="#0F172A" letter-spacing="-4">72</text>
<text x="1184" y="602" text-anchor="end" font-family="Helvetica Neue, Arial, sans-serif" font-size="30" fill="#64748B">aggregate index</text>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

@@ -0,0 +1,18 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1280 720">
<defs>
<pattern id="vp-half" width="12" height="12" patternUnits="userSpaceOnUse"><circle cx="6" cy="6" r="2.4" fill="#7C2D12" opacity="0.45"/></pattern>
</defs>
<rect width="1280" height="720" fill="#EFD9A8"/>
<circle cx="890" cy="300" r="180" fill="#E07A3E"/>
<rect x="710" y="300" width="360" height="180" fill="url(#vp-half)"/>
<path d="M0 520L240 380L440 520Z" fill="#2E6E5E"/>
<path d="M320 540L620 340L900 540Z" fill="#245A4C"/>
<path d="M760 540L1020 400L1280 540V560H760Z" fill="#2E6E5E"/>
<rect x="0" y="540" width="1280" height="180" fill="#C6612E"/>
<rect x="0" y="540" width="1280" height="180" fill="url(#vp-half)"/>
<text x="90" y="180" font-family="Georgia, serif" font-size="26" letter-spacing="8" fill="#7C2D12">EST. 04 · SERIES</text>
<text x="86" y="296" font-family="Georgia, 'Times New Roman', serif" font-size="120" font-weight="900" fill="#7C2D12">Lorem</text>
<text x="90" y="352" font-family="Georgia, serif" font-size="27" fill="#8A4B24">Mid-century flat blocks · halftone · warm geometry</text>
<text x="90" y="628" font-family="Georgia, serif" font-size="40" font-weight="700" fill="#F3E4C4">72% · dolor sit amet</text>
<rect x="28" y="28" width="1224" height="664" fill="none" stroke="#7C2D12" stroke-width="4"/>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

@@ -0,0 +1,26 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1280 720">
<defs>
<pattern id="z-half" width="10" height="10" patternUnits="userSpaceOnUse"><circle cx="5" cy="5" r="2.6" fill="#EC4899"/></pattern>
<filter id="z-grain" x="0" y="0" width="100%" height="100%">
<feTurbulence type="fractalNoise" baseFrequency="0.85" numOctaves="2" stitchTiles="stitch"/>
<feColorMatrix type="saturate" values="0"/>
</filter>
</defs>
<rect width="1280" height="720" fill="#F6F1E7"/>
<text x="96" y="248" font-family="Arial Black, Arial, sans-serif" font-size="132" font-weight="900" fill="#2563EB">LOREM</text>
<text x="86" y="240" font-family="Arial Black, Arial, sans-serif" font-size="132" font-weight="900" fill="#EC4899" opacity="0.85">LOREM</text>
<circle cx="1010" cy="230" r="150" fill="url(#z-half)"/>
<circle cx="1010" cy="230" r="150" fill="none" stroke="#2563EB" stroke-width="4"/>
<circle cx="990" cy="212" r="150" fill="none" stroke="#EC4899" stroke-width="4" opacity="0.7"/>
<rect x="96" y="322" width="560" height="70" fill="#EC4899"/>
<rect x="88" y="314" width="560" height="70" fill="#2563EB" opacity="0.55"/>
<text x="120" y="372" font-family="Courier New, monospace" font-size="30" font-weight="700" fill="#F6F1E7">RISO · 2-COLOR · No.04</text>
<text x="96" y="466" font-family="Courier New, monospace" font-size="26" fill="#2563EB">Riso offsets · halftone dots</text>
<text x="96" y="508" font-family="Courier New, monospace" font-size="26" fill="#EC4899">limited colors · print grain</text>
<g stroke="#111827" stroke-width="4"><path d="M96 556h120M96 592h90"/></g>
<rect x="760" y="440" width="420" height="200" fill="url(#z-half)" opacity="0.9"/>
<rect x="752" y="432" width="420" height="200" fill="none" stroke="#2563EB" stroke-width="4"/>
<text x="792" y="562" font-family="Arial Black, Arial, sans-serif" font-size="92" font-weight="900" fill="#2563EB">72</text>
<text x="792" y="602" font-family="Courier New, monospace" font-size="22" fill="#111827">PERCENT</text>
<rect width="1280" height="720" filter="url(#z-grain)" opacity="0.08"/>
</svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

@@ -1,6 +1,6 @@
# Confirm UI — Eight Confirmations Page
# Confirm UI — Strategist Confirmation Stage 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) 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.
> The interactive, visual surface for SKILL.md Step 4 (the Strategist confirmation stage). 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 staged confirmation in chat.
## Authority and Scope
@@ -8,7 +8,7 @@
|---|---|
| Step 4 gate and pipeline order | `SKILL.md` |
| Confirm UI schema | This document |
| Tier 1 / Tier 2 field membership | This document |
| Stage 1 / Stage 2 / Stage 3 field membership | This document |
| Server launch / wait / shutdown behavior | This document |
| Port and lock behavior | This document |
| Chat fallback equivalence | This document |
@@ -16,13 +16,14 @@
**Hard rule**: Keep detailed Confirm UI behavior here. `SKILL.md` may summarize the orchestration, but it should not duplicate the full JSON schema, catalog behavior, or launcher lifecycle.
**Fallback rule**: Browser failure never cancels Step 4. Re-check `result.json` once, then use the chat confirmation path with the same two-tier semantics.
**Fallback rule**: Browser failure never cancels Step 4. Re-check `result.json` once, then use the chat confirmation path with the same three-stage semantics.
## `confirm_ui/server.py`
```bash
python3 scripts/confirm_ui/server.py <project_path> --daemon --wait # launch + wait for Tier 1
python3 scripts/confirm_ui/server.py <project_path> --wait-only # Tier 2: attach to the running page, wait for the final result
python3 scripts/confirm_ui/server.py <project_path> --daemon --wait # launch + wait for Stage 1
python3 scripts/confirm_ui/server.py <project_path> --wait-only --wait-stage stage2 # Stage 2: wait for the design-system handoff
python3 scripts/confirm_ui/server.py <project_path> --wait-only # Stage 3: wait for the final result
python3 scripts/confirm_ui/server.py <project_path> --daemon
python3 scripts/confirm_ui/server.py <project_path> --daemon --port 5051
python3 scripts/confirm_ui/server.py <project_path> --no-browser
@@ -33,7 +34,7 @@ python3 scripts/confirm_ui/server.py <project_path> --shutdown # Step 4 clean
- Binds `127.0.0.1:5050` by default — or the next free port if another project already holds it (the launch log prints the actual URL) — and auto-opens the browser (suppress with `--no-browser`). `--port <other>` forces a specific port.
- **Shares port 5050 with the live preview server** (`svg_editor/server.py`). The two never run at once: confirm is Step 4, live preview is Step 6, and Step 4 always shuts this server down on exit (see `--shutdown`) so the port is free. One port = one forward rule for the whole pipeline. They still keep **separate processes and locks** (`.confirm_ui.lock` vs `.live_preview.lock`).
- `--daemon` starts the Flask process in the background; add `--wait` in the main pipeline so the parent command returns only after the page writes a fresh `result.json`. The `--wait` budget defaults to **590 s** (`--wait-timeout`), kept under the typical 600 s tool ceiling — run the launch with a long tool timeout (≈600000 ms). On timeout the parent returns non-zero but the detached server keeps running, so the caller must re-check `result.json` once before the chat fallback (a slow user may confirm just after the wait returns).
- `--wait-only` is the **second leg of the two-tier flow**: it does **not** launch a server — it attaches to the page already running from the first `--daemon --wait` and blocks until the page writes the **final** result (`stage: "final"`). It keys on the **stage alone** (no mtime gate): this run's tier-1 confirm already left result.json at `stage: "tier1"`, so any `final` is the tier-2 submit — and an mtime gate would race-miss a user who confirms tier 2 before this wait is issued. Same `--wait-timeout` budget; on timeout / server-gone it returns non-zero and the caller re-checks `result.json` once before the chat fallback.
- `--wait-only` does **not** launch a server — it attaches to the page already running from the first `--daemon --wait` and blocks until the page writes the requested stage. Use `--wait-stage stage2` for the middle design-system handoff, then the default `--wait-stage final` for the final Stage-3 confirmation. It keys on the **stage alone** (no mtime gate), because a user may submit before this wait command is issued. Same `--wait-timeout` budget; on timeout / server-gone it returns non-zero and the caller re-checks `result.json` once before the chat fallback.
- `--shutdown` stops a confirm server left running for this project and exits — **idempotent** (a no-op when nothing is running). Tries a graceful `/api/shutdown`, falls back to killing the recorded pid, then clears the lock. SKILL.md Step 4 runs this on every path (page-confirm or chat-fallback) so the page never lingers on the shared port before live preview starts.
- Refuses to start unless `<project_path>/confirm_ui/recommendations.json` exists (except `--shutdown`, which needs no recommendations).
- Per-project lock at `<project_path>/.confirm_ui.lock` — duplicate launches are refused; stale locks (dead pid) are overwritten.
@@ -48,6 +49,7 @@ pip install flask
## Two kinds of field
- **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.
- **Visual examples for hard-to-name choices** — the full-screen confirmation page loads real SVG page samples from `static/style_previews/` for `visual_style`, and renders real sample SVGs from `templates/icons` for `icons`. These thumbnails make style and icon-library choices visually comparable before the user locks them. Preview copy is fixed role text (big title / section title / body / points), not project content from `recommendations.json`, so users compare visual treatment rather than copywriting. These previews are a confirmation aid only: they do not add fields to `recommendations.json` or `result.json`, and they do not replace the later Step 6 live preview.
- **Image usage multi-select** — image sources are selected as one or more catalog ids: `ai` = AI-generated, `web` = Web-sourced, `provided` = User-provided, `placeholder` = Placeholder, `none` = No images. `none` is exclusive. 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).
@@ -58,28 +60,30 @@ pip install flask
## Catalogs — `static/catalogs.json` (the finite option universe)
The front-end loads `/api/catalogs` (served by the confirm server) and falls back to the static `/static/catalogs.json` if that route is unavailable. `/api/catalogs` returns the static file **with the `canvas` list synced live from `config.py CANVAS_FORMATS`** — the set of formats and their `dim` come from config (single source of truth, zero drift), while bilingual labels / use text stay in catalogs.json (a plain fallback label is synthesized for any new id config adds). Keys: `canvas`, `modes`, `visual_styles` (grouped), `icons`, `image_usage`, `image_ai_path`, `formula_policy`, `generation_mode`, `delivery_purpose`. Each entry is `{ "id", "label", "label_zh", "label_en", ... }`; descriptions use `desc_zh` / `desc_en`, and `visual_styles` groups use `group_zh` / `group_en`. The front-end falls back to legacy `label` / `desc` / `group`, so old catalogs still load, but new user-facing catalog text must be bilingual. English labels should mirror canonical reference names (`pyramid`, `swiss-minimal`, `Path A`, `mixed`, etc.); Chinese labels should be translated for users. Descriptions render inline after the option title, not as a separate selected-option line. `visual_styles` is `[{ "group", "group_zh", "group_en", "items": [...] }]`. For `canvas` you only need to maintain the bilingual labels in catalogs.json; the format set and dimensions are authoritative in `config.py CANVAS_FORMATS`.
The front-end loads `/api/catalogs` (served by the confirm server) and falls back to the static `/static/catalogs.json` if that route is unavailable. `/api/catalogs` returns the static file **with the `canvas` list synced live from `config.py CANVAS_FORMATS`** — the set of formats and their `dim` come from config (single source of truth, zero drift), while trilingual labels / use text stay in catalogs.json (a plain fallback label is synthesized for any new id config adds). Keys: `canvas`, `modes`, `visual_styles` (grouped), `icons`, `image_usage`, `image_ai_path`, `formula_policy`, `generation_mode`, `delivery_purpose`. Each entry is `{ "id", "label", "label_zh", "label_en", "label_ja", ... }`; descriptions use `desc_zh` / `desc_en` / `desc_ja`, and `visual_styles` groups use `group_zh` / `group_en` / `group_ja`. The front-end falls back to legacy `label` / `desc` / `group`, so old catalogs still load, but new user-facing catalog text must cover all three languages (zh / en / ja). English labels should mirror canonical reference names (`pyramid`, `swiss-minimal`, `Path A`, `mixed`, etc.); Chinese and Japanese labels should be translated for users. Descriptions render inline after the option title, not as a separate selected-option line. `visual_styles` is `[{ "group", "group_zh", "group_en", "group_ja", "items": [...] }]`. For `canvas` you only need to maintain the trilingual labels in catalogs.json; the format set and dimensions are authoritative in `config.py CANVAS_FORMATS`.
## Round-trip data contract
Both files live under `<project_path>/confirm_ui/`.
### Two-tier flow
### Three-stage flow
The page runs as a **two-tier wizard in one browser session**. `recommendations.json` carries a top-level `"tier"`:
The page runs as a **three-stage wizard in one browser session**. `recommendations.json` carries a top-level `"stage"` selector. Legacy payloads that still carry `"tier"` are accepted as read-only compatibility input, but new files must use `stage`.
| `tier` | Page renders | Button | On submit |
| `recommendations.json stage` | 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` |
| `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 |
| `"stage1"` | direction anchors — canvas, audience + `content_divergence` + `delivery_purpose` *(PPT only — omitted on non-PPT canvases, not written to the result)*, mode + visual_style | **Next** | writes `result.json` `{ stage: "stage1", status: "stage1-confirmed", <anchors> }`; the page does **not** close — it shows a "deriving…" state and polls `GET /api/recommendations` |
| `"stage2"` | design system — page count, color, icons, typography, formula policy | **Next** | writes `result.json` `{ stage: "stage2", status: "stage2-confirmed", <anchors + design system> }`; the page stays open and polls for Stage 3 |
| `"stage3"` | images and execution — 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 |
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 Stage 1 (`--daemon --wait`), reads the stage-1 result, **re-derives** the design-system candidates from the user's actual anchors, overwrites `recommendations.json` with `"stage": "stage2"`, and re-attaches with `--wait-only --wait-stage stage2`. After the Stage-2 result, it **re-derives** image and execution recommendations from the confirmed anchors + design system, overwrites `recommendations.json` with `"stage": "stage3"`, and re-attaches with `--wait-only` for the final result. The page preserves earlier selections across transitions (single JS session). `GET /api/recommendations` is served `no-store` so polls see overwrites; on later stages the server folds already-confirmed choices from `result.json` back into the payload so a refresh / reopen re-initializes from the user's actual choices even though those sections are no longer rendered.
### Input — `recommendations.json` (written by Strategist before launch)
```json
{
"stage": "stage1",
"lang": "zh",
"recommend": {
"canvas": "ppt169",
@@ -135,31 +139,31 @@ The AI launches Tier 1 (`--daemon --wait`), reads the tier-1 result, **re-derive
]
},
"visual_style_spectrum": [
{ "id": "soft-rounded", "tag_zh": "稳妥专业", "tag_en": "Safe & professional", "note_zh": "像 Notion 官网", "note_en": "like the Notion site" },
{ "id": "editorial", "tag_zh": "编辑质感", "tag_en": "Editorial depth", "note_zh": "像经济学人专题", "note_en": "like an Economist feature" },
{ "id": "brutalist", "tag_zh": "硬核宣言", "tag_en": "Bold manifesto", "note_zh": "像研究机构年度宣言", "note_en": "like a research-house manifesto" }
{ "id": "soft-rounded", "tag_zh": "稳妥专业", "tag_en": "Safe & professional", "tag_ja": "手堅くプロフェッショナル", "note_zh": "像 Notion 官网", "note_en": "like the Notion site", "note_ja": "Notion公式サイト風" },
{ "id": "editorial", "tag_zh": "编辑质感", "tag_en": "Editorial depth", "tag_ja": "エディトリアルな質感", "note_zh": "像经济学人专题", "note_en": "like an Economist feature", "note_ja": "The Economistの特集記事風" },
{ "id": "brutalist", "tag_zh": "硬核宣言", "tag_en": "Bold manifesto", "tag_ja": "大胆なマニフェスト", "note_zh": "像研究机构年度宣言", "note_en": "like a research-house manifesto", "note_ja": "研究機関の年次宣言風" }
],
"refine_spec": { "value": false }
}
```
> 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 — `color` and `typography` must each carry **≥3** in a real file, while `image_strategy.candidates` should carry **exactly 3 non-custom recommendation** entries when AI image generation is offered. The UI adds the fourth **Custom** card itself; `selected` indexes the recommended default among the recommendation entries.
- `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.
- `recommend.image_usage` should be an array of source ids when more than one source applies, e.g. `["ai", "provided"]`. A single string is still accepted for backward compatibility. Do not write bare `"custom"` and do not encode a mixed-source plan as prose here; write the prose to top-level `image_notes.value`.
- `image_notes` is the initial strategy note shown under the image source chips. Use it for page-role guidance and constraints: which source applies where, what to avoid, which user assets are authoritative, how realistic / abstract the imagery should be, and what can remain as placeholders. It is intent guidance, not a separate finite option.
- 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.
- **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 multilingual**: color / typography candidates can provide `name_zh` / `name_en` / `name_ja` and `note_zh` / `note_en` / `note_ja`; the page falls back to legacy `name` / `note`. Labels resolve in the page language first, then fall back across the others (a `ja` page: ja → en → zh; zh/en pages keep their zh↔en fallback and try `_ja` last), so when `lang` is `ja` always include the `_ja` variants — otherwise the candidate labels render in English.
- **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.53.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.
- **`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, Stage 1) as a consumption-mode choice; the Stage-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.
- **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.
- **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. Author **exactly three non-custom recommendation candidates** here; the page displays those three, then appends one built-in **Custom** card. If extra candidates are present, the page shows only the first three non-custom entries. When `rendering` / `palette` match files under `references/ai-image-comparison/`, the left preview pane displays those reference PNGs for the selected candidate; when AI image generation is not selected, that left preview is hidden. The right-side option cards stay text-first and do not duplicate the gallery. The **Custom** card lists all reference-gallery `rendering` and `palette` ids from `ai-image-comparison/*/_manifest.json`, plus `custom` as a prose-only tail choice and a free-text prompt box; that prose is written to `result.json.image_strategy.custom`. If either selected dimension is `custom`, the preview intentionally falls back to prose and shows no reference image. `palette` means color behavior only — final AI image HEX values follow the confirmed `color` choice above. 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"/"tag_ja", "note_zh"/"note_en"/"note_ja" }` (include the `_ja` variants when `lang` is `ja`) where `id` is a real `visual_styles` catalog id; the page badges those chips with their temperament `tag` (replacing the single ★) and appends the `note` (a real-world analogy) inline. The full grouped style list and Custom box stay visible below, and `recommend.visual_style` is still the pre-selected default (it should equal the spectrum's safe pick). Author **≥3** spanning safe / shifted / bold (mirrors h.5; honest-shortfall exception applies — fewer only when the constraints genuinely cannot yield 3). The user's pick still writes back to `result.json.visual_style` as a plain id; the spectrum is presentation-only. Omit the field to fall back to the single-recommendation badge.
- `recommend.generation_mode` and `refine_spec` mirror the two mandatory notes in SKILL.md Step 4. Confirmed `generation_mode: "split"` / `refine_spec: true` are explicit user choices, equivalent to opting in through chat.
- `content_divergence` is a **free-text** field shown right under the audience box in §c — the user states in their own words how closely to follow the source vs how freely to reshape it (e.g. "stick closely to the document" / "freely restructure and expand within the source"). It is **not** a fixed-option field; blank means a balanced default. Whatever the level, facts stay sourced — reshaping develops what is in the source, never imports facts from outside it. The Strategist consumes the prose when authoring the §IX outline and records it in `design_spec.md §I`; it is **not** written to `spec_lock.md` (the Executor never reads it). It carries no page-count coupling and no source-signal recommendation — it is purely the user's stated intent. Beautify / template-fill keep content verbatim and do not surface this field.
- `lang` is a soft default; an explicit user language choice in the page (persisted to `localStorage`) wins.
- `lang` is a soft default (`zh` / `en` / `ja` — the page UI supports all three); an explicit user language choice in the page (persisted to `localStorage`) wins.
### Output — `result.json` (written on submit, read by the AI)
@@ -188,11 +192,11 @@ 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** (Stage 3) result, carrying Stage 1 anchors, Stage 2 design-system fields, and Stage 3 image / execution fields. The intermediate **Stage 1** write carries only the anchor fields plus `"stage": "stage1"`, `"status": "stage1-confirmed"`; the AI reads it to re-derive Stage 2 and never treats it as the final confirmation. The intermediate **Stage 2** write carries anchors + design-system fields plus `"stage": "stage2"`, `"status": "stage2-confirmed"`; the AI reads it to re-derive Stage 3 and never treats it as final. Legacy `tier1` / `tier2` results are accepted for old sessions but are no longer written. 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 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` 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** (Stage 3, or single-pass), the page saves `result.json` and shuts the server down (auto-close). Stage-1 and Stage-2 **Next** keep the page open while it polls for the re-derived downstream stage. In the default flow, the first `--daemon --wait` returns on the stage-1 result, `--wait-only --wait-stage stage2` returns on the stage-2 result, and the final `--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
@@ -4,6 +4,67 @@
Source conversion tools turn PDFs, documents, slide decks, and web pages into Markdown before project creation.
Default workflow entry: use `source_to_md.py` unless a backend-specific
diagnostic or forced route is needed.
## Shared Output Contract
All `source_to_md` converters keep their existing Markdown output behavior and
now also write a lightweight sidecar profile when conversion succeeds:
| Output | Convention |
|---|---|
| Markdown | `<stem>.md` beside the local source unless `-o` selects another path |
| Asset directory | `<stem>_files/` when the backend extracts images or media |
| Image manifest | `<stem>_files/image_manifest.json` when image metadata is available |
| Conversion profile | `<stem>.conversion_profile.json` beside the Markdown output |
The conversion profile is metadata only. It records the converter, source path,
Markdown structure counts, asset directory, image manifest path, and image
count. Downstream PPT workflows still use the Markdown and image manifest as the
content/asset contract; the profile is for inspection and debugging.
## `source_to_md.py`
Unified dispatcher for ad hoc explicit-source conversion. It auto-detects each
listed input file or URL and calls the existing backend converter, so backend
behavior remains the source of truth.
Routing is centralized in `source_to_md/_dispatcher.py` and reused by
`project_manager.py import-sources`; do not add a second type-to-backend table.
```bash
python3 scripts/source_to_md.py paper.pdf
python3 scripts/source_to_md.py paper.pdf report.docx deck.pptx
python3 scripts/source_to_md.py ./sources
python3 scripts/source_to_md.py ./pdfs/*.pdf
python3 scripts/source_to_md.py ./decks/*.pptx
python3 scripts/source_to_md.py report.docx -o report.md
python3 scripts/source_to_md.py ./sources -o ./markdown # explicit separate output directory
python3 scripts/source_to_md.py workbook.xlsx --json
python3 scripts/source_to_md.py deck.pptx
python3 scripts/source_to_md.py https://example.com/article -o article.md
```
Useful options:
- `-t pdf|doc|excel|pptx|web|markdown|text` forces a route when extension
detection is not enough.
- `--json` prints a compact machine-readable result after success when the
output path is known. With multiple inputs, each successful conversion prints
its own JSON line after that source finishes.
- `--images all|filtered|none`, `--no-images`, and `--filter-images` map to the
existing PDF image mode. They are intentionally PDF-only until other backends
expose the same behavior natively.
- Unknown backend-specific flags are passed through to each selected converter.
- `-o/--output` selects one Markdown file for one input, or an output directory
for multiple inputs / directory inputs.
For multi-source project intake, use `project_manager.py import-sources` with
all source paths / URLs. For local files, the default is to keep generated
Markdown/profile outputs beside the original source. `source_to_md.py` and the
backend converters support single files, explicit multi-file inputs, and
non-recursive directory inputs.
## `source_to_md/pdf_to_md.py`
Recommended first choice for native PDFs.
@@ -11,8 +72,9 @@ Recommended first choice for native PDFs.
```bash
python3 scripts/source_to_md/pdf_to_md.py book.pdf
python3 scripts/source_to_md/pdf_to_md.py book.pdf -o output.md
python3 scripts/source_to_md/pdf_to_md.py book.pdf appendix.pdf
python3 scripts/source_to_md/pdf_to_md.py ./pdfs
python3 scripts/source_to_md/pdf_to_md.py ./pdfs -o ./markdown
python3 scripts/source_to_md/pdf_to_md.py ./pdfs -o ./markdown # explicit separate output directory
# Image extraction control (default: filtered)
python3 scripts/source_to_md/pdf_to_md.py book.pdf --images filtered # size/quality filters applied
@@ -41,7 +103,7 @@ pip install PyMuPDF
Hybrid converter: pure-Python for the common formats, pandoc fallback for the rest.
Native path (no external binary required):
- `.docx` — via `mammoth`; OMML / Office Math equations (Word-native or MathType "Convert to Office Math") are rewritten to inline LaTeX. Classic MathType OLE objects carry no OMML and are kept only as their preview image.
- `.docx` — via `mammoth`; text-only tables are preserved as pipe Markdown, and OMML / Office Math equations (Word-native or MathType "Convert to Office Math") are rewritten to inline LaTeX. Classic MathType OLE objects carry no OMML and are kept only as their preview image.
- `.html` / `.htm` — via `markdownify` + `beautifulsoup4`
- `.epub` — via `ebooklib` + `markdownify`
- `.ipynb` — via `nbconvert`
@@ -52,6 +114,9 @@ Pandoc fallback (only if you need these):
```bash
python3 scripts/source_to_md/doc_to_md.py lecture.docx
python3 scripts/source_to_md/doc_to_md.py lecture.docx -o output.md
python3 scripts/source_to_md/doc_to_md.py lecture.docx notes.html
python3 scripts/source_to_md/doc_to_md.py ./docs
python3 scripts/source_to_md/doc_to_md.py ./docs -o ./markdown # explicit separate output directory
python3 scripts/source_to_md/doc_to_md.py notes.epub
python3 scripts/source_to_md/doc_to_md.py paper.tex -o paper.md # uses pandoc
```
@@ -69,6 +134,7 @@ pip install mammoth markdownify ebooklib nbconvert beautifulsoup4
```
All paths produce the same output convention: `<input>.md` plus a sibling `<input>_files/` directory containing extracted images with relative references.
On success, a sibling `<input>.conversion_profile.json` is also written.
## `source_to_md/excel_to_md.py`
@@ -84,6 +150,9 @@ Unsupported by default:
```bash
python3 scripts/source_to_md/excel_to_md.py report.xlsx
python3 scripts/source_to_md/excel_to_md.py report.xlsx -o output.md
python3 scripts/source_to_md/excel_to_md.py report.xlsx budget.xlsm
python3 scripts/source_to_md/excel_to_md.py ./workbooks
python3 scripts/source_to_md/excel_to_md.py ./workbooks -o ./markdown # explicit separate output directory
python3 scripts/source_to_md/excel_to_md.py report.xlsm --max-rows 200 --max-cols 40
```
@@ -93,6 +162,7 @@ Behavior:
- trims empty outer rows and columns
- propagates merged-cell labels for readable Markdown tables
- exports formula cells as cached values; it does not recalculate formulas
- writes `<input>.conversion_profile.json` after successful conversion
Dependency:
@@ -114,8 +184,9 @@ Supported formats include:
```bash
python3 scripts/source_to_md/ppt_to_md.py sales_deck.pptx
python3 scripts/source_to_md/ppt_to_md.py sales_deck.pptx -o output.md
python3 scripts/source_to_md/ppt_to_md.py sales_deck.pptx appendix.pptx
python3 scripts/source_to_md/ppt_to_md.py ./decks
python3 scripts/source_to_md/ppt_to_md.py ./decks -o ./markdown
python3 scripts/source_to_md/ppt_to_md.py ./decks -o ./markdown # explicit separate output directory
python3 scripts/source_to_md/ppt_to_md.py template.ppsx -o notes/template.md
```
@@ -125,6 +196,7 @@ Behavior:
- transcribes native chart data (type + categories × series values) into a Markdown table, so chart numbers are not lost in conversion
- exports embedded pictures to a sibling `_files/` directory
- appends speaker notes when present
- writes `<input>.conversion_profile.json` after successful conversion
Dependency:
@@ -165,6 +237,7 @@ python3 scripts/source_to_md/web_to_md.py https://example.com/article
python3 scripts/source_to_md/web_to_md.py https://url1.com https://url2.com
python3 scripts/source_to_md/web_to_md.py -f urls.txt
python3 scripts/source_to_md/web_to_md.py https://example.com -o output.md
python3 scripts/source_to_md/web_to_md.py https://example.com --emit-result /tmp/result.json
```
When `curl_cffi` is installed (included in `requirements.txt`), this script
@@ -173,6 +246,11 @@ fetch WeChat Official Accounts (`mp.weixin.qq.com`) and other sites that
block Python's default TLS fingerprint. No extra flags needed. If
`curl_cffi` is not available, it falls back to plain `requests`.
On success, the converter writes `<output>.conversion_profile.json` beside the
Markdown output.
`--emit-result` is for wrapper scripts that need the actual saved Markdown path
when the converter derives a title-based filename.
## `rotate_images.py`
@@ -10,7 +10,7 @@ Main entry point for project setup and validation.
```bash
python3 scripts/project_manager.py init <project_name> --format ppt169
python3 scripts/project_manager.py import-sources <project_path> <source1> [<source2> ...]
python3 scripts/project_manager.py import-sources <project_path> <source1_or_dir> [<source2_or_dir> ...]
python3 scripts/project_manager.py validate <project_path>
python3 scripts/project_manager.py info <project_path>
```
@@ -18,6 +18,10 @@ python3 scripts/project_manager.py info <project_path>
Notes:
- Files outside the repo are copied into `sources/` by default
- With `--move`, files outside the repo are moved into `sources/`
- Directory inputs are expanded non-recursively. After Step 1 conversion,
pass the source file/directory once when generated Markdown lives beside the
original source. If Step 1 used `-o` to write Markdown elsewhere, pass both
the original source path/directory and the Markdown output path/directory.
- Files already inside the repo are moved into `sources/` by default (with a stderr
note), to avoid leaving unintended artifacts that could be committed by mistake.
Pass `--copy` to force a copy for in-repo sources instead.
@@ -20,9 +20,7 @@ Unified post-processing entry point. This is the preferred way to run SVG cleanu
It aggregates:
- `embed_icons.py`
- `crop_images.py`
- `fix_image_aspect.py`
- `embed_images.py`
- `align_embed_images.py` (`crop-images` / `fix-aspect` / `embed-images` aliases route here)
- `flatten_tspan.py`
- `svg_rect_to_path.py`
@@ -127,7 +125,6 @@ python3 scripts/svg_quality_checker.py examples/project --export
Checks include:
- `viewBox`
- banned elements
- width/height consistency
- line-break structure
## `svg_position_calculator.py`
@@ -180,15 +177,18 @@ python3 scripts/svg_finalize/svg_rect_to_path.py path/to/file.svg
Use when rounded corners must survive PowerPoint shape conversion.
### `fix_image_aspect.py`
### `align_embed_images.py`
```bash
python3 scripts/svg_finalize/fix_image_aspect.py path/to/slide.svg
python3 scripts/svg_finalize/fix_image_aspect.py 01_cover.svg 02_toc.svg
python3 scripts/svg_finalize/fix_image_aspect.py --dry-run path/to/slide.svg
python3 scripts/svg_finalize/align_embed_images.py path/to/slide.svg
python3 scripts/svg_finalize/align_embed_images.py --dry-run path/to/slide.svg
```
Use when embedded images stretch after PowerPoint shape conversion.
Use for rare single-file diagnostics when image `slice` / `meet` alignment and
Base64 embedding must be inspected outside `finalize_svg.py`. In normal project
runs, use `python3 scripts/finalize_svg.py <project_path>`; the old
`crop-images`, `fix-aspect`, and `embed-images` names remain accepted only as
`finalize_svg.py --only` aliases for the merged `align-images` step.
### `embed_icons.py`
@@ -210,7 +210,7 @@ Use PowerPoint-safe transparency syntax:
| `<g opacity=\"...\">` | Set opacity on each child |
| `<image opacity=\"...\">` | Overlay with a mask layer |
PowerPoint also has trouble with:
- marker-based arrows
- unsupported filters
- direct SVG features not mapped to DrawingML
PowerPoint also has trouble with unsupported filters and direct SVG features
not mapped to DrawingML. Connector arrows may use qualified
`marker-start` / `marker-end`; chunky or exotic arrows should be standalone
`<path>` / `<polygon>` shapes.
@@ -75,10 +75,11 @@ class ErrorHelper:
'severity': 'warning'
},
'viewbox_mismatch': {
'message': 'SVG viewBox does not match canvas format',
'message': 'SVG viewBox differs from the recorded canvas format',
'solutions': [
'Check the viewBox attribute of SVG files',
'Ensure it matches the project canvas format',
'Treat the root viewBox as the actual canvas size',
'If the project metadata is stale, export will use the SVG viewBox',
'PPT 16:9 should be: viewBox="0 0 1280 720"',
'PPT 4:3 should be: viewBox="0 0 1024 768"',
'Reference: references/canvas-formats.md'
@@ -100,8 +101,8 @@ class ErrorHelper:
'solutions': [
'Add the viewBox attribute to the SVG root element',
'Format: <svg viewBox="0 0 1280 720" ...>',
'Ensure width, height are consistent with viewBox',
'This is a mandatory requirement for SVG generation'
'Root width/height are optional compatibility attributes',
'The root viewBox is mandatory for SVG generation'
],
'severity': 'error'
},
@@ -266,7 +267,7 @@ class ErrorHelper:
'message': 'Forbidden web font (@font-face) detected',
'solutions': [
'Remove @font-face declarations',
'End every font-family stack with a PPT-safe pre-installed family',
'Use font-family stacks that export PPT-safe pre-installed typefaces',
'Example: font-family: "Microsoft YaHei", Arial, sans-serif'
],
'severity': 'error'
@@ -290,9 +291,9 @@ class ErrorHelper:
'severity': 'error'
},
'invalid_font': {
'message': 'Font stack does not end on a PPT-safe family',
'message': 'Font stack exports non-PPT-safe typefaces to PPTX',
'solutions': [
'End the stack with a cross-platform pre-installed family',
'Use stacks whose exported Latin / EA typefaces are pre-installed',
'CJK: "Microsoft YaHei", sans-serif | SimSun, serif',
'Latin: Arial, sans-serif | "Times New Roman", serif',
'Mono: Consolas, "Courier New", monospace',
@@ -45,6 +45,7 @@ configure_utf8_stdio()
# Import finalize helpers from the internal package.
sys.path.insert(0, str(Path(__file__).parent))
from resource_paths import icon_search_dirs_for_project # noqa: E402
from svg_finalize.align_embed_images import (
align_and_embed_images_in_svg,
count_office_vector_refs_in_svg,
@@ -136,12 +137,7 @@ def finalize_project(
"""
svg_output = project_dir / 'svg_output'
svg_final = project_dir / 'svg_final'
# Project-first: embed from the deck's own icons/ (synced library icons +
# any custom icons), falling back to the global library per-icon.
global_icons_dir = Path(__file__).parent.parent / 'templates' / 'icons'
project_icons_dir = project_dir / 'icons'
icons_dir = project_icons_dir if project_icons_dir.is_dir() else global_icons_dir
icons_fallback_dir = global_icons_dir if icons_dir != global_icons_dir else None
icons_dir, icons_fallback_dir = icon_search_dirs_for_project(project_dir)
# Check if svg_output exists
if not svg_output.exists():
@@ -43,24 +43,24 @@ if str(_SCRIPTS_DIR) not in sys.path:
from console_encoding import configure_utf8_stdio # noqa: E402
from pptx_animations import TRANSITIONS, create_transition_xml # noqa: E402
from svg_to_pptx.pptx_builder import ( # noqa: E402
from svg_to_pptx.pptx_package.builder import ( # noqa: E402
_add_default_content_type,
_append_relationship,
_ensure_notes_master,
)
from svg_to_pptx.pptx_narration import ( # noqa: E402
from svg_to_pptx.pptx_package.narration import ( # noqa: E402
AUDIO_CONTENT_TYPES,
AUDIO_MARKER_PNG_BYTES,
AUDIO_REL_TYPE,
IMAGE_REL_TYPE,
MEDIA_REL_TYPE,
NARRATION_EXTENSIONS,
TRANSPARENT_PNG_BYTES,
apply_recorded_timing,
inject_narration,
next_shape_id,
probe_audio_duration,
)
from svg_to_pptx.pptx_notes import ( # noqa: E402
from svg_to_pptx.pptx_package.notes import ( # noqa: E402
create_notes_slide_rels_xml,
create_notes_slide_xml,
markdown_to_plain_text,
@@ -360,7 +360,7 @@ def _apply_audio(
poster_name = "native_enhance_audio_poster.png"
poster_path = media_dir / poster_name
if not poster_path.exists():
poster_path.write_bytes(TRANSPARENT_PNG_BYTES)
poster_path.write_bytes(AUDIO_MARKER_PNG_BYTES)
slide_rels = _relationship_file_for_part(extract_dir, slide.part_name)
_ensure_rels_file(slide_rels)
@@ -1,6 +1,6 @@
"""DrawingML <a:custGeom> -> SVG <path d="..."> conversion.
Reverse of svg_to_pptx/drawingml_paths.path_commands_to_drawingml.
Reverse of svg_to_pptx/drawingml/paths.py path_commands_to_drawingml.
Path command mapping:
<a:moveTo> -> M
@@ -1,6 +1,6 @@
"""DrawingML <a:effectLst> -> SVG <filter> conversion.
Reverse of svg_to_pptx/drawingml_styles.build_effect_xml.
Reverse of svg_to_pptx/drawingml/styles.py build_effect_xml.
Covers the most common DrawingML effects:
- <a:outerShdw> -> feDropShadow (or feGaussianBlur+feOffset+feFlood)
@@ -1,6 +1,6 @@
"""EMU <-> pixel conversion and DrawingML unit constants.
Mirrors svg_to_pptx/drawingml_utils.py and pptx_dimensions.py, in reverse.
Mirrors svg_to_pptx/drawingml/utils.py and pptx_package/dimensions.py, in reverse.
DrawingML unit conventions:
- Coordinates / sizes: EMU (English Metric Unit). 914400 EMU = 1 inch = 96 px.
@@ -1,6 +1,6 @@
"""DrawingML <a:ln> -> SVG stroke conversion.
Reverse of svg_to_pptx/drawingml_styles.build_stroke_xml.
Reverse of svg_to_pptx/drawingml/styles.py build_stroke_xml.
Produces an SVG attribute dict with stroke / stroke-width / stroke-opacity /
stroke-dasharray / stroke-linecap / stroke-linejoin / marker-start /
@@ -1,6 +1,6 @@
"""DrawingML <p:txBody> -> SVG <text> conversion.
Reverse of svg_to_pptx/drawingml_elements.convert_text.
Reverse of svg_to_pptx/drawingml/elements.py convert_text.
Strategy (v1):
- Each <a:p> paragraph emits one <text> element (one line of baseline).
@@ -803,7 +803,7 @@ def _is_cjk(ch: str) -> bool:
def _char_width(ch: str, font_size: float, bold: bool) -> float:
"""Estimate a single character's rendered width in pixels.
Mirrors svg_to_pptx/drawingml_utils.estimate_text_width so wrapping breaks
Mirrors svg_to_pptx/drawingml/utils.py estimate_text_width so wrapping breaks
align with the same heuristic used to estimate text-box sizes elsewhere.
"""
if _is_cjk(ch):
@@ -47,21 +47,22 @@ except ImportError:
TOOLS_DIR = Path(__file__).resolve().parent
SKILL_DIR = TOOLS_DIR.parent
REPO_ROOT = SKILL_DIR.parent.parent
SOURCE_TO_MD_TOOLS_DIR = TOOLS_DIR / "source_to_md"
if str(SOURCE_TO_MD_TOOLS_DIR) not in sys.path:
sys.path.insert(0, str(SOURCE_TO_MD_TOOLS_DIR))
from _dispatcher import ( # noqa: E402
DOC_SUFFIXES,
EXCEL_SUFFIXES,
LEGACY_EXCEL_SUFFIXES,
PDF_SUFFIXES,
PRESENTATION_SUFFIXES,
build_conversion_command,
)
SOURCE_DIRNAME = "sources"
TEXT_SOURCE_SUFFIXES = {".md", ".markdown", ".txt"}
TABLE_TEXT_SUFFIXES = {".csv", ".tsv"}
PDF_SUFFIXES = {".pdf"}
PRESENTATION_SUFFIXES = {".pptx", ".pptm", ".ppsx", ".ppsm", ".potx", ".potm"}
EXCEL_SUFFIXES = {".xlsx", ".xlsm"}
LEGACY_EXCEL_SUFFIXES = {".xls"}
DOC_SUFFIXES = {
".docx", ".doc", ".odt", ".rtf", # Office documents
".epub", # eBooks
".html", ".htm", # Web pages
".tex", ".latex", ".rst", ".org", # Academic / technical
".ipynb", ".typ", # Notebooks / Typst
}
WECHAT_HOST_KEYWORDS = ("mp.weixin.qq.com", "weixin.qq.com")
IMAGE_ASSET_SUFFIXES = {
".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".tiff", ".tif",
".emf", ".wmf", ".svg",
@@ -71,15 +72,6 @@ IMAGE_ASSET_SUFFIXES = {
configure_utf8_stdio()
def _curl_cffi_available() -> bool:
"""Return whether curl_cffi is importable (enables Python TLS impersonation)."""
try:
import curl_cffi # noqa: F401
return True
except ImportError:
return False
def is_url(value: str) -> bool:
"""Return whether a string looks like an HTTP(S) URL."""
parsed = urlparse(value)
@@ -140,7 +132,13 @@ class ProjectManager:
)
date_str = datetime.now().strftime("%Y%m%d")
project_dir_name = f"{project_name}_{normalized_format}_{date_str}"
# A name already carrying a `_<format>_<YYYYMMDD>` suffix (e.g. a full
# project dir name pasted back into init) is used as-is — re-appending
# would produce `name_ppt169_20260101_ppt169_20260102`.
if re.search(rf"_{re.escape(normalized_format)}_\d{{8}}$", project_name):
project_dir_name = project_name
else:
project_dir_name = f"{project_name}_{normalized_format}_{date_str}"
project_path = base_path / project_dir_name
if project_path.exists():
@@ -177,7 +175,7 @@ class ProjectManager:
"- `live_preview/`: browser preview runtime files and history (lock.json, server.log, edits.jsonl, annotations.jsonl)\n"
"- `sources/`: source materials and normalized markdown\n"
"- `analysis/`: machine-extracted intermediate analysis (PPTX intake, image_analysis.csv) — the pipeline's canonical must-read source/asset facts\n"
"- `exports/`: main native pptx (timestamped); `_svg.pptx` sibling added when exported with `--svg-snapshot`\n"
"- `exports/`: main native pptx (timestamped); `_svg.pptx` sibling added with `--svg-snapshot`, `_native_charts.pptx` name with `--native-objects`\n"
"- `backup/<timestamp>/`: svg_output/ archive (always written in default-flow mode; safe to delete old timestamps)\n"
),
encoding="utf-8",
@@ -263,37 +261,28 @@ class ProjectManager:
print(result.stdout.strip())
def _import_pdf(self, pdf_path: Path, markdown_path: Path) -> None:
self._run_tool(
[
sys.executable,
str(TOOLS_DIR / "source_to_md" / "pdf_to_md.py"),
str(pdf_path),
"-o",
str(markdown_path),
]
route = build_conversion_command(
str(pdf_path),
markdown_path,
forced_type="pdf",
)
self._run_tool(route.command)
def _import_doc(self, doc_path: Path, markdown_path: Path) -> None:
self._run_tool(
[
sys.executable,
str(TOOLS_DIR / "source_to_md" / "doc_to_md.py"),
str(doc_path),
"-o",
str(markdown_path),
]
route = build_conversion_command(
str(doc_path),
markdown_path,
forced_type="doc",
)
self._run_tool(route.command)
def _import_presentation(self, presentation_path: Path, markdown_path: Path) -> None:
self._run_tool(
[
sys.executable,
str(TOOLS_DIR / "source_to_md" / "ppt_to_md.py"),
str(presentation_path),
"-o",
str(markdown_path),
]
route = build_conversion_command(
str(presentation_path),
markdown_path,
forced_type="pptx",
)
self._run_tool(route.command)
def _import_pptx_intake(self, presentation_path: Path, project_dir: Path) -> Path:
# Multi-deck intake: each PPTX writes its own `<stem>.identity.json` /
@@ -312,36 +301,20 @@ class ProjectManager:
return analysis_dir
def _import_excel(self, excel_path: Path, markdown_path: Path) -> None:
self._run_tool(
[
sys.executable,
str(TOOLS_DIR / "source_to_md" / "excel_to_md.py"),
str(excel_path),
"-o",
str(markdown_path),
]
route = build_conversion_command(
str(excel_path),
markdown_path,
forced_type="excel",
)
self._run_tool(route.command)
def _import_url(self, url: str, markdown_path: Path) -> None:
# Prefer web_to_md.py: it uses curl_cffi internally when available,
# which handles WeChat and other TLS-fingerprint-blocked sites.
# Fall back to the Node.js version only when the URL is known to
# require TLS impersonation AND curl_cffi isn't installed.
host = urlparse(url).netloc.lower()
is_tls_sensitive = any(keyword in host for keyword in WECHAT_HOST_KEYWORDS)
if is_tls_sensitive and not _curl_cffi_available() and shutil.which("node"):
command = ["node", str(TOOLS_DIR / "source_to_md" / "web_to_md.cjs"),
url, "-o", str(markdown_path)]
else:
command = [
sys.executable,
str(TOOLS_DIR / "source_to_md" / "web_to_md.py"),
url,
"-o",
str(markdown_path),
]
self._run_tool(command)
route = build_conversion_command(
url,
markdown_path,
forced_type="web",
)
self._run_tool(route.command)
def _is_valid_imported_url_markdown(self, markdown_path: Path) -> bool:
"""Return whether web_to_md produced a usable Markdown source."""
@@ -598,6 +571,14 @@ class ProjectManager:
move=move,
)
profile_src = source_path.with_name(f"{source_path.stem}.conversion_profile.json")
if profile_src.is_file():
self._copy_or_move_file(
profile_src,
sources_dir / f"{archived_markdown.stem}.conversion_profile.json",
move=move,
)
asset_dir = self._companion_asset_dir(source_path)
if asset_dir is None:
return archived_markdown, None, None
@@ -645,16 +626,37 @@ class ProjectManager:
"notes": [],
"skipped": [],
}
expanded_items: list[str] = []
for item in source_items:
if is_url(item):
expanded_items.append(item)
continue
item_path = Path(item)
if item_path.is_dir():
directory_files = sorted(
path for path in item_path.iterdir() if path.is_file()
)
if directory_files:
expanded_items.extend(str(path) for path in directory_files)
summary["notes"].append(
f"{item}: expanded directory into {len(directory_files)} file(s)"
)
else:
summary["skipped"].append(f"{item}: directory contains no files")
continue
expanded_items.append(item)
explicit_markdown_stems = {
Path(item).stem
for item in source_items
for item in expanded_items
if not is_url(item)
and Path(item).exists()
and Path(item).is_file()
and Path(item).suffix.lower() in {".md", ".markdown"}
}
for item in source_items:
for item in expanded_items:
if is_url(item):
markdown_path = self._ensure_unique_path(
sources_dir / f"{derive_url_basename(item)}.md"
@@ -894,7 +896,7 @@ def build_parser() -> argparse.ArgumentParser:
help="Import source files or URLs into a project",
)
import_sources.add_argument("project_path", help="Project directory")
import_sources.add_argument("sources", nargs="+", help="Source files or URLs")
import_sources.add_argument("sources", nargs="+", help="Source files, directories, or URLs")
mode = import_sources.add_mutually_exclusive_group()
mode.add_argument("--move", action="store_true", help="Move local source files")
mode.add_argument("--copy", action="store_true", help="Copy local source files")
@@ -11,6 +11,7 @@ import re
from pathlib import Path
from datetime import datetime
from typing import Dict, List, Optional, Tuple
from xml.etree import ElementTree as ET
from console_encoding import configure_utf8_stdio
@@ -323,7 +324,6 @@ def validate_svg_viewbox(svg_files: List[Path], expected_format: Optional[str] =
List of warnings
"""
warnings = []
viewbox_pattern = re.compile(r'viewBox="([^"]+)"')
viewboxes = set()
# Determine expected viewBox
@@ -333,27 +333,28 @@ def validate_svg_viewbox(svg_files: List[Path], expected_format: Optional[str] =
for svg_file in svg_files[:10]: # Check first 10 files
try:
with open(svg_file, 'r', encoding='utf-8') as f:
content = f.read(2000) # Only read first 2000 characters
match = viewbox_pattern.search(content)
if match:
viewbox = match.group(1)
viewboxes.add(viewbox)
viewbox = ET.parse(svg_file).getroot().get('viewBox')
if viewbox:
viewboxes.add(viewbox)
# If expected format is specified, check for match
if expected_viewbox and viewbox != expected_viewbox:
warnings.append(
f"{svg_file.name}: viewBox '{viewbox}' does not match expected format "
f"'{expected_format}' (expected: '{expected_viewbox}')"
)
else:
warnings.append(f"{svg_file.name}: viewBox attribute not found")
# If expected format is specified, check for match
if expected_viewbox and viewbox != expected_viewbox:
warnings.append(
f"{svg_file.name}: root viewBox '{viewbox}' differs from recorded "
f"project format '{expected_format}' ({expected_viewbox}); export uses "
f"the SVG viewBox as the canvas size"
)
else:
warnings.append(f"{svg_file.name}: root viewBox attribute not found")
except Exception as e:
warnings.append(f"{svg_file.name}: Failed to read - {e}")
# Check for multiple different viewBoxes
if len(viewboxes) > 1:
warnings.append(f"Multiple different viewBox settings detected: {viewboxes}")
warnings.append(
f"Multiple different root viewBox settings detected: {viewboxes}; "
"confirm this mixed-canvas project is intentional"
)
return warnings
@@ -0,0 +1,88 @@
#!/usr/bin/env python3
"""
PPT Master - Shared SVG Resource Path Helpers
Centralizes project-relative SVG resource lookup used by the checker,
finalizer, and SVG-to-PPTX exporter.
Usage:
Imported by scripts; not intended as a standalone CLI.
Examples:
from resource_paths import resolve_external_image_reference
Dependencies:
None
"""
from __future__ import annotations
from pathlib import Path
from urllib.parse import unquote, urlsplit
SVG_WORK_DIR_NAMES = frozenset({'svg_output', 'svg_final', 'svg-flat', 'svg_flat'})
def project_root_for_svg_path(svg_path: Path) -> Path:
"""Infer the project root from an SVG file path or SVG directory path."""
path = Path(svg_path)
base = path if path.is_dir() else path.parent
if base.name in SVG_WORK_DIR_NAMES:
return base.parent
return base
def global_icons_dir() -> Path:
"""Return the skill-level icon library directory."""
return Path(__file__).resolve().parent.parent / 'templates' / 'icons'
def icon_search_dirs_for_project(project_path: Path) -> tuple[Path, Path | None]:
"""Return project-first icon dirs plus the global fallback when needed."""
global_dir = global_icons_dir()
project_icons_dir = Path(project_path) / 'icons'
if project_icons_dir.is_dir():
return project_icons_dir, global_dir
return global_dir, None
def icon_search_dirs_for_svg(svg_path: Path) -> tuple[Path, Path | None]:
"""Return icon dirs for an SVG file path or SVG directory path."""
return icon_search_dirs_for_project(project_root_for_svg_path(svg_path))
def external_image_reference_candidates(svg_dir: Path, href: str) -> list[Path]:
"""Return candidate paths for a non-data-URI SVG image href."""
parsed = urlsplit(href)
if parsed.scheme and parsed.scheme not in {'file'}:
return []
decoded = unquote(
parsed.path
if parsed.scheme
else href.split('?', 1)[0].split('#', 1)[0]
)
svg_dir = Path(svg_dir)
project_root = project_root_for_svg_path(svg_dir)
return [
svg_dir / decoded,
project_root / decoded,
project_root / 'images' / decoded,
project_root / 'templates' / decoded,
]
def resolve_external_image_reference(svg_dir: Path, href: str) -> Path | None:
"""Resolve an SVG image href to an existing file, or return None."""
for candidate in external_image_reference_candidates(svg_dir, href):
if candidate.exists():
return candidate.resolve()
return None
def unresolved_external_image_reference_path(svg_dir: Path, href: str) -> Path:
"""Return the first candidate path for diagnostics when resolution fails."""
candidates = external_image_reference_candidates(svg_dir, href)
if candidates:
return candidates[0].resolve()
return (Path(svg_dir) / href).resolve()
@@ -0,0 +1,505 @@
#!/usr/bin/env python3
"""
PPT Master - Unified Markdown Converter
Auto-detect source type and dispatch to the existing source_to_md converters.
Usage:
python3 scripts/source_to_md.py <file_or_url_or_dir> [<file_or_url_or_dir> ...] [options]
Examples:
python3 scripts/source_to_md.py paper.pdf
python3 scripts/source_to_md.py paper.pdf report.docx deck.pptx
python3 scripts/source_to_md.py ./sources -o ./markdown
python3 scripts/source_to_md.py report.docx -o report.md
python3 scripts/source_to_md.py deck.pptx --json
Dependencies:
Same as the backend converter selected for the input.
"""
from __future__ import annotations
import argparse
import json
import subprocess
import sys
import tempfile
from pathlib import Path
_SCRIPTS_DIR = Path(__file__).resolve().parent
if str(_SCRIPTS_DIR) not in sys.path:
sys.path.insert(0, str(_SCRIPTS_DIR))
from console_encoding import configure_utf8_stdio # noqa: E402
_SOURCE_TO_MD_DIR = _SCRIPTS_DIR / "source_to_md"
if str(_SOURCE_TO_MD_DIR) not in sys.path:
sys.path.insert(0, str(_SOURCE_TO_MD_DIR))
from _conversion_profile import ( # noqa: E402
build_result_payload,
profile_path_for,
write_conversion_profile,
)
from _batch import expand_directory_inputs, unique_output_path # noqa: E402
from _dispatcher import ( # noqa: E402
build_conversion_command,
default_markdown_path,
detect_source_type,
is_url,
)
configure_utf8_stdio()
def resolve_output(output: str | None, input_arg: str) -> Path:
return Path(output) if output else default_markdown_path(input_arg)
def _print_status(message: str) -> None:
print(message, file=sys.stderr)
def _is_supported_directory_item(path: Path) -> bool:
return detect_source_type(str(path)) in {
"pdf", "doc", "excel", "pptx", "markdown", "text",
}
def _dispatch_output_arg(
input_arg: str,
conversion_type: str,
output_arg: str | None,
batch_mode: bool,
used_outputs: set[Path],
) -> str | None:
if output_arg and batch_mode and conversion_type == "web":
return None
if output_arg and batch_mode:
return str(
unique_output_path(
Path(output_arg),
default_markdown_path(input_arg).stem,
used_outputs,
)
)
if output_arg:
return output_arg
if batch_mode and conversion_type != "web":
return str(default_markdown_path(input_arg))
return None
def run_backend(command: list[str], script_name: str) -> int:
_print_status(f"[>>] {script_name} {' '.join(command[2:])}")
try:
result = subprocess.run(
command,
check=False,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
)
except KeyboardInterrupt:
return 130
if result.stdout.strip():
print(result.stdout.strip(), file=sys.stderr)
if result.stderr.strip():
print(result.stderr.strip(), file=sys.stderr)
return result.returncode
def print_output(path: Path) -> None:
print(f"OUTPUT: {path.resolve()}")
def write_passthrough(
input_arg: str,
output: Path,
conversion_type: str,
json_output: bool = False,
) -> int:
"""Copy text-like input to Markdown and write the profile sidecar."""
source = Path(input_arg)
try:
text = source.read_text(encoding="utf-8", errors="replace")
except OSError as exc:
print(f"[ERROR] Cannot read {source}: {exc}", file=sys.stderr)
return 1
output.parent.mkdir(parents=True, exist_ok=True)
if output.resolve() != source.resolve():
output.write_text(text, encoding="utf-8")
profile = write_conversion_profile(
input_path=input_arg,
markdown_path=output,
converter="source_to_md.py",
conversion_type=conversion_type,
)
_print_status(f"[OK] Saved Markdown to: {output}")
_print_status(f" Wrote conversion profile -> {profile}")
print_output(output)
if json_output:
payload = build_result_payload(
input_path=input_arg,
markdown_path=output,
converter="source_to_md.py",
conversion_type=conversion_type,
profile_path=profile,
)
print(json.dumps(payload, ensure_ascii=False))
return 0
def ensure_profile(
input_arg: str,
output: Path,
converter: str,
conversion_type: str,
) -> Path:
"""Return an existing profile path, writing one if the backend did not."""
profile = profile_path_for(output)
if profile.is_file():
return profile
return write_conversion_profile(
input_path=input_arg,
markdown_path=output,
converter=converter,
conversion_type=conversion_type,
)
def print_json_result(
input_arg: str,
output: Path,
converter: str,
conversion_type: str,
profile: Path,
) -> None:
payload = build_result_payload(
input_path=input_arg,
markdown_path=output,
converter=converter,
conversion_type=conversion_type,
profile_path=profile,
)
print(json.dumps(payload, ensure_ascii=False))
def _read_emit_result(path: Path) -> Path | None:
try:
payload = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return None
markdown = payload.get("markdown")
return Path(markdown) if isinstance(markdown, str) and markdown else None
def _pdf_image_mode(args: argparse.Namespace) -> str | None:
image_mode = args.images
if args.no_images:
image_mode = "none"
if args.filter_images:
image_mode = "filtered"
return image_mode
def _validate_image_options(args: argparse.Namespace) -> bool:
selected = sum(bool(value) for value in (args.images, args.no_images, args.filter_images))
if selected > 1:
print(
"[ERROR] --images, --no-images, and --filter-images are mutually exclusive",
file=sys.stderr,
)
return False
return True
def dispatch_single(
input_arg: str,
conversion_type: str,
output_arg: str | None,
args: argparse.Namespace,
unknown_args: list[str],
web_output_dir: str | None = None,
) -> int:
"""Dispatch one source to the matching existing converter."""
if conversion_type == "auto":
conversion_type = detect_source_type(input_arg)
if conversion_type == "markdown":
output = resolve_output(output_arg, input_arg)
return write_passthrough(input_arg, output, "markdown", args.json)
if conversion_type == "text":
output = resolve_output(output_arg, input_arg)
return write_passthrough(input_arg, output, "text", args.json)
if conversion_type == "web":
output = Path(output_arg) if output_arg else None
emit_result: Path | None = None
extra_args = list(unknown_args)
if output is None:
emit_file = tempfile.NamedTemporaryFile(
prefix="ppt-master-web-result-",
suffix=".json",
delete=False,
)
emit_file.close()
emit_result = Path(emit_file.name)
extra_args.extend(["--emit-result", str(emit_result)])
if web_output_dir:
extra_args.extend(["--dir", web_output_dir])
try:
route = build_conversion_command(
input_arg,
output,
forced_type="web",
extra_args=extra_args,
)
except ValueError as exc:
if emit_result:
emit_result.unlink(missing_ok=True)
print(f"[ERROR] {exc}", file=sys.stderr)
return 1
rc = run_backend(route.command, route.script_name)
if rc != 0:
if emit_result:
emit_result.unlink(missing_ok=True)
return rc
output_path = route.output_path
if output_path is None and emit_result is not None:
output_path = _read_emit_result(emit_result)
emit_result.unlink(missing_ok=True)
if output_path and output_path.is_file():
profile = ensure_profile(input_arg, output_path, route.script_name, "web")
print_output(output_path)
if args.json:
print_json_result(input_arg, output_path, route.script_name, "web", profile)
return 0
if output is not None:
print(f"[ERROR] Expected Markdown output not found: {output}", file=sys.stderr)
else:
print("[ERROR] Web conversion did not report a Markdown output path", file=sys.stderr)
return 1
if conversion_type not in {"pdf", "doc", "excel", "pptx"}:
print(
f"[ERROR] Could not determine conversion type for {input_arg!r}. "
"Use -t pdf|doc|excel|pptx|web|markdown|text.",
file=sys.stderr,
)
return 1
if not is_url(input_arg) and not Path(input_arg).exists():
print(f"[ERROR] File not found: {input_arg}", file=sys.stderr)
return 1
output = resolve_output(output_arg, input_arg)
try:
route = build_conversion_command(
input_arg,
output,
forced_type=conversion_type,
extra_args=unknown_args,
pdf_image_mode=_pdf_image_mode(args),
render_vector_figures=args.render_vector_figures,
)
except ValueError as exc:
print(f"[ERROR] {exc}", file=sys.stderr)
return 1
rc = run_backend(route.command, route.script_name)
if rc != 0:
return rc
if not output.is_file():
print(f"[ERROR] Expected Markdown output not found: {output}", file=sys.stderr)
return 1
profile = ensure_profile(input_arg, output, route.script_name, conversion_type)
print_output(output)
if args.json:
print_json_result(input_arg, output, route.script_name, conversion_type, profile)
return 0
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description="Auto-detect source type and convert to Markdown via source_to_md backends.",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
python3 scripts/source_to_md.py paper.pdf
python3 scripts/source_to_md.py paper.pdf report.docx deck.pptx
python3 scripts/source_to_md.py ./sources -o ./markdown
python3 scripts/source_to_md.py report.docx -o output.md
python3 scripts/source_to_md.py deck.pptx --json
python3 scripts/source_to_md.py https://example.com/article -o article.md
Backend-specific flags not listed here are passed through to the selected
converter, so existing converter behavior remains the source of truth.
""",
)
parser.add_argument("inputs", nargs="+", help="Input file(s), directories, or URL(s)")
parser.add_argument(
"-t",
"--type",
choices=["auto", "pdf", "doc", "excel", "pptx", "web", "markdown", "text"],
default="auto",
help="Force a conversion type (default: auto)",
)
parser.add_argument(
"-o",
"--output",
help="Output Markdown file for one input, or output directory for multiple inputs/directories",
)
parser.add_argument(
"--images",
choices=["all", "filtered", "none"],
help="PDF image extraction mode; maps to pdf_to_md.py --images",
)
parser.add_argument(
"--no-images",
action="store_true",
help="Alias for --images none on PDF inputs",
)
parser.add_argument(
"--filter-images",
action="store_true",
help="Alias for --images filtered on PDF inputs",
)
parser.add_argument(
"--render-vector-figures",
action="store_true",
help="Pass through to pdf_to_md.py for PDF vector figure rendering",
)
parser.add_argument(
"--json",
action="store_true",
help="Print a machine-readable result after successful conversion",
)
return parser
def _has_pdf_image_flags(args: argparse.Namespace) -> bool:
return bool(args.images or args.no_images or args.filter_images or args.render_vector_figures)
def _conversion_type_for_input(input_arg: str, requested_type: str) -> str:
if requested_type == "auto":
return detect_source_type(input_arg)
return requested_type
def _validate_pdf_image_flags(args: argparse.Namespace, conversion_types: list[str]) -> bool:
if not _has_pdf_image_flags(args):
return True
if any(conversion_type != "pdf" for conversion_type in conversion_types):
print("[ERROR] Image extraction flags are currently supported only for PDFs", file=sys.stderr)
return False
return True
def dispatch_many(
inputs: list[str],
args: argparse.Namespace,
unknown_args: list[str],
conversion_types: list[str],
batch_mode: bool = False,
initial_failures: list[str] | None = None,
) -> int:
success_count = 0
failed: list[str] = []
skipped: list[str] = list(initial_failures or [])
batch_mode = batch_mode or len(inputs) > 1
if args.output and batch_mode:
output_dir = Path(args.output)
if output_dir.exists() and not output_dir.is_dir():
print(f"[ERROR] Batch output path is not a directory: {args.output}", file=sys.stderr)
return 1
output_dir.mkdir(parents=True, exist_ok=True)
used_outputs: set[Path] = set()
for input_arg, conversion_type in zip(inputs, conversion_types):
output_arg = _dispatch_output_arg(
input_arg,
conversion_type,
args.output,
batch_mode,
used_outputs,
)
web_output_dir = (
args.output
if args.output and batch_mode and conversion_type == "web"
else None
)
if batch_mode:
_print_status(f"\n==> {input_arg}")
rc = dispatch_single(
input_arg,
conversion_type,
output_arg,
args,
unknown_args,
web_output_dir=web_output_dir,
)
if rc == 0:
success_count += 1
else:
failed.append(f"{input_arg}: exit {rc}")
if batch_mode:
_print_status(f"\n[Done] Success: {success_count}/{len(inputs)}, Failed: {len(failed)}")
if skipped:
_print_status("\n[Skipped directories]:")
for item in skipped:
_print_status(f" - {item}")
if failed:
_print_status("\n[Failed inputs]:")
for item in failed:
_print_status(f" - {item}")
if not inputs:
return 1
return 0 if not failed and not skipped else 1
def main(argv: list[str] | None = None) -> int:
parser = build_parser()
args, unknown_args = parser.parse_known_args(argv)
if not _validate_image_options(args):
return 2
inputs, expansion_errors, saw_directory = expand_directory_inputs(
args.inputs,
_is_supported_directory_item,
is_external_ref=is_url,
)
batch_mode = saw_directory or len(inputs) > 1
conversion_types = [_conversion_type_for_input(item, args.type) for item in inputs]
if not _validate_pdf_image_flags(args, conversion_types):
return 2
if unknown_args and any(
conversion_type in {"markdown", "text"} for conversion_type in conversion_types
):
print(
"[ERROR] Backend-specific flags cannot be used with markdown/text passthrough inputs",
file=sys.stderr,
)
return 2
return dispatch_many(
inputs,
args,
unknown_args,
conversion_types,
batch_mode=batch_mode,
initial_failures=expansion_errors,
)
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,150 @@
#!/usr/bin/env python3
"""
PPT Master - Source Converter Batch Helpers
Share explicit multi-file and directory expansion logic across source_to_md
backend converters.
Usage:
Imported by scripts/source_to_md/*_to_md.py
Examples:
run_path_batch(["docs"], {".docx"}, None, convert_one)
Dependencies:
None
"""
from __future__ import annotations
import sys
from collections.abc import Callable
from pathlib import Path
ConvertOne = Callable[[Path, Path], bool]
IsSupportedFile = Callable[[Path], bool]
def _print_status(message: str) -> None:
print(message, file=sys.stderr)
def expand_directory_inputs(
inputs: list[str],
is_supported_file: IsSupportedFile,
is_external_ref: Callable[[str], bool] | None = None,
) -> tuple[list[str], list[str], bool]:
"""Expand non-recursive directory inputs while preserving other items."""
expanded: list[str] = []
errors: list[str] = []
saw_directory = False
is_external = is_external_ref or (lambda _item: False)
for item in inputs:
if is_external(item):
expanded.append(item)
continue
path = Path(item)
if path.is_dir():
saw_directory = True
matches = sorted(
child for child in path.iterdir()
if child.is_file() and is_supported_file(child)
)
if matches:
expanded.extend(str(match) for match in matches)
else:
errors.append(f"{item}: no supported files found")
continue
expanded.append(item)
return expanded, errors, saw_directory
def _output_key(path: Path) -> Path:
return path.resolve(strict=False)
def unique_output_path(output_dir: Path, stem: str, used_outputs: set[Path]) -> Path:
"""Return an in-run unique Markdown path without consulting the filesystem."""
base = stem or "output"
candidate = output_dir / f"{base}.md"
suffix = 2
while _output_key(candidate) in used_outputs:
candidate = output_dir / f"{base}_{suffix}.md"
suffix += 1
used_outputs.add(_output_key(candidate))
return candidate
def _output_for(
source: Path,
output_arg: str | None,
batch_mode: bool,
used_outputs: set[Path],
) -> Path:
if output_arg and batch_mode:
return unique_output_path(Path(output_arg), source.stem, used_outputs)
if output_arg:
return Path(output_arg)
return source.with_suffix(".md")
def run_path_batch(
inputs: list[str],
supported_suffixes: set[str],
output_arg: str | None,
convert_one: ConvertOne,
) -> int:
"""Run one converter across explicit files and non-recursive directories."""
expanded, expansion_errors, saw_directory = expand_directory_inputs(
inputs,
lambda path: path.suffix.lower() in supported_suffixes,
)
sources = [Path(item) for item in expanded]
batch_mode = saw_directory or len(sources) > 1
if output_arg and batch_mode:
output_dir = Path(output_arg)
if output_dir.exists() and not output_dir.is_dir():
_print_status(f"[ERROR] Batch output path is not a directory: {output_arg}")
return 1
output_dir.mkdir(parents=True, exist_ok=True)
success_count = 0
failed: list[str] = []
skipped: list[str] = list(expansion_errors)
used_outputs: set[Path] = set()
for source in sources:
output = _output_for(source, output_arg, batch_mode, used_outputs)
if batch_mode:
_print_status(f"\n==> {source}")
try:
converted = convert_one(source, output)
sys.stdout.flush()
if converted:
success_count += 1
else:
failed.append(str(source))
except Exception as exc:
sys.stdout.flush()
failed.append(f"{source}: {exc}")
_print_status(f"[ERROR] {source}: {exc}")
if batch_mode:
sys.stdout.flush()
_print_status(f"\n[Done] Success: {success_count}/{len(sources)}, Failed: {len(failed)}")
if skipped:
_print_status("\n[Skipped directories]:")
for item in skipped:
_print_status(f" - {item}")
if failed:
_print_status("\n[Failed inputs]:")
for item in failed:
_print_status(f" - {item}")
if not sources:
return 1
return 0 if not failed and not skipped else 1
@@ -0,0 +1,235 @@
#!/usr/bin/env python3
"""
PPT Master - Markdown Conversion Profile Helpers
Write lightweight sidecar metadata for source_to_md conversion outputs.
Usage:
Imported by scripts/source_to_md/*.py
Examples:
write_conversion_profile(input_path="demo.pdf", markdown_path="demo.md", ...)
Dependencies:
None
"""
from __future__ import annotations
import json
import re
import sys
from pathlib import Path
from typing import Any
IMAGE_MANIFEST_NAME = "image_manifest.json"
PROFILE_SCHEMA = "ppt-master.source_to_md.profile.v1"
PROFILE_SUFFIX = ".conversion_profile.json"
def default_asset_dir(markdown_path: Path) -> Path:
"""Return the conventional companion asset directory for one Markdown output."""
return markdown_path.parent / f"{markdown_path.stem}_files"
def profile_path_for(markdown_path: Path) -> Path:
"""Return the sidecar profile path for one Markdown output."""
return markdown_path.with_name(f"{markdown_path.stem}{PROFILE_SUFFIX}")
def _display_path(path: Path | None, root: Path) -> str:
if path is None:
return ""
try:
return path.resolve().relative_to(root.resolve()).as_posix()
except ValueError:
return path.resolve().as_posix()
def _count_tables(lines: list[str]) -> int:
count = 0
in_table = False
separator_re = re.compile(
r"^\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)+\|?$"
)
for line in lines:
stripped = line.strip()
is_table_line = stripped.startswith("|") and stripped.endswith("|")
has_separator = bool(separator_re.match(stripped))
if is_table_line or has_separator:
if not in_table:
count += 1
in_table = True
continue
in_table = False
return count
def markdown_stats(markdown_path: Path) -> dict[str, int]:
"""Return low-cost Markdown structure counts for inspection/debugging."""
if not markdown_path.is_file():
return {
"line_count": 0,
"char_count": 0,
"heading_count": 0,
"table_count": 0,
"image_ref_count": 0,
"link_count": 0,
}
text = markdown_path.read_text(encoding="utf-8", errors="replace")
lines = text.splitlines()
return {
"line_count": len(lines),
"char_count": len(text),
"heading_count": sum(1 for line in lines if re.match(r"^#{1,6}\s+", line)),
"table_count": _count_tables(lines),
"image_ref_count": len(re.findall(r"!\[[^\]]*\]\([^)]+\)", text)),
"link_count": len(re.findall(r"(?<!!)\[[^\]]+\]\([^)]+\)", text)),
}
def _read_json(path: Path) -> Any:
try:
return json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return None
def _image_count_from_manifest(path: Path) -> int:
payload = _read_json(path)
if isinstance(payload, list):
return len(payload)
if isinstance(payload, dict):
items = payload.get("items")
if isinstance(items, list):
return len(items)
return 0
def build_conversion_profile(
*,
input_path: str,
markdown_path: str | Path,
converter: str,
conversion_type: str,
asset_dir: str | Path | None = None,
warnings: list[str] | None = None,
) -> dict[str, Any]:
"""Build a sidecar profile without changing the Markdown conversion result."""
markdown = Path(markdown_path)
root = markdown.parent
is_url = input_path.startswith(("http://", "https://"))
source = None if is_url else Path(input_path)
assets = Path(asset_dir) if asset_dir else default_asset_dir(markdown)
image_manifest = assets / IMAGE_MANIFEST_NAME
source_exists = bool(source and source.exists())
return {
"schema": PROFILE_SCHEMA,
"converter": converter,
"conversion_type": conversion_type,
"source": {
"path": input_path if is_url else _display_path(source, root),
"name": input_path if is_url else (source.name if source else input_path),
"suffix": "" if is_url else (source.suffix.lower() if source else ""),
"kind": "url" if is_url else "file",
"exists": source_exists,
"size_bytes": (
source.stat().st_size
if source_exists and source is not None and source.is_file()
else None
),
},
"outputs": {
"markdown": _display_path(markdown, root),
"asset_dir": _display_path(assets, root) if assets.is_dir() else "",
"image_manifest": (
_display_path(image_manifest, root) if image_manifest.is_file() else ""
),
"image_count": _image_count_from_manifest(image_manifest),
},
"markdown": markdown_stats(markdown),
"warnings": warnings or [],
}
def write_conversion_profile(
*,
input_path: str,
markdown_path: str | Path,
converter: str,
conversion_type: str,
asset_dir: str | Path | None = None,
warnings: list[str] | None = None,
) -> Path:
"""Write `<stem>.conversion_profile.json` beside one Markdown output."""
markdown = Path(markdown_path)
profile_path = profile_path_for(markdown)
profile = build_conversion_profile(
input_path=input_path,
markdown_path=markdown,
converter=converter,
conversion_type=conversion_type,
asset_dir=asset_dir,
warnings=warnings,
)
profile_path.write_text(
json.dumps(profile, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
return profile_path
def write_conversion_profile_best_effort(
*,
input_path: str,
markdown_path: str | Path,
converter: str,
conversion_type: str,
asset_dir: str | Path | None = None,
warnings: list[str] | None = None,
) -> Path | None:
"""Write a profile sidecar, warning without changing converter success."""
try:
return write_conversion_profile(
input_path=input_path,
markdown_path=markdown_path,
converter=converter,
conversion_type=conversion_type,
asset_dir=asset_dir,
warnings=warnings,
)
except OSError as exc:
print(f"[WARN] Could not write conversion profile: {exc}", file=sys.stderr)
return None
def build_result_payload(
*,
input_path: str,
markdown_path: str | Path,
converter: str,
conversion_type: str,
asset_dir: str | Path | None = None,
profile_path: str | Path | None = None,
) -> dict[str, Any]:
"""Return a compact JSON payload for CLI consumers."""
markdown = Path(markdown_path)
assets = Path(asset_dir) if asset_dir else default_asset_dir(markdown)
image_manifest = assets / IMAGE_MANIFEST_NAME
profile = Path(profile_path) if profile_path else profile_path_for(markdown)
return {
"input": (
input_path
if input_path.startswith(("http://", "https://"))
else str(Path(input_path).resolve())
),
"markdown": str(markdown.resolve()),
"asset_dir": str(assets.resolve()) if assets.is_dir() else "",
"image_manifest": str(image_manifest.resolve()) if image_manifest.is_file() else "",
"conversion_profile": str(profile.resolve()) if profile.is_file() else "",
"converter": converter,
"conversion_type": conversion_type,
}
@@ -0,0 +1,187 @@
#!/usr/bin/env python3
"""
PPT Master - Source to Markdown Dispatcher
Shared routing and backend command construction for source-to-Markdown tools.
Usage:
Imported by scripts/source_to_md.py and scripts/project_manager.py
Examples:
build_conversion_command("report.pdf", "report.md")
Dependencies:
None
"""
from __future__ import annotations
import shutil
import sys
from dataclasses import dataclass
from pathlib import Path
from urllib.parse import urlparse
SOURCE_TO_MD_DIR = Path(__file__).resolve().parent
WECHAT_HOST_KEYWORDS = ("mp.weixin.qq.com", "weixin.qq.com")
DOC_SUFFIXES = {
".docx", ".doc", ".odt", ".rtf", # Office documents
".epub", # eBooks
".html", ".htm", # Web pages
".tex", ".latex", ".rst", ".org", # Academic / technical
".ipynb", ".typ", # Notebooks / Typst
}
EXCEL_SUFFIXES = {".xlsx", ".xlsm"}
LEGACY_EXCEL_SUFFIXES = {".xls"}
MARKDOWN_SUFFIXES = {".md", ".markdown"}
PDF_SUFFIXES = {".pdf"}
PRESENTATION_SUFFIXES = {".pptx", ".pptm", ".ppsx", ".ppsm", ".potx", ".potm"}
TEXT_SUFFIXES = {".txt", ".text"}
@dataclass
class ConversionCommand:
"""Backend command plus metadata for one conversion route."""
command: list[str]
script_name: str
conversion_type: str
output_path: Path | None
def is_url(value: str) -> bool:
"""Return whether a string looks like an HTTP(S) URL."""
parsed = urlparse(value)
return parsed.scheme in {"http", "https"} and bool(parsed.netloc)
def detect_source_type(input_arg: str) -> str:
"""Detect a conversion type from a URL, file, or directory."""
if is_url(input_arg):
return "web"
path = Path(input_arg)
if not path.exists():
return "unknown"
if path.is_dir():
return "directory"
suffix = path.suffix.lower()
if suffix in PDF_SUFFIXES:
return "pdf"
if suffix in DOC_SUFFIXES:
return "doc"
if suffix in EXCEL_SUFFIXES or suffix in LEGACY_EXCEL_SUFFIXES:
return "excel"
if suffix in PRESENTATION_SUFFIXES:
return "pptx"
if suffix in MARKDOWN_SUFFIXES:
return "markdown"
if suffix in TEXT_SUFFIXES:
return "text"
return "unknown"
def default_markdown_path(input_arg: str) -> Path:
"""Return the conventional Markdown output path for a local input."""
path = Path(input_arg)
return path.parent / f"{path.stem}.md"
def _curl_cffi_available() -> bool:
"""Return whether curl_cffi is importable."""
try:
import curl_cffi # noqa: F401
return True
except ImportError:
return False
def _web_script_command(
url: str,
output_path: Path | None,
python_executable: str,
allow_node_fallback: bool,
) -> tuple[str, list[str]]:
"""Return the web backend script name and base command."""
host = urlparse(url).netloc.lower()
is_tls_sensitive = any(keyword in host for keyword in WECHAT_HOST_KEYWORDS)
node_backend = SOURCE_TO_MD_DIR / "web_to_md.cjs"
if (
allow_node_fallback
and is_tls_sensitive
and not _curl_cffi_available()
and node_backend.is_file()
and shutil.which("node")
):
command = ["node", str(node_backend), url]
script_name = "web_to_md.cjs"
else:
command = [python_executable, str(SOURCE_TO_MD_DIR / "web_to_md.py"), url]
script_name = "web_to_md.py"
if output_path is not None:
command.extend(["-o", str(output_path)])
return script_name, command
def build_conversion_command(
input_arg: str,
output_path: str | Path | None,
*,
forced_type: str | None = None,
extra_args: list[str] | None = None,
pdf_image_mode: str | None = None,
render_vector_figures: bool = False,
python_executable: str | None = None,
allow_node_web_fallback: bool = True,
) -> ConversionCommand:
"""Build the backend CLI command for one source-to-Markdown conversion."""
conversion_type = forced_type or detect_source_type(input_arg)
output = Path(output_path) if output_path is not None else None
extra = extra_args or []
python = python_executable or sys.executable
if conversion_type == "web":
if not is_url(input_arg):
raise ValueError("web conversion requires an http:// or https:// URL")
script_name, command = _web_script_command(
input_arg,
output,
python,
allow_node_web_fallback,
)
command.extend(extra)
return ConversionCommand(command, script_name, conversion_type, output)
if conversion_type in {"markdown", "text", "directory", "unknown"}:
raise ValueError(f"conversion type {conversion_type!r} has no backend command")
script_by_type = {
"pdf": "pdf_to_md.py",
"doc": "doc_to_md.py",
"excel": "excel_to_md.py",
"pptx": "ppt_to_md.py",
}
script_name = script_by_type.get(conversion_type)
if script_name is None:
raise ValueError(f"unsupported conversion type: {conversion_type}")
if output is None:
output = default_markdown_path(input_arg)
command = [
python,
str(SOURCE_TO_MD_DIR / script_name),
input_arg,
"-o",
str(output),
]
if conversion_type == "pdf" and pdf_image_mode:
command.extend(["--images", pdf_image_mode])
if conversion_type == "pdf" and render_vector_figures:
command.append("--render-vector-figures")
command.extend(extra)
return ConversionCommand(command, script_name, conversion_type, output)
@@ -3,7 +3,7 @@
Document to Markdown Converter (hybrid Python + Pandoc fallback)
Primary formats (pure Python, no external tools required):
.docx mammoth (OMML/Office Math equations rewritten to inline LaTeX)
.docx mammoth (tables preserved; OMML equations rewritten to inline LaTeX)
.html markdownify + BeautifulSoup
.epub ebooklib + markdownify
.ipynb nbconvert
@@ -38,6 +38,8 @@ if str(_SCRIPTS_DIR) not in sys.path:
sys.path.insert(0, str(_SCRIPTS_DIR))
from console_encoding import configure_utf8_stdio # noqa: E402
from _batch import run_path_batch # noqa: E402
from _conversion_profile import write_conversion_profile_best_effort # noqa: E402
configure_utf8_stdio()
@@ -614,6 +616,13 @@ def _make_text_run(text: str) -> ET.Element:
return run
def _make_text_paragraph(text: str) -> ET.Element:
"""Build a simple Word paragraph containing text."""
paragraph = ET.Element(f"{{{W_NS}}}p")
paragraph.append(_make_text_run(text))
return paragraph
def _docx_inject_math_latex(
input_file: Path,
) -> tuple[Path, dict[str, str]] | None:
@@ -680,6 +689,134 @@ def _docx_inject_math_latex(
return out_path, replacements
# ─────────────────────────────────────────────────────────────
# DOCX tables → pipe Markdown
# ─────────────────────────────────────────────────────────────
def _docx_paragraph_text(paragraph: ET.Element) -> str:
"""Extract readable text from one Word paragraph."""
parts: list[str] = []
for elem in paragraph.iter():
local = _local_name(elem)
if local == "t":
parts.append(elem.text or "")
elif local == "tab":
parts.append("\t")
elif local in {"br", "cr"}:
parts.append(" ")
return "".join(parts).strip()
def _docx_table_has_media(table: ET.Element) -> bool:
"""Return whether a table contains image-bearing nodes."""
return any(_local_name(elem) in {"drawing", "imagedata"} for elem in table.iter())
def _docx_table_cell_text(cell: ET.Element) -> str:
"""Extract table-cell text, preserving paragraph breaks as Markdown breaks."""
paragraphs: list[str] = []
for child in cell:
if _local_name(child) == "p":
text = _docx_paragraph_text(child)
if text:
paragraphs.append(text)
return "<br>".join(paragraphs)
def _markdown_table_cell(text: str) -> str:
"""Escape Markdown table delimiters in one cell."""
text = re.sub(r"[ \t\r\n]+", " ", text).strip()
return text.replace("|", r"\|")
def _docx_table_to_markdown(table: ET.Element) -> str:
"""Convert a Word table XML node to a pipe Markdown table."""
rows: list[list[str]] = []
for row in table.findall("w:tr", DOCX_NS):
cells = [
_markdown_table_cell(_docx_table_cell_text(cell))
for cell in row.findall("w:tc", DOCX_NS)
]
if cells:
rows.append(cells)
if not rows:
return ""
width = max(len(row) for row in rows)
rows = [row + [""] * (width - len(row)) for row in rows]
header, body = rows[0], rows[1:]
lines = [
"| " + " | ".join(header) + " |",
"| " + " | ".join("---" for _ in range(width)) + " |",
]
lines.extend("| " + " | ".join(row) + " |" for row in body)
return "\n".join(lines)
def _docx_inject_tables_markdown(
input_file: Path,
) -> tuple[Path, dict[str, str]] | None:
"""Replace text-only DOCX tables with Markdown placeholders in a temp DOCX."""
try:
with zipfile.ZipFile(input_file) as docx:
document_xml = docx.read("word/document.xml")
except (KeyError, zipfile.BadZipFile, OSError):
return None
try:
root = ET.fromstring(document_xml)
except ET.ParseError:
return None
parent_map = {child: parent for parent in root.iter() for child in parent}
token_base = uuid.uuid4().hex
replacements: dict[str, str] = {}
for index, table in enumerate(root.findall(".//w:tbl", DOCX_NS)):
if _docx_table_has_media(table):
continue
markdown = _docx_table_to_markdown(table)
if not markdown:
continue
parent = parent_map.get(table)
if parent is None:
continue
position = list(parent).index(table)
token = f"MARKDOWNTABLE{token_base}{index:04d}"
parent.remove(table)
parent.insert(position, _make_text_paragraph(token))
replacements[token] = markdown
if not replacements:
return None
for prefix, uri in DOCX_NS.items():
if prefix != "rel":
ET.register_namespace(prefix, uri)
patched_xml = ET.tostring(root, encoding="utf-8", xml_declaration=True)
tmp = tempfile.NamedTemporaryFile(suffix=".docx", delete=False)
tmp.close()
out_path = Path(tmp.name)
with zipfile.ZipFile(input_file) as zin, \
zipfile.ZipFile(out_path, "w", zipfile.ZIP_DEFLATED) as zout:
for item in zin.infolist():
data = patched_xml if item.filename == "word/document.xml" else zin.read(item.filename)
zout.writestr(item, data)
return out_path, replacements
def _clean_mammoth_markdown(markdown: str) -> str:
"""Remove Mammoth escapes for punctuation that is safe as literal text."""
def _repl(match: re.Match[str]) -> str:
char = match.group(1)
if char == ".":
line_start = markdown.rfind("\n", 0, match.start()) + 1
line_prefix = markdown[line_start:match.start()]
if re.fullmatch(r"\s*\d+", line_prefix):
return match.group(0)
return char
return re.sub(r"\\([.(),:])", _repl, markdown)
# ─────────────────────────────────────────────────────────────
# DOCX → Markdown (mammoth)
# ─────────────────────────────────────────────────────────────
@@ -743,7 +880,13 @@ def _convert_docx(input_file: Path, out_file: Path) -> str:
math_file, math_replacements = math_injection
else:
math_file, math_replacements = None, {}
table_file = None
table_replacements: dict[str, str] = {}
mammoth_source = math_file or input_file
table_injection = _docx_inject_tables_markdown(mammoth_source)
if table_injection is not None:
table_file, table_replacements = table_injection
mammoth_source = table_file
try:
with mammoth_source.open("rb") as f:
result = mammoth.convert_to_markdown(
@@ -751,6 +894,11 @@ def _convert_docx(input_file: Path, out_file: Path) -> str:
convert_image=mammoth.images.img_element(_save_image),
)
finally:
if table_file is not None:
try:
table_file.unlink()
except OSError:
pass
if math_file is not None:
try:
math_file.unlink()
@@ -758,9 +906,12 @@ def _convert_docx(input_file: Path, out_file: Path) -> str:
pass
markdown = result.value
for token, table_markdown in table_replacements.items():
markdown = markdown.replace(token, table_markdown)
for token, latex in math_replacements.items():
markdown = markdown.replace(token, latex)
markdown = _html_img_to_md(markdown)
markdown = _clean_mammoth_markdown(markdown)
out_file.write_text(markdown, encoding="utf-8")
if manifest:
@@ -1263,20 +1414,42 @@ def convert_to_markdown(input_path: str, output_path: str | None = None) -> str:
desc = _FORMAT_DESC[suffix]
print(f"[INFO] Converting {desc}: {input_file.name}")
if suffix == ".docx":
return _convert_docx(input_file, out_file)
if suffix in (".html", ".htm"):
return _convert_html(input_file, out_file)
if suffix == ".epub":
return _convert_epub(input_file, out_file)
if suffix == ".ipynb":
return _convert_ipynb(input_file, out_file)
markdown = _convert_docx(input_file, out_file)
elif suffix in (".html", ".htm"):
markdown = _convert_html(input_file, out_file)
elif suffix == ".epub":
markdown = _convert_epub(input_file, out_file)
elif suffix == ".ipynb":
markdown = _convert_ipynb(input_file, out_file)
else:
markdown = ""
if markdown:
profile_path = write_conversion_profile_best_effort(
input_path=str(input_file),
markdown_path=out_file,
converter="doc_to_md.py",
conversion_type=suffix.lstrip("."),
)
if profile_path:
print(f" Wrote conversion profile -> {profile_path}")
return markdown
_, format_desc = PANDOC_FORMATS[suffix]
print(f"[INFO] Converting {format_desc} via pandoc: {input_file.name}")
return _convert_with_pandoc(input_file, out_file, suffix)
markdown = _convert_with_pandoc(input_file, out_file, suffix)
if markdown:
profile_path = write_conversion_profile_best_effort(
input_path=str(input_file),
markdown_path=out_file,
converter="doc_to_md.py",
conversion_type=suffix.lstrip("."),
)
if profile_path:
print(f" Wrote conversion profile -> {profile_path}")
return markdown
def main() -> None:
def main() -> int:
parser = argparse.ArgumentParser(
description="Convert documents to Markdown "
"(pure-Python for common formats, pandoc fallback for the rest)",
@@ -1284,6 +1457,8 @@ def main() -> None:
epilog="""
Examples:
python doc_to_md.py lecture.docx # Word → Markdown (mammoth)
python doc_to_md.py lecture.docx notes.html # Convert multiple files
python doc_to_md.py ./docs -o ./markdown # Convert supported files in a directory
python doc_to_md.py article.html # HTML → Markdown (markdownify)
python doc_to_md.py book.epub # EPUB → Markdown (ebooklib)
python doc_to_md.py notebook.ipynb # Jupyter → Markdown (nbconvert)
@@ -1296,13 +1471,22 @@ Pandoc fallback formats (require system pandoc):
.doc .odt .rtf .tex/.latex .rst .org .typ
""",
)
parser.add_argument("input", help="Input document file")
parser.add_argument("-o", "--output", help="Output Markdown file path")
parser.add_argument("inputs", nargs="+", help="Input document file(s) or directories")
parser.add_argument(
"-o",
"--output",
help="Output Markdown file for one input, or output directory for multiple inputs/directories",
)
args = parser.parse_args()
result = convert_to_markdown(args.input, args.output)
sys.exit(0 if result else 1)
supported_suffixes = set(NATIVE_FORMATS) | set(PANDOC_FORMATS)
return run_path_batch(
args.inputs,
supported_suffixes,
args.output,
lambda source, output: bool(convert_to_markdown(str(source), str(output))),
)
if __name__ == "__main__":
main()
raise SystemExit(main())
@@ -25,6 +25,8 @@ if str(_SCRIPTS_DIR) not in sys.path:
sys.path.insert(0, str(_SCRIPTS_DIR))
from console_encoding import configure_utf8_stdio # noqa: E402
from _batch import run_path_batch # noqa: E402
from _conversion_profile import write_conversion_profile_best_effort # noqa: E402
configure_utf8_stdio()
@@ -337,16 +339,28 @@ def convert_to_markdown(
out_file.parent.mkdir(parents=True, exist_ok=True)
print(f"[INFO] Converting Excel workbook: {input_file.name}")
return _convert_excel(input_file, out_file, max_rows=max_rows, max_cols=max_cols)
markdown = _convert_excel(input_file, out_file, max_rows=max_rows, max_cols=max_cols)
if markdown:
profile_path = write_conversion_profile_best_effort(
input_path=str(input_file),
markdown_path=out_file,
converter="excel_to_md.py",
conversion_type=suffix.lstrip("."),
)
if profile_path:
print(f" Wrote conversion profile -> {profile_path}")
return markdown
def main() -> None:
def main() -> int:
parser = argparse.ArgumentParser(
description="Convert Excel workbooks to Markdown",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
python excel_to_md.py report.xlsx
python excel_to_md.py report.xlsx budget.xlsm
python excel_to_md.py ./workbooks -o ./markdown
python excel_to_md.py report.xlsx -o output.md
python excel_to_md.py report.xlsm --max-rows 200 --max-cols 40
@@ -357,8 +371,12 @@ Unsupported by default:
.xls Resave as .xlsx first
""",
)
parser.add_argument("input", help="Input Excel workbook")
parser.add_argument("-o", "--output", help="Output Markdown file path")
parser.add_argument("inputs", nargs="+", help="Input Excel workbook(s) or directories")
parser.add_argument(
"-o",
"--output",
help="Output Markdown file for one input, or output directory for multiple inputs/directories",
)
parser.add_argument(
"--max-rows",
type=int,
@@ -373,14 +391,20 @@ Unsupported by default:
)
args = parser.parse_args()
result = convert_to_markdown(
args.input,
return run_path_batch(
args.inputs,
EXCEL_FORMATS | LEGACY_EXCEL_FORMATS,
args.output,
max_rows=args.max_rows,
max_cols=args.max_cols,
lambda source, output: bool(
convert_to_markdown(
str(source),
str(output),
max_rows=args.max_rows,
max_cols=args.max_cols,
)
),
)
sys.exit(0 if result else 1)
if __name__ == "__main__":
main()
raise SystemExit(main())
@@ -19,6 +19,8 @@ if str(_SCRIPTS_DIR) not in sys.path:
sys.path.insert(0, str(_SCRIPTS_DIR))
from console_encoding import configure_utf8_stdio # noqa: E402
from _batch import run_path_batch # noqa: E402
from _conversion_profile import write_conversion_profile_best_effort # noqa: E402
configure_utf8_stdio()
@@ -1059,50 +1061,20 @@ def extract_pdf_to_markdown(
json.dumps(image_manifest, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
profile_path = write_conversion_profile_best_effort(
input_path=pdf_path,
markdown_path=output_path,
converter="pdf_to_md.py",
conversion_type="pdf",
asset_dir=img_dir,
)
print(f"[OK] Saved Markdown to: {output_path}")
if profile_path:
print(f" Wrote conversion profile -> {profile_path}")
return markdown_content
def process_directory(
input_dir: str,
output_dir: str | None = None,
images: str = "filtered",
render_vector_figures: bool = False,
vector_figure_dpi: int = VECTOR_FIGURE_DPI,
) -> None:
"""Convert all PDFs in a directory to Markdown.
Args:
input_dir: Directory containing PDF files.
output_dir: Optional output directory for Markdown files.
images: Image extraction mode passed through to each file conversion.
render_vector_figures: Rasterize large vector drawing regions as PNGs.
vector_figure_dpi: DPI used for rendered vector figure PNGs.
"""
input_path = Path(input_dir)
if output_dir:
output_path = Path(output_dir)
else:
output_path = input_path
pdf_files = sorted(input_path.glob('*.pdf'))
print(f"Found {len(pdf_files)} PDF files")
for pdf_file in pdf_files:
output_file = output_path / (pdf_file.stem + '.md')
print(f"Processing: {pdf_file.name}")
extract_pdf_to_markdown(
str(pdf_file),
str(output_file),
images=images,
render_vector_figures=render_vector_figures,
vector_figure_dpi=vector_figure_dpi,
)
def main() -> int:
"""Run the CLI entry point."""
parser = argparse.ArgumentParser(
@@ -1111,10 +1083,10 @@ def main() -> int:
epilog='''
Examples:
python pdf_to_md.py book.pdf # Convert a single file
python pdf_to_md.py book.pdf appendix.pdf # Convert multiple files
python pdf_to_md.py ./pdfs -o ./markdown # Convert PDFs in a directory
python pdf_to_md.py book.pdf -o output.md # Specify output file
python pdf_to_md.py book.pdf --render-vector-figures
python pdf_to_md.py ./pdfs # Convert all PDFs in directory
python pdf_to_md.py ./pdfs -o ./markdown # Specify output directory
Structure detection features:
- Auto-detect heading levels (based on font size)
@@ -1126,8 +1098,12 @@ Structure detection features:
'''
)
parser.add_argument('input', help='PDF file or directory containing PDFs')
parser.add_argument('-o', '--output', help='Output file or directory')
parser.add_argument('inputs', nargs='+', help='PDF file(s) or directories')
parser.add_argument(
'-o',
'--output',
help='Output Markdown file for one input, or output directory for multiple inputs/directories',
)
parser.add_argument(
'--images',
choices=['all', 'filtered', 'none'],
@@ -1148,31 +1124,21 @@ Structure detection features:
args = parser.parse_args()
input_path = Path(args.input)
if input_path.is_file():
output = args.output or str(input_path.with_suffix('.md'))
extract_pdf_to_markdown(
str(input_path),
output,
images=args.images,
render_vector_figures=args.render_vector_figures,
vector_figure_dpi=args.vector_figure_dpi,
)
elif input_path.is_dir():
process_directory(
str(input_path),
args.output,
images=args.images,
render_vector_figures=args.render_vector_figures,
vector_figure_dpi=args.vector_figure_dpi,
)
else:
print(f"Error: File or directory not found: {args.input}")
return 1
return 0
return run_path_batch(
args.inputs,
{'.pdf'},
args.output,
lambda source, output: bool(
extract_pdf_to_markdown(
str(source),
str(output),
images=args.images,
render_vector_figures=args.render_vector_figures,
vector_figure_dpi=args.vector_figure_dpi,
)
),
)
if __name__ == '__main__':
exit(main())
raise SystemExit(main())
@@ -39,6 +39,8 @@ if str(_SCRIPTS_DIR) not in sys.path:
sys.path.insert(0, str(_SCRIPTS_DIR))
from console_encoding import configure_utf8_stdio # noqa: E402
from _batch import run_path_batch # noqa: E402
from _conversion_profile import write_conversion_profile_best_effort # noqa: E402
from pptx import Presentation
from pptx.enum.action import PP_ACTION
@@ -814,8 +816,17 @@ def convert_presentation_to_markdown(
json.dumps(image_manifest, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
profile_path = write_conversion_profile_best_effort(
input_path=str(input_file),
markdown_path=out_file,
converter="ppt_to_md.py",
conversion_type=suffix.lstrip("."),
asset_dir=asset_dir,
)
print(f"[OK] Saved Markdown to: {out_file}")
if profile_path:
print(f" Wrote conversion profile -> {profile_path}")
if asset_dir_used:
media_files = [
path for path in asset_dir.iterdir()
@@ -832,31 +843,7 @@ def convert_presentation_to_markdown(
return markdown_content
def process_directory(input_dir: str, output_dir: str | None = None) -> None:
"""Convert all supported PowerPoint files in a directory to Markdown."""
input_path = Path(input_dir)
if output_dir:
output_path = Path(output_dir)
else:
output_path = input_path
presentation_files = sorted(
path for path in input_path.iterdir()
if path.is_file() and path.suffix.lower() in SUPPORTED_FORMATS
)
print(f"Found {len(presentation_files)} PowerPoint files")
for presentation_file in presentation_files:
output_file = output_path / f"{presentation_file.stem}.md"
print(f"Processing: {presentation_file.name}")
result = convert_presentation_to_markdown(str(presentation_file), str(output_file))
if not result:
print(f"[WARN] Skipped failed file: {presentation_file.name}")
def main() -> None:
def main() -> int:
"""Run the CLI entry point."""
parser = argparse.ArgumentParser(
description="Convert PowerPoint files to Markdown",
@@ -864,9 +851,9 @@ def main() -> None:
epilog="""
Examples:
python ppt_to_md.py slides.pptx
python ppt_to_md.py slides.pptx -o output.md
python ppt_to_md.py ./decks
python ppt_to_md.py slides.pptx appendix.pptx
python ppt_to_md.py ./decks -o ./markdown
python ppt_to_md.py slides.pptx -o output.md
python ppt_to_md.py deck.ppsx -o notes/deck.md
Supported formats:
@@ -875,23 +862,22 @@ Supported formats:
Legacy .ppt is not parsed directly. Resave it as .pptx or export it to PDF first.
""",
)
parser.add_argument("input", help="Input PowerPoint file or directory")
parser.add_argument("-o", "--output", help="Output Markdown file or directory path")
parser.add_argument("inputs", nargs="+", help="Input PowerPoint file(s) or directories")
parser.add_argument(
"-o",
"--output",
help="Output Markdown file for one input, or output directory for multiple inputs/directories",
)
args = parser.parse_args()
input_path = Path(args.input)
if input_path.is_file():
output = args.output or str(input_path.with_suffix(".md"))
result = convert_presentation_to_markdown(str(input_path), output)
sys.exit(0 if result else 1)
if input_path.is_dir():
process_directory(str(input_path), args.output)
sys.exit(0)
print(f"Error: File or directory not found: {args.input}")
sys.exit(1)
return run_path_batch(
args.inputs,
set(SUPPORTED_FORMATS),
args.output,
lambda source, output: bool(convert_presentation_to_markdown(str(source), str(output))),
)
if __name__ == "__main__":
main()
raise SystemExit(main())
@@ -43,6 +43,10 @@ if str(_SCRIPTS_DIR) not in sys.path:
sys.path.insert(0, str(_SCRIPTS_DIR))
from console_encoding import configure_utf8_stdio # noqa: E402
from _conversion_profile import ( # noqa: E402
profile_path_for,
write_conversion_profile_best_effort,
)
configure_utf8_stdio()
@@ -804,8 +808,13 @@ def simple_html_to_markdown_traversal(soup: Tag | BeautifulSoup | None) -> str:
return md or ""
def process_url(url: str, output_file: str | None = None) -> tuple[bool, str, str | None]:
"""Fetch, convert, and save one web page as Markdown."""
def process_url(url: str, output_file: str | None = None) -> tuple[bool, str, str | None, str | None]:
"""Fetch, convert, and save one web page as Markdown.
Returns (success, url, error, output_path). output_path is the actual saved
Markdown path (derived from the article title when no output_file is given),
so a caller can locate a title-named file it did not choose upfront.
"""
print(f"\n[Fetching] {url}")
try:
html = fetch_url(url)
@@ -869,13 +878,38 @@ def process_url(url: str, output_file: str | None = None) -> tuple[bool, str, st
with open(output_path, 'w', encoding='utf-8') as f:
f.write(full_content)
profile_path = write_conversion_profile_best_effort(
input_path=url,
markdown_path=output_path,
converter="web_to_md.py",
conversion_type="web",
asset_dir=image_dir,
)
print(f" [OK] Saved: {output_path}")
return True, url, None
if profile_path:
print(f" [OK] Conversion profile: {profile_path}")
return True, url, None, output_path
except Exception as e:
print(f" [ERROR] {str(e)}")
return False, url, str(e)
return False, url, str(e), None
def _write_emit_result(result_file: str, url: str, markdown_path: str) -> None:
"""Write the actual saved path as JSON so a caller can locate the output."""
md = Path(markdown_path).resolve()
profile = profile_path_for(md)
payload = {
"input": url,
"markdown": str(md),
"conversion_profile": str(profile) if profile.is_file() else "",
}
try:
Path(result_file).write_text(
json.dumps(payload, ensure_ascii=False), encoding="utf-8")
except OSError as exc:
print(f" [WARN] Could not write --emit-result: {exc}")
def main() -> None:
@@ -887,6 +921,10 @@ def main() -> None:
"-f", "--file", help="File containing URLs (one per line)")
parser.add_argument("-o", "--output", help="Output file (single URL only)")
parser.add_argument("-d", "--dir", help="Output directory")
parser.add_argument(
"--emit-result",
help="On success, write the saved output path as JSON to this file "
"(single-URL dispatcher use, so a title-named file can be located)")
args = parser.parse_args()
@@ -914,8 +952,10 @@ def main() -> None:
for i, url in enumerate(targets):
# Allow specific output file only if 1 URL
out = args.output if (len(targets) == 1 and args.output) else None
success, url, err = process_url(url, out)
success, url, err, out_path = process_url(url, out)
results.append((success, url, err))
if args.emit_result and success and out_path:
_write_emit_result(args.emit_result, url, out_path)
# Summary
success_count = sum(1 for r in results if r[0])
@@ -62,7 +62,6 @@
overlap_caption: "Overlapping here — pick one",
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_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: ",
@@ -83,6 +82,81 @@
nav_counter: "{current} / {total}",
nav_empty: "— / —"
},
ja: {
page_title: "PPT Master - ライブプレビュー",
panel_slides: "スライド",
panel_annotations: "注釈",
panel_edit_annotate: "編集 / 注釈",
placeholder_select_slide: "左のスライドを選択して開始",
label_selected_element: "選択中の要素",
empty_selected_element: "スライド上の要素をクリックして選択",
btn_select_group: "親グループを選択",
label_batch_edit: "一括編集",
label_group_edit: "グループ編集",
section_geometry: "位置・サイズ",
section_style: "スタイル",
section_text_style: "テキスト",
section_raw_attrs: "生の属性",
label_edit_instruction: "編集指示",
pending_none: "未適用の変更はありません",
pending_summary: "直接編集{edits}件、AI注釈のあるページ{annotations}件が未適用",
pending_pages: "対象ページ:{pages}",
quick_align: "整列 / 移動",
quick_resize: "サイズ変更",
quick_replace_image: "画像を差し替え",
quick_copy: "文言を修正",
quick_relayout: "この領域を再レイアウト",
placeholder_annotation: "この要素をAIにどう修正してほしいか記述…",
placeholder_annotation_multi: "選択した{count}個の要素をどう修正するか記述…",
btn_add_annotation: "注釈を追加",
label_annotations_on_slide: "このスライドの注釈",
btn_submit_annotations: "変更を適用",
btn_exit_preview: "プレビューを終了",
modal_submit: "送信",
modal_cancel: "キャンセル",
empty_waiting_slides: "スライドの生成を待っています…",
empty_no_slides: "スライドが見つかりません",
placeholder_live_ready: "ライブプレビュー準備完了。生成されたスライドがここに表示されます。",
placeholder_slide_writing: "スライドはまだ書き込み中です。次の更新を待っています…",
empty_annotations: "注釈はまだありません",
tooltip_remove_annotation: "注釈を削除",
multi_selected: "{count}個の要素を選択中",
multi_mixed: "混在",
err_load_slides: "スライド一覧の読み込みに失敗: ",
err_load_slide: "スライドの読み込みに失敗: ",
err_add_annotation: "注釈の追加に失敗: ",
err_remove_annotation: "注釈の削除に失敗: ",
err_save: "保存に失敗: ",
err_edit: "編集に失敗: ",
label_direct_edit: "オブジェクト属性(「変更を適用」までは保留)",
prop_multiline_hint: "複数行テキストです — 文字を編集するには1行(tspan)を選択してください",
edit_saved_hint: "変更を一時保存しました。「変更を適用」で svg_output に書き込まれます。",
btn_undo: "元に戻す",
undo_done: "直前の一時保存済み編集を取り消しました",
undo_empty: "取り消せる編集はありません",
overlap_caption: "要素が重なっています — 1つ選択してください",
err_empty_svg: "スライドは読み込めましたがキャンバスが空です。SVGが不正か、ルート<svg>要素がない可能性があります。",
warn_icon_inline: "{count}個のアイコンを描画できませんでした: {names}",
warn_matrix_transform: "このジオメトリ編集は transform matrix として保存されます。プレビューは正確ですが、PPTX出力にはmatrix対応の現行エクスポーターが必要です。",
modal_matrix_transform_note: "\n\n注意:一時保存済みのジオメトリ編集に transform matrix を使うものがあります。matrixが反映されるよう、現行のPPTXエクスポーターで再エクスポートしてください。",
slide_error_tooltip: "このスライドの解析に失敗: ",
reload_banner: "このスライドはディスク上で更新されました。クリックで再読み込み。",
modal_confirm_submit: "一時保存済みの直接編集とAI注釈をディスクに書き込みますか?\n\nプレビューサービスは動き続けます。止めたいときは「プレビューを終了」を押してください。",
modal_success_submit: "変更を svg_output に保存しました。\n\nプレビューサービスは引き続き動作中です。",
modal_success_direct_only: "変更を svg_output に保存しました。\n\n直接編集はSVGソースに反映済みです。PPTXを更新したいときは、チャットに戻って再エクスポートを依頼してください。プレビューサービスは引き続き動作中です。",
modal_success_annotations_only: "注釈を svg_output に保存しました。\n\nAIに注釈を解釈・反映させたいときは、チャットに戻って注釈の適用を依頼してください。プレビューサービスは引き続き動作中です。",
modal_success_mixed: "直接編集と注釈を svg_output に保存しました。\n\nチャットに戻ってAI判断が必要な注釈の適用を先に依頼し、確認できたらPPTXを再エクスポートしてください。プレビューサービスは引き続き動作中です。",
modal_confirm_exit: "プレビューを終了してローカルサーバーを停止しますか?\n\n未適用の編集と注釈は破棄されます。",
modal_success_exit: "プレビューを停止しました。\n\nこのタブを閉じてチャットに戻れます。",
modal_stopping: "プレビューサーバーを停止しています…",
lang_toggle_title: "言語を切り替え",
nav_first: "最初のスライド (Home)",
nav_prev: "前のスライド (←)",
nav_next: "次のスライド (→)",
nav_last: "最後のスライド (End)",
nav_counter: "{current} / {total}",
nav_empty: "— / —"
},
zh: {
page_title: "PPT Master - 实时预览",
panel_slides: "幻灯片",
@@ -138,7 +212,6 @@
overlap_caption: "此处重叠元素——点击选择",
err_empty_svg: "幻灯片已加载但画布为空。SVG 可能损坏或缺少根 <svg> 元素。",
warn_icon_inline: "{count} 个图标渲染失败:{names}",
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: "该幻灯片解析失败:",
@@ -164,10 +237,12 @@
var LANG = (function () {
try {
var stored = window.localStorage.getItem("ppt_lang");
if (stored === "zh" || stored === "en") return stored;
if (stored === "zh" || stored === "en" || stored === "ja") return stored;
} catch (e) { /* ignore */ }
var nav = (navigator.language || navigator.userLanguage || "en").toLowerCase();
return nav.indexOf("zh") === 0 ? "zh" : "en";
if (nav.indexOf("zh") === 0) return "zh";
if (nav.indexOf("ja") === 0) return "ja";
return "en";
})();
function t(key, params) {
@@ -184,7 +259,7 @@
}
function applyI18n() {
document.documentElement.setAttribute("lang", LANG === "zh" ? "zh-CN" : "en");
document.documentElement.setAttribute("lang", LANG === "zh" ? "zh-CN" : (LANG === "ja" ? "ja" : "en"));
document.title = t("page_title");
document.querySelectorAll("[data-i18n]").forEach(function (el) {
el.textContent = t(el.getAttribute("data-i18n"));
@@ -198,16 +273,27 @@
updateNavLabel();
}
var LANG_NAMES = { zh: "中文", en: "English", ja: "日本語" };
function refreshLangUI(lang) {
// Custom dropdown (OS-independent): button shows the CURRENT language.
var cur = document.getElementById("lang-current");
if (cur) cur.textContent = LANG_NAMES[lang] || lang;
var btn = document.getElementById("btn-lang-toggle");
if (btn) btn.title = t("lang_toggle_title");
document.querySelectorAll("#lang-menu li").forEach(function (li) {
var selected = li.getAttribute("data-lang") === lang;
li.classList.toggle("selected", selected);
li.setAttribute("aria-selected", selected ? "true" : "false");
});
}
function setLang(lang) {
if (lang !== "zh" && lang !== "en") return;
if (lang !== "zh" && lang !== "en" && lang !== "ja") return;
LANG = lang;
try { window.localStorage.setItem("ppt_lang", lang); } catch (e) { /* ignore */ }
applyI18n();
var toggleBtn = document.getElementById("btn-lang-toggle");
if (toggleBtn) {
toggleBtn.textContent = lang === "zh" ? "EN" : "中";
toggleBtn.title = t("lang_toggle_title");
}
refreshLangUI(lang);
// Re-render dynamic regions so they pick up the new language
updateSelectionPanel();
updateAnnotationList();
@@ -336,7 +422,7 @@
btn.textContent = t(action.key);
btn.addEventListener("click", function () {
var label = t(action.key);
var prefix = LANG === "zh" ? label + "" : label + ": ";
var prefix = (LANG === "zh" || LANG === "ja") ? label + "" : label + ": ";
if (!annotationText.value.trim()) {
annotationText.value = prefix;
} else if (annotationText.value.indexOf(prefix) === -1) {
@@ -510,10 +596,6 @@
// Selecting a slide implicitly dismisses any stale "page updated" banner.
hideReloadBanner();
// Remove any stale spec-violation banner from a previous load.
var oldSpecBanner = document.getElementById("spec-banner");
if (oldSpecBanner) oldSpecBanner.remove();
fetch("/api/slide/" + encodeURIComponent(name))
.then(function (res) { return res.json(); })
.then(function (data) {
@@ -536,16 +618,24 @@
// Empty-canvas guard: surface a clear error if the SVG parsed
// to nothing renderable (issue #115's silent-blank scenario).
var rootSvg = svgContent.querySelector("svg");
// Spec observability: missing width/height → red banner only
if (rootSvg && (!rootSvg.hasAttribute("width") || !rootSvg.hasAttribute("height"))) {
var specBanner = document.createElement("div");
specBanner.id = "spec-banner";
specBanner.style.cssText = "position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);"
+ "background:#fee2e2;color:#b91c1c;border:2px solid #f87171;border-radius:8px;"
+ "padding:24px 36px;font-size:16px;font-weight:bold;text-align:center;z-index:9999;"
+ "width:420px;line-height:1.6;box-shadow:0 4px 12px rgba(0,0,0,0.15);";
specBanner.textContent = t("warn_svg_no_dims");
document.body.appendChild(specBanner);
// viewBox is the PPT Master canvas authority. Normalize the
// preview DOM from it so stale or missing root width/height
// cannot shrink the slide. View-layer only — the file on disk
// is never touched.
if (rootSvg) {
var vb = (rootSvg.getAttribute("viewBox") || "").trim().split(/[\s,]+/);
var vbWidth = Number(vb[2]);
var vbHeight = Number(vb[3]);
if (
vb.length === 4 &&
Number.isFinite(vbWidth) &&
Number.isFinite(vbHeight) &&
vbWidth > 0 &&
vbHeight > 0
) {
rootSvg.setAttribute("width", vb[2]);
rootSvg.setAttribute("height", vb[3]);
}
}
var hasContent = false;
if (rootSvg) {
@@ -2781,11 +2871,73 @@
initAnnotationQuickActions();
updatePendingStatus();
var langToggleBtn = document.getElementById("btn-lang-toggle");
if (langToggleBtn) {
langToggleBtn.textContent = LANG === "zh" ? "EN" : "中";
langToggleBtn.title = t("lang_toggle_title");
langToggleBtn.addEventListener("click", function () {
setLang(LANG === "zh" ? "en" : "zh");
var langMenu = document.getElementById("lang-menu");
if (langToggleBtn && langMenu) {
refreshLangUI(LANG);
var setMenuOpen = function (open) {
langMenu.hidden = !open;
langToggleBtn.setAttribute("aria-expanded", open ? "true" : "false");
if (open) {
var sel = langMenu.querySelector("li.selected") || langMenu.querySelector("li[data-lang]");
if (sel) sel.focus();
}
};
var chooseLang = function (v) {
setMenuOpen(false);
langToggleBtn.focus();
if (v) setLang(v);
};
langToggleBtn.addEventListener("click", function (e) {
e.stopPropagation();
setMenuOpen(langMenu.hidden);
});
langToggleBtn.addEventListener("keydown", function (e) {
if (e.key === "Escape" && !langMenu.hidden) {
e.stopPropagation();
setMenuOpen(false);
} else if ((e.key === "ArrowDown" || e.key === "ArrowUp") && langMenu.hidden) {
e.preventDefault();
e.stopPropagation();
setMenuOpen(true);
}
});
langMenu.addEventListener("click", function (e) {
e.stopPropagation();
var li = e.target && e.target.closest ? e.target.closest("li[data-lang]") : null;
if (li) chooseLang(li.getAttribute("data-lang"));
else setMenuOpen(false);
});
langMenu.addEventListener("keydown", function (e) {
e.stopPropagation(); // keep nudge / slide-nav shortcuts away while the menu is open
var items = Array.prototype.slice.call(langMenu.querySelectorAll("li[data-lang]"));
var idx = items.indexOf(document.activeElement);
if (e.key === "Escape") {
setMenuOpen(false);
langToggleBtn.focus();
} else if (e.key === "ArrowDown") {
e.preventDefault();
(items[idx + 1] || items[0]).focus();
} else if (e.key === "ArrowUp") {
e.preventDefault();
(items[idx - 1] || items[items.length - 1]).focus();
} else if (e.key === "Home") {
e.preventDefault();
items[0].focus();
} else if (e.key === "End") {
e.preventDefault();
items[items.length - 1].focus();
} else if (e.key === "ArrowLeft" || e.key === "ArrowRight") {
e.preventDefault();
} else if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
if (idx >= 0) chooseLang(items[idx].getAttribute("data-lang"));
}
});
langToggleBtn.parentElement.addEventListener("focusout", function (e) {
if (!langMenu.hidden && !langToggleBtn.parentElement.contains(e.relatedTarget)) setMenuOpen(false);
});
document.addEventListener("click", function () {
if (!langMenu.hidden) setMenuOpen(false);
});
}
@@ -30,7 +30,14 @@
</div>
</div>
<div id="panel-right">
<button id="btn-lang-toggle" class="btn-lang-toggle" title="Switch language"></button>
<div class="lang-select">
<button id="btn-lang-toggle" class="lang-select-btn" type="button" title="Switch language" aria-haspopup="listbox" aria-expanded="false"><span id="lang-current"></span><span class="lang-caret" aria-hidden="true"></span></button>
<ul id="lang-menu" class="lang-menu" role="listbox" hidden>
<li role="option" tabindex="0" aria-selected="false" data-lang="zh">中文</li>
<li role="option" tabindex="0" aria-selected="false" data-lang="en">English</li>
<li role="option" tabindex="0" aria-selected="false" data-lang="ja">日本語</li>
</ul>
</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">
@@ -528,29 +528,63 @@ body.svg-dragging * {
flex-shrink: 0;
}
.btn-lang-toggle {
.lang-select {
position: absolute;
top: 10px;
right: 12px;
z-index: 2;
z-index: 5;
}
.lang-select-btn {
display: flex;
align-items: center;
gap: 5px;
min-width: 34px;
padding: 3px 10px;
font-size: 12px;
font-weight: 600;
color: #c0c0d8;
background: transparent;
background: #1e1e3a;
border: 1px solid #3a3a5a;
border-radius: 4px;
cursor: pointer;
transition: background 0.15s, border-color 0.15s, color 0.15s;
}
.btn-lang-toggle:hover {
.lang-select-btn:hover {
color: #ffffff;
background: #2a2a4a;
border-color: #5a5a7a;
}
.lang-caret { font-size: 9px; color: #8a8aa8; }
.lang-menu {
position: absolute;
right: 0;
top: calc(100% + 4px);
margin: 0;
padding: 4px;
list-style: none;
min-width: 96px;
background: #1e1e3a;
border: 1px solid #3a3a5a;
border-radius: 6px;
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.4);
}
.lang-menu li {
padding: 6px 10px;
border-radius: 4px;
font-size: 12px;
color: #c0c0d8;
cursor: pointer;
}
.lang-menu li:hover,
.lang-menu li:focus { background: #2a2a4a; color: #ffffff; }
.lang-menu li.selected { color: #ffffff; font-weight: 600; }
#btn-save {
padding: 11px 0;
font-size: 14px;
@@ -129,6 +129,21 @@ def _resolve_image_path(href: str, svg_dir: Path) -> Path | None:
return candidate if candidate.exists() else None
def _is_svg_image(img_path: Path, raw_bytes: bytes) -> bool:
"""Return True when an image reference is an SVG document."""
if img_path.suffix.lower() == '.svg':
return True
head = raw_bytes.lstrip()[:512].lower()
return head.startswith(b'<svg') or (head.startswith(b'<?xml') and b'<svg' in head)
def _embed_raw_image(image: ET.Element, img_path: Path, raw_bytes: bytes) -> None:
"""Embed raw image bytes without PIL transforms."""
mime_type = get_mime_type(img_path.name, raw_bytes)
b64 = base64.b64encode(raw_bytes).decode('ascii')
_set_href(image, f'data:{mime_type};base64,{b64}')
def _load_pil_image(img_path: Path) -> 'PILImage' | None:
"""Open an image with PIL, returning None on any failure."""
try:
@@ -309,10 +324,34 @@ def _process_one_image(
print(f' [INFO] {img_path.name}: Office vector left external for native PPTX passthrough')
return False, None
if _is_svg_image(img_path, raw_bytes):
_embed_raw_image(image, img_path, raw_bytes)
if verbose:
print(f' [OK] {img_path.name} (svg, embedded as-is)')
return True, None
img = _load_pil_image(img_path)
if img is None:
return False, 'PIL open failed'
# Multi-frame images (animated GIF / WebP / APNG): every PIL transform
# and re-save below operates on frame 0 only, silently flattening the
# animation — and the "original bytes are smaller" fallback never fires
# because one frame is always smaller than all frames. Embed the raw
# bytes untouched and keep the geometry attributes (including
# preserveAspectRatio, which the native converter maps to srcRect
# non-destructively). Animated assets skip re-encode, resize, and the
# size cap.
if getattr(img, 'is_animated', False):
_embed_raw_image(image, img_path, raw_bytes)
if max_dimension and max(img.size) > max_dimension:
print(f' [WARN] {img_path.name}: animated image kept as-is '
f'({img.size[0]}x{img.size[1]} exceeds max dimension '
f'{max_dimension}px); animations are exempt from size limits')
if verbose:
print(f' [OK] {img_path.name} (animated, embedded as-is)')
return True, None
box_x = _parse_float(image.get('x'))
box_y = _parse_float(image.get('y'))
box_w = _parse_float(image.get('width'))
@@ -230,6 +230,16 @@ def extract_paths_from_icon(icon_path: Path, target_color: str = '#000000') -> t
return elements, style, base_size
def _attr_value(tag_text: str, attr: str) -> str | None:
"""Return an attribute value from a raw tag, accepting either quote style."""
match = re.search(
rf'\b{re.escape(attr)}\s*=\s*(["\'])(.*?)\1',
tag_text,
re.DOTALL,
)
return match.group(2) if match else None
def parse_use_element(use_match: str) -> dict[str, str | float]:
"""
Parse attributes of a use element.
@@ -241,42 +251,42 @@ def parse_use_element(use_match: str) -> dict[str, str | float]:
Attribute dictionary
"""
attrs: dict[str, str | float] = {}
# Extract data-icon
icon_match = re.search(r'data-icon="([^"]+)"', use_match)
if icon_match:
attrs['icon'] = icon_match.group(1)
icon_value = _attr_value(use_match, 'data-icon')
if icon_value:
attrs['icon'] = icon_value
# Extract numeric attributes
for attr in ['x', 'y', 'width', 'height']:
match = re.search(rf'{attr}="([^"]+)"', use_match)
if match:
attrs[attr] = float(match.group(1))
value = _attr_value(use_match, attr)
if value is not None:
attrs[attr] = float(value)
# Extract fill color
fill_match = re.search(r'fill="([^"]+)"', use_match)
if fill_match:
attrs['fill'] = fill_match.group(1)
fill_value = _attr_value(use_match, 'fill')
if fill_value is not None:
attrs['fill'] = fill_value
# Stroke-style icons may be authored with natural SVG semantics:
# fill="none" stroke="#HEX". Keep accepting fill as the canonical color
# carrier, but preserve stroke so outline icons do not collapse to none.
stroke_match = re.search(r'stroke="([^"]+)"', use_match)
if stroke_match:
attrs['stroke'] = stroke_match.group(1)
stroke_value = _attr_value(use_match, 'stroke')
if stroke_value is not None:
attrs['stroke'] = stroke_value
# Live preview direct edits may write an absolute transform matrix back to
# the placeholder. Preserve it so the expanded icon matches the edited
# browser geometry instead of falling back to the original x/y placement.
transform_match = re.search(r'transform="([^"]+)"', use_match)
if transform_match:
attrs['transform'] = transform_match.group(1)
transform_value = _attr_value(use_match, 'transform')
if transform_value is not None:
attrs['transform'] = transform_value
# Extract optional stroke-width override (stroke-style icons only).
# Tabler-outline ships at stroke-width=2; passing 1.5 reads thin, 3 reads bold.
stroke_width_match = re.search(r'stroke-width="([^"]+)"', use_match)
if stroke_width_match:
attrs['stroke-width'] = stroke_width_match.group(1)
stroke_width_value = _attr_value(use_match, 'stroke-width')
if stroke_width_value is not None:
attrs['stroke-width'] = stroke_width_value
return attrs
@@ -386,9 +396,10 @@ def process_svg_file(svg_path: Path, icons_dir: Path, dry_run: bool = False, ver
content = svg_path.read_text(encoding='utf-8')
# Match <use data-icon="xxx" ... /> elements
use_pattern = r'<use\s+[^>]*data-icon="[^"]*"[^>]*/>'
matches = list(re.finditer(use_pattern, content))
# Match self-closing <use data-icon="..."/> placeholders. Attribute
# parsing below accepts both single and double quotes.
use_pattern = r'<use\b(?=[^>]*\bdata-icon\s*=)[^>]*/>'
matches = list(re.finditer(use_pattern, content, re.IGNORECASE | re.DOTALL))
if not matches:
if verbose:
@@ -84,6 +84,19 @@ def _optimize_image_bytes(img_bytes: bytes, mime_type: str,
except Exception:
return img_bytes
# Multi-frame images (animated GIF / WebP / APNG): resize/re-save below
# keeps frame 0 only, silently flattening the animation. Pass the
# original bytes through — animations are exempt from compression and
# the size cap.
if getattr(img, 'is_animated', False):
if max_dimension:
w, h = img.size
if w > max_dimension or h > max_dimension:
print(f" [WARN] Animated image kept as-is ({w}x{h} exceeds "
f"max dimension {max_dimension}px); animations are "
f"exempt from size limits")
return img_bytes
changed = False
# Downscale if exceeding max_dimension
@@ -35,16 +35,9 @@ from pathlib import Path
from typing import Dict, List, Tuple, Optional, Any
from dataclasses import dataclass
# Fix garbled Chinese output on Windows
if sys.platform == 'win32':
try:
sys.stdout.reconfigure(encoding='utf-8')
sys.stderr.reconfigure(encoding='utf-8')
except AttributeError:
# Python < 3.7
import io
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding='utf-8')
from console_encoding import configure_utf8_stdio
configure_utf8_stdio()
# Import canvas format configuration
try:
@@ -1014,7 +1007,7 @@ def analyze_svg_file(svg_file: str) -> None:
print(f"{'='*70}")
# Extract viewBox
viewbox_match = re.search(r'viewBox="([^"]+)"', content)
viewbox_match = re.search(r'viewBox\s*=\s*["\']([^"\']+)["\']', content)
if viewbox_match:
print(f"Canvas viewBox: {viewbox_match.group(1)}")
@@ -45,9 +45,75 @@ except ImportError:
_load_animation_config = None
_validate_animation_config = None
try:
from svg_to_pptx.drawingml.utils import (
parse_font_family as _parse_export_font_family,
)
except ImportError:
_parse_export_font_family = None
try:
from svg_to_pptx.native_objects import (
validate_native_object_marker as _validate_native_object_marker,
)
except ImportError:
_validate_native_object_marker = None
try:
from svg_to_pptx.native_objects import (
validate_native_object_marker_with_warnings as _validate_native_object_marker_with_warnings,
)
except ImportError:
_validate_native_object_marker_with_warnings = None
try:
from svg_to_pptx.native_objects import (
native_object_marker_warnings as _native_object_marker_warnings,
)
except ImportError:
_native_object_marker_warnings = None
try:
from svg_finalize.embed_icons import (
resolve_icon_path as _resolve_icon_path,
)
except ImportError:
_resolve_icon_path = None
try:
from resource_paths import (
SVG_WORK_DIR_NAMES as _SVG_WORK_DIR_NAMES,
icon_search_dirs_for_svg as _icon_search_dirs_for_svg,
project_root_for_svg_path as _project_root_for_svg_path,
resolve_external_image_reference as _resolve_external_image_reference,
unresolved_external_image_reference_path as _unresolved_external_image_reference_path,
)
except ImportError:
_SVG_WORK_DIR_NAMES = frozenset()
_icon_search_dirs_for_svg = None
_project_root_for_svg_path = None
_resolve_external_image_reference = None
_unresolved_external_image_reference_path = None
HEX_VALUE_RE = re.compile(r"#[0-9A-Fa-f]{3,8}")
SVG_NS = "http://www.w3.org/2000/svg"
XLINK_NS = "http://www.w3.org/1999/xlink"
# Fonts that survive direct PPTX typeface assignment on a typical Windows /
# macOS viewer without requiring a custom install. Keep this aligned with
# strategist.md §g and drawingml/utils.py FONT_FALLBACK_WIN.
PPT_SAFE_FONTS = {
'microsoft yahei', 'simhei', 'simsun', 'kaiti', 'fangsong',
'dengxian', 'microsoft jhenghei',
'pingfang sc', 'heiti sc', 'songti sc', 'stsong',
'arial', 'arial black', 'calibri', 'segoe ui', 'verdana',
'helvetica', 'helvetica neue', 'tahoma', 'trebuchet ms',
'times new roman', 'times', 'georgia', 'cambria', 'palatino',
'garamond', 'book antiqua',
'consolas', 'courier new', 'menlo', 'monaco',
'impact',
}
# Ramp envelope for font-size drift detection.
# From design_spec_reference.md §IV — Font Size Hierarchy: the ramp spans
@@ -93,6 +159,28 @@ def _design_spec_is_brand(spec_path: Path) -> bool:
return False
def _local_name(elem: ET.Element) -> str:
"""Return an XML element's namespace-free local tag name."""
tag = elem.tag
if not isinstance(tag, str):
return ''
return tag.rsplit('}', 1)[-1] if '}' in tag else tag
def _parse_viewbox_values(viewbox: str) -> Tuple[float, float, float, float] | None:
"""Parse a root viewBox into four numeric values."""
parts = re.split(r'[\s,]+', viewbox.strip())
if len(parts) != 4:
return None
try:
values = tuple(float(part) for part in parts)
except ValueError:
return None
if values[2] <= 0 or values[3] <= 0:
return None
return values
def _parse_placeholders_fallback(block: str) -> Dict[str, Tuple[str, ...]]:
"""Tiny YAML-free reader for the documented ``placeholders:`` shape.
@@ -260,15 +348,16 @@ class SVGQualityChecker:
with open(svg_path, 'r', encoding='utf-8') as f:
content = f.read()
# 0. Check XML well-formedness — every other check assumes the file
# is valid XML. Bail early on failure so the regex-based checks
# below don't produce misleading errors on a broken document.
if self._check_xml_well_formed(content, result):
# 0. Parse XML once — every other check assumes the file is valid
# XML. Bail early on failure so the regex-based checks below don't
# produce misleading errors on a broken document.
root = self._parse_xml_root(content, result)
if root is not None:
# 1. Check viewBox
self._check_viewbox(content, result, expected_format)
self._check_viewbox(root, result, expected_format)
# 2. Check forbidden elements
self._check_forbidden_elements(content, result)
self._check_forbidden_elements(content, root, result)
# 3. Check font-size values
self._check_font_size_values(content, result)
@@ -276,20 +365,23 @@ class SVGQualityChecker:
# 4. Check fonts
self._check_fonts(content, result)
# 5. Check width/height consistency with viewBox
self._check_dimensions(content, result)
# 5. Check text wrapping methods
self._check_text_elements(content, root, result)
# 6. Check text wrapping methods
self._check_text_elements(content, result)
# 6. Check image references (file existence and resolution)
self._check_image_references(root, svg_path, result)
# 7. Check image references (file existence and resolution)
self._check_image_references(content, svg_path, result)
# 7. Check icon placeholders resolve before post-processing.
self._check_icon_placeholders(root, svg_path, result)
# 8. Check object-level animation anchor quality.
self._check_animation_group_ids(content, result)
self._check_animation_group_ids(root, result)
# 8b. Check <pattern> elements declare a PPTX preset.
self._check_pattern_fills(content, result)
self._check_pattern_fills(root, result)
# 8c. Check opt-in native table/chart markers before export.
self._check_native_object_markers(root, result)
# 9. Check spec_lock drift (colors / font-family / font-size).
# Templates do not ship a spec_lock.md, so skip in template
@@ -326,8 +418,8 @@ class SVGQualityChecker:
self.results.append(result)
return result
def _check_xml_well_formed(self, content: str, result: Dict) -> bool:
"""Check that the SVG content parses as well-formed XML.
def _parse_xml_root(self, content: str, result: Dict) -> ET.Element | None:
"""Parse the SVG content as well-formed XML.
SVG is strict XML. AI-generated decks frequently produce content that
looks fine in HTML5-tolerant previews but fails strict XML parsing
@@ -336,11 +428,11 @@ class SVGQualityChecker:
cannot be exported to PPTX, so we surface them here as a hard error
before any downstream check looks at them.
Returns True when the document is well-formed; False otherwise.
Returns the parsed root when the document is well-formed; otherwise
appends an error and returns None.
"""
try:
ET.fromstring(content)
return True
return ET.fromstring(content)
except ET.ParseError as e:
result['errors'].append(
f"Invalid XML: {e} — SVG must be well-formed XML. "
@@ -348,34 +440,53 @@ class SVGQualityChecker:
f"escape XML reserved chars as &amp; &lt; &gt; &quot; &apos; "
f"(see references/shared-standards.md §1)."
)
return False
return None
def _check_viewbox(self, content: str, result: Dict, expected_format: str = None):
def _check_viewbox(self, root: ET.Element, result: Dict, expected_format: str = None):
"""Check viewBox attribute"""
viewbox_match = re.search(r'viewBox="([^"]+)"', content)
if not viewbox_match:
viewbox = root.get('viewBox')
if not viewbox:
result['errors'].append("Missing viewBox attribute")
return
viewbox = viewbox_match.group(1)
result['info']['viewbox'] = viewbox
# Check format
if not re.match(r'0 0 \d+ \d+', viewbox):
parts = re.split(r'[\s,]+', viewbox.strip())
if len(parts) != 4:
result['errors'].append(
f"viewBox must contain exactly four numeric values; got: {viewbox}"
)
return
try:
values = tuple(float(part) for part in parts)
except ValueError:
result['errors'].append(
f"viewBox must contain exactly four numeric values; got: {viewbox}"
)
return
if values[2] <= 0 or values[3] <= 0:
result['errors'].append(
f"viewBox width/height must be positive; got: {viewbox}"
)
return
if values[0] != 0 or values[1] != 0 or any(not part.isdigit() for part in parts):
result['warnings'].append(f"Unusual viewBox format: {viewbox}")
# Check if it matches expected format
if expected_format and expected_format in CANVAS_FORMATS:
expected_viewbox = CANVAS_FORMATS[expected_format]['viewbox']
if viewbox != expected_viewbox:
expected_values = _parse_viewbox_values(expected_viewbox)
if expected_values and values != expected_values:
result['errors'].append(
f"viewBox mismatch: expected '{expected_viewbox}', got '{viewbox}'"
)
def _check_forbidden_elements(self, content: str, result: Dict):
def _check_forbidden_elements(self, content: str, root: ET.Element, result: Dict):
"""Check forbidden elements (blocklist)"""
content_lower = content.lower()
elems = list(root.iter())
local_names = {_local_name(elem).lower() for elem in elems}
# ============================================================
# Forbidden elements blocklist - PPT incompatible
@@ -385,37 +496,36 @@ class SVGQualityChecker:
# clipPath is allowed on <image> elements and on pptx_to_svg-generated
# nested crop <svg data-pptx-crop="1"> wrappers. Both map back to
# DrawingML picture geometry in the native converter.
if '<clippath' in content_lower:
# clip-path on non-image elements → error
clip_on_non_image = re.search(
r'<(?!image\b)(?!svg\b[^>]*\bdata-pptx-crop\s*=\s*["\']1["\'])\w+[^>]*\bclip-path\s*=',
content,
re.IGNORECASE,
)
if clip_on_non_image:
result['errors'].append(
"clip-path is only allowed on <image> elements or "
"pptx_to_svg crop wrappers — for shapes, draw the target "
"shape directly instead of clipping")
# Check that every clip-path reference has a matching <clipPath> def
clip_refs = re.findall(r'clip-path\s*=\s*["\']url\(#([^)]+)\)', content)
for ref_id in clip_refs:
if f'id="{ref_id}"' not in content and f"id='{ref_id}'" not in content:
if 'clippath' in local_names:
ids = {elem.get('id') for elem in elems if elem.get('id')}
for elem in elems:
clip_ref = elem.get('clip-path')
if not clip_ref:
continue
tag = _local_name(elem).lower()
is_crop_svg = tag == 'svg' and elem.get('data-pptx-crop') == '1'
if tag != 'image' and not is_crop_svg:
result['errors'].append(
f"clip-path references #{ref_id} but no matching "
f"<clipPath id=\"{ref_id}\"> definition found")
if '<mask' in content_lower:
"clip-path is only allowed on <image> elements or "
"pptx_to_svg crop wrappers — for shapes, draw the target "
"shape directly instead of clipping")
match = re.search(r'url\(#([^)]+)\)', clip_ref)
if match and match.group(1) not in ids:
result['errors'].append(
f"clip-path references #{match.group(1)} but no matching "
f"<clipPath id=\"{match.group(1)}\"> definition found")
if 'mask' in local_names:
result['errors'].append("Detected forbidden <mask> element (PPT does not support SVG masks)")
# Style system
if '<style' in content_lower:
if 'style' in local_names:
result['errors'].append("Detected forbidden <style> element (use inline attributes instead)")
if re.search(r'\bclass\s*=', content):
result['errors'].append("Detected forbidden class attribute (use inline styles instead)")
# id attribute: only report error when <style> also exists (id is harmful only with CSS selectors)
# id inside <defs> for linearGradient/filter etc. is required, Inkscape also auto-adds id to elements,
# standalone id attributes have no impact on PPT export
if '<style' in content_lower and re.search(r'\bid\s*=', content):
if 'style' in local_names and re.search(r'\bid\s*=', content):
result['errors'].append(
"Detected id attribute used with <style> (CSS selectors forbidden, use inline styles instead)"
)
@@ -427,46 +537,65 @@ class SVGQualityChecker:
result['errors'].append("Detected forbidden @import (external CSS references forbidden)")
# Structure / nesting
if '<foreignobject' in content_lower:
if 'foreignobject' in local_names:
result['errors'].append(
"Detected forbidden <foreignObject> element (use <tspan> for manual line breaks)")
has_symbol = '<symbol' in content_lower
has_use = re.search(r'<use\b', content_lower) is not None
has_symbol = 'symbol' in local_names
has_use = 'use' in local_names
if has_symbol and has_use:
result['errors'].append("Detected forbidden <symbol> + <use> complex usage (use basic shapes or simple <use> instead)")
# marker-start / marker-end are conditionally allowed (see shared-standards.md §1.1).
# The converter maps qualifying <marker> defs to native DrawingML <a:headEnd>/<a:tailEnd>.
# We only warn when a marker is used without an obvious <defs> definition in the same file.
if re.search(r'\bmarker-(?:start|end)\s*=\s*["\']url\(#([^)]+)\)', content_lower):
if '<marker' not in content_lower:
if 'marker' not in local_names:
result['errors'].append(
"Detected marker-start/marker-end referencing a marker id, "
"but no <marker> element found in the file")
# Text / fonts
if '<textpath' in content_lower:
if 'textpath' in local_names:
result['errors'].append("Detected forbidden <textPath> element (path text is incompatible with PPT)")
if '@font-face' in content_lower:
result['errors'].append("Detected forbidden @font-face (use system font stack)")
# Animation / interaction
if re.search(r'<animate', content_lower):
if any(name.startswith('animate') for name in local_names):
result['errors'].append("Detected forbidden SMIL animation element <animate*> (SVG animations are not exported)")
if re.search(r'<set\b', content_lower):
if 'set' in local_names:
result['errors'].append("Detected forbidden SMIL animation element <set> (SVG animations are not exported)")
if '<script' in content_lower:
if 'script' in local_names:
result['errors'].append("Detected forbidden <script> element (scripts and event handlers forbidden)")
if re.search(r'\bon\w+\s*=', content): # onclick, onload etc.
result['errors'].append("Detected forbidden event attributes (e.g., onclick, onload)")
# Other discouraged elements
if '<iframe' in content_lower:
if 'iframe' in local_names:
result['errors'].append("Detected <iframe> element (should not appear in SVG)")
if re.search(r'rgba\s*\(', content_lower):
# Paint grammar: rgba()/hsl()/alpha-hex all render in browser preview
# but come back as None from parse_hex_color, so the exporter writes
# <a:noFill/> — the fill silently disappears in PPTX. Named colors and
# rgb() export correctly and are deliberately not flagged.
paint_values = [
value
for attr in ('fill', 'stroke', 'stop-color')
for value in self._svg_property_values(content, attr)
]
if any('rgba' in value.lower() for value in paint_values):
result['errors'].append("Detected forbidden rgba() color (use fill-opacity/stroke-opacity instead)")
if re.search(r'<g[^>]*\sopacity\s*=', content_lower):
if any('hsl' in value.lower() for value in paint_values):
result['errors'].append(
"Detected hsl()/hsla() color (not exported to PPTX — fills become "
"invisible; use 6-digit HEX instead)")
alpha_hex_re = re.compile(r'^#[0-9A-Fa-f]{4}$|^#[0-9A-Fa-f]{8}$')
if any(alpha_hex_re.match(value.strip()) for value in paint_values):
result['errors'].append(
"Detected alpha-channel HEX color (#RGBA/#RRGGBBAA is not exported "
"to PPTX — fills become invisible; use 6-digit HEX plus "
"fill-opacity/stroke-opacity)")
if any(_local_name(elem).lower() == 'g' and elem.get('opacity') for elem in elems):
result['errors'].append("Detected forbidden <g opacity> (set opacity on each child element individually)")
if re.search(r'<image[^>]*\sopacity\s*=', content_lower):
if any(_local_name(elem).lower() == 'image' and elem.get('opacity') for elem in elems):
result['errors'].append("Detected forbidden <image opacity> (use overlay mask approach)")
def _check_font_size_values(self, content: str, result: Dict):
@@ -497,73 +626,63 @@ class SVGQualityChecker:
def _check_fonts(self, content: str, result: Dict):
"""Check font usage.
PPTX stores a single `typeface` per run with no runtime fallback, so every
stack must END with a cross-platform pre-installed family. See
strategist.md §g "PPT-safe font discipline".
PPTX stores concrete typefaces per run with no CSS fallback. The
converter resolves each SVG font stack to exported latin / EA typefaces;
validate those exported values rather than the visual-preview tail.
"""
font_matches = re.findall(
r'font-family[:\s]*["\']([^"\']+)["\']', content, re.IGNORECASE)
font_matches = self._font_family_values(content)
if not font_matches:
return
result['info']['fonts'] = list(set(font_matches))
# Pre-installed on Windows + macOS out of the box (plus their direct
# FONT_FALLBACK_WIN mappings). A stack whose last concrete family is in
# this set survives the PPTX round-trip on any viewer machine.
ppt_safe_tail = {
'microsoft yahei', 'simhei', 'simsun', 'kaiti', 'fangsong',
'dengxian', 'microsoft jhenghei',
'pingfang sc', 'heiti sc', 'songti sc', 'stsong',
'arial', 'arial black', 'calibri', 'segoe ui', 'verdana',
'helvetica', 'helvetica neue', 'tahoma', 'trebuchet ms',
'times new roman', 'times', 'georgia', 'cambria', 'palatino',
'consolas', 'courier new', 'menlo', 'monaco',
'impact',
}
result['info']['fonts'] = sorted(set(font_matches))
if _parse_export_font_family is None:
result['warnings'].append(
"Unable to import svg_to_pptx font resolver; skipped exported-font safety check"
)
return
for font_family in font_matches:
# Drop the generic CSS fallback (sans-serif / serif / monospace)
# and inspect the last concrete family.
parts = [p.strip().strip('"').strip("'").lower()
for p in font_family.split(',')]
parts = [p for p in parts
if p and p not in ('sans-serif', 'serif', 'monospace',
'cursive', 'fantasy', 'system-ui')]
if not parts:
continue
tail = parts[-1]
if tail not in ppt_safe_tail:
exported = _parse_export_font_family(font_family)
unsafe = [
f"{role}={family}"
for role, family in exported.items()
if family.strip().lower() not in PPT_SAFE_FONTS
]
if unsafe:
result['warnings'].append(
f"Font stack does not end on a PPT-safe family "
f"(expected e.g. Microsoft YaHei / SimSun / Arial / "
f"Times New Roman / Consolas): {font_family}"
"Font stack exports non-PPT-safe typeface(s) to PPTX "
f"({', '.join(unsafe)}): {font_family}"
)
break
def _check_dimensions(self, content: str, result: Dict):
"""Check width/height consistency with viewBox"""
width_match = re.search(r'width="(\d+)"', content)
height_match = re.search(r'height="(\d+)"', content)
@staticmethod
def _font_family_values(content: str) -> List[str]:
"""Extract SVG font-family values from attributes and inline styles."""
return SVGQualityChecker._svg_property_values(content, 'font-family')
if width_match and height_match:
width = width_match.group(1)
height = height_match.group(1)
result['info']['dimensions'] = f"{width}x{height}"
@staticmethod
def _svg_property_values(content: str, property_name: str) -> List[str]:
"""Extract a SVG property from direct attributes and inline styles."""
values: List[str] = []
attr_re = re.compile(
rf'\b{re.escape(property_name)}\s*=\s*(["\'])(.*?)\1',
re.IGNORECASE | re.DOTALL,
)
for match in attr_re.finditer(content):
values.append(html.unescape(match.group(2)).strip())
# Check consistency with viewBox
if 'viewbox' in result['info']:
viewbox_parts = result['info']['viewbox'].split()
if len(viewbox_parts) == 4:
vb_width, vb_height = viewbox_parts[2], viewbox_parts[3]
if width != vb_width or height != vb_height:
result['warnings'].append(
f"width/height ({width}x{height}) does not match viewBox "
f"({vb_width}x{vb_height})"
)
for match in re.finditer(r'\bstyle\s*=\s*(["\'])(.*?)\1', content, re.IGNORECASE | re.DOTALL):
style_value = html.unescape(match.group(2))
for part in style_value.split(';'):
if ':' not in part:
continue
name, value = part.split(':', 1)
if name.strip().lower() == property_name.lower():
values.append(value.strip())
return [value for value in values if value]
def _check_text_elements(self, content: str, result: Dict):
def _check_text_elements(self, content: str, root: ET.Element, result: Dict):
"""Check text elements and wrapping methods"""
# Count text and tspan elements
text_count = content.count('<text')
@@ -579,15 +698,10 @@ class SVGQualityChecker:
f"Detected {len(text_matches)} potentially overly long single-line text(s) (consider using tspan for wrapping)"
)
self._check_unmergeable_leading_text(content, result)
self._check_unmergeable_leading_text(root, result)
def _check_unmergeable_leading_text(self, content: str, result: Dict) -> None:
def _check_unmergeable_leading_text(self, root: ET.Element, result: Dict) -> None:
"""Warn when leading text cannot be normalized for paragraph merging."""
try:
root = ET.fromstring(content)
except ET.ParseError:
return
risky = []
for text_el in root.iter(f'{{{SVG_NS}}}text'):
if not (text_el.text or "").strip():
@@ -639,43 +753,38 @@ class SVGQualityChecker:
return None
def _check_image_references(self, content: str, svg_path: Path, result: Dict):
def _check_image_references(self, root: ET.Element, svg_path: Path, result: Dict):
"""Check image file existence and resolution vs display size."""
# Find all <image ...> elements (capture the full tag)
img_tag_pattern = re.compile(r'<image\b([^>]*)/?>', re.IGNORECASE)
svg_dir = svg_path.parent
checked = set()
for tag_match in img_tag_pattern.finditer(content):
attrs = tag_match.group(1)
# Extract href (prefer href over xlink:href)
href_match = (
re.search(r'\bhref="(?!data:)([^"]+)"', attrs) or
re.search(r'\bxlink:href="(?!data:)([^"]+)"', attrs)
)
if not href_match:
for image in root.iter():
if _local_name(image).lower() != 'image':
continue
href = href_match.group(1)
href = image.get('href') or image.get(f'{{{XLINK_NS}}}href')
if not href or href.startswith('data:'):
continue
if _resolve_external_image_reference is None or _unresolved_external_image_reference_path is None:
result['warnings'].append(
"Detected image references, but shared image resolver could not be imported; "
"export will still validate them."
)
return
if href in checked:
continue
checked.add(href)
# Resolve path relative to SVG file directory
img_path = (svg_dir / href).resolve()
if not img_path.exists():
img_path = _resolve_external_image_reference(svg_dir, href)
if img_path is None:
resolved_path = _unresolved_external_image_reference_path(svg_dir, href)
result['errors'].append(
f"Image file not found: {href} (resolved to {img_path})")
f"Image file not found: {href} (resolved to {resolved_path})")
continue
# Check resolution vs display size
w_match = re.search(r'\bwidth="([^"]+)"', attrs)
h_match = re.search(r'\bheight="([^"]+)"', attrs)
display_w_str = w_match.group(1) if w_match else None
display_h_str = h_match.group(1) if h_match else None
display_w_str = image.get('width')
display_h_str = image.get('height')
if not display_w_str or not display_h_str:
continue
@@ -704,13 +813,49 @@ class SVGQualityChecker:
except Exception:
pass # Image unreadable, skip resolution check
def _check_animation_group_ids(self, content: str, result: Dict):
"""Warn when visible top-level groups cannot be customized."""
try:
root = ET.fromstring(content)
except ET.ParseError:
def _check_icon_placeholders(self, root: ET.Element, svg_path: Path, result: Dict) -> None:
"""Check that <use data-icon="..."> placeholders resolve."""
placeholders = [
elem for elem in root.iter()
if _local_name(elem).lower() == 'use' and elem.get('data-icon') is not None
]
if not placeholders:
return
if _resolve_icon_path is None:
result['warnings'].append(
"Detected data-icon placeholders, but icon resolver could not be imported; "
"post-processing/export will still validate them."
)
return
if _icon_search_dirs_for_svg is None:
result['warnings'].append(
"Detected data-icon placeholders, but shared icon search helper could not be imported; "
"post-processing/export will still validate them."
)
return
icons_dir, fallback_dir = _icon_search_dirs_for_svg(svg_path)
seen = set()
for elem in placeholders:
icon_name = (elem.get('data-icon') or '').strip()
if not icon_name:
result['errors'].append("Icon placeholder has empty data-icon value")
continue
if icon_name in seen:
continue
seen.add(icon_name)
icon_path, _ = _resolve_icon_path(icon_name, icons_dir, fallback_dir)
if not icon_path.exists():
fallback_msg = f", then {fallback_dir}" if fallback_dir else ""
result['errors'].append(
f"Icon not found: {icon_name} (searched {icons_dir}"
f"{fallback_msg})"
)
def _check_animation_group_ids(self, root: ET.Element, result: Dict):
"""Warn when visible top-level groups cannot be customized."""
non_visual = {'defs', 'title', 'desc', 'metadata', 'style'}
for index, child in enumerate(list(root), start=1):
tag = child.tag.split('}', 1)[-1]
@@ -738,7 +883,7 @@ class SVGQualityChecker:
'divot', 'shingle',
})
def _check_pattern_fills(self, content: str, result: Dict):
def _check_pattern_fills(self, root: ET.Element, result: Dict):
"""Audit <pattern> defs that drive PPTX <a:pattFill> output.
svg_to_pptx maps <pattern fill> to native <a:pattFill prst="...">. The
@@ -756,11 +901,6 @@ class SVGQualityChecker:
value) is the canonical mistake; the only grids are `smGrid` /
`lgGrid` / `dotGrid`.
"""
try:
root = ET.fromstring(content)
except ET.ParseError:
return
for pattern in root.iter(f'{{{SVG_NS}}}pattern'):
pat_id = pattern.get('id', '<unnamed>')
prst = pattern.get('data-pptx-pattern')
@@ -784,6 +924,51 @@ class SVGQualityChecker:
"_OOXML_PATTERN_PRESETS."
)
def _check_native_object_markers(self, root: ET.Element, result: Dict) -> None:
"""Validate opt-in native table/chart markers before PPTX export."""
markers = [
elem for elem in root.iter()
if elem.get('data-pptx-native') and elem.tag.rsplit('}', 1)[-1] != 'metadata'
]
if not markers:
return
if _validate_native_object_marker is None:
result['warnings'].append(
"Detected data-pptx-native markers, but native-object validator "
"could not be imported; export-time validation will still run."
)
return
for marker in markers:
marker_id = marker.get('id') or '<unnamed>'
if _validate_native_object_marker_with_warnings is not None:
try:
warnings = _validate_native_object_marker_with_warnings(marker)
except RuntimeError as exc:
result['errors'].append(
f"Invalid data-pptx-native marker {marker_id}: {exc}"
)
continue
for warning in warnings:
result['warnings'].append(
f"data-pptx-native marker {marker_id}: {warning}"
)
continue
try:
_validate_native_object_marker(marker)
except RuntimeError as exc:
result['errors'].append(
f"Invalid data-pptx-native marker {marker_id}: {exc}"
)
continue
if _native_object_marker_warnings is None:
continue
for warning in _native_object_marker_warnings(marker):
result['warnings'].append(
f"data-pptx-native marker {marker_id}: {warning}"
)
def _get_spec_lock(self, svg_path: Path):
"""Locate and parse spec_lock.md near the SVG. Returns dict or None.
@@ -878,17 +1063,15 @@ class SVGQualityChecker:
# Scan SVG for used values
color_drifts = set()
for attr in ('fill', 'stroke', 'stop-color'):
pattern = re.compile(rf'\b{attr}\s*=\s*["\'](#[0-9A-Fa-f]{{3,8}})["\']')
for m in pattern.finditer(content):
val = m.group(1).upper()
for raw_value in self._svg_property_values(content, attr):
if not HEX_VALUE_RE.fullmatch(raw_value):
continue
val = raw_value.upper()
if val not in allowed_colors:
color_drifts.add(val)
font_drifts = set()
# Capture to the matching delimiter (group 1) so a double-quoted stack
# containing single-quoted family names is not truncated at the inner quote.
for m in re.finditer(r'font-family\s*=\s*(["\'])(.*?)\1', content):
val = m.group(2).strip()
for val in self._font_family_values(content):
if allowed_fonts and self._normalize_font_stack(val) not in allowed_fonts:
font_drifts.add(val)
@@ -900,8 +1083,8 @@ class SVGQualityChecker:
size_drifts = set()
used_sizes = []
for m in re.finditer(r'font-size\s*=\s*["\']([^"\']+)["\']', content):
val = self._normalize_size(m.group(1))
for raw_value in self._svg_property_values(content, 'font-size'):
val = self._normalize_size(raw_value)
used_sizes.append(val)
if not allowed_sizes or val in allowed_sizes:
continue
@@ -1273,8 +1456,8 @@ class SVGQualityChecker:
@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 _project_root_for_svg_path is not None and dir_path.name in _SVG_WORK_DIR_NAMES:
return _project_root_for_svg_path(dir_path)
if (dir_path / 'svg_output').exists() or (dir_path / 'design_spec.md').exists():
return dir_path
return dir_path.parent
@@ -1488,14 +1671,18 @@ class SVGQualityChecker:
@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
"""Return root viewBox width/height from SVG content."""
try:
return float(match.group(1)), float(match.group(2))
except ValueError:
root = ET.fromstring(content)
except ET.ParseError:
return None
viewbox = root.get('viewBox')
if not viewbox:
return None
values = _parse_viewbox_values(viewbox)
if values is None:
return None
return values[2], values[3]
@classmethod
def _has_off_canvas_reference(cls, refs: List[Tuple[Path, str]]) -> bool:
@@ -1543,7 +1730,7 @@ class SVGQualityChecker:
"""Project-level animations.json reference checks."""
if _load_animation_config is None or _validate_animation_config is None:
return
project_path = dir_path if (dir_path / 'svg_output').exists() else dir_path.parent
project_path = self._resolve_project_path(dir_path)
try:
config = _load_animation_config(project_path)
except Exception as exc:
@@ -1851,9 +2038,9 @@ class SVGQualityChecker:
if self.summary['errors'] > 0 or self.summary['warnings'] > 0:
print(f"\n[TIP] Common fixes:")
print(f" 1. XML well-formedness: write typography as raw Unicode (—, ©, →, NBSP); escape XML reserved chars as &amp; &lt; &gt; &quot; &apos; — never use HTML named entities like &nbsp; &mdash; &copy;")
print(f" 2. viewBox issues: Ensure consistency with canvas format (see references/canvas-formats.md)")
print(f" 2. viewBox issues: root viewBox is the canvas authority (see references/canvas-formats.md)")
print(f" 3. foreignObject: Use <text> + <tspan> for manual line breaks")
print(f" 4. Font issues: end every font-family stack with a PPT-safe family (e.g. Microsoft YaHei / Arial / Consolas)")
print(f" 4. Font issues: use PPT-safe exported typefaces (e.g. Microsoft YaHei / Arial / Consolas)")
def _print_animation_summary(self):
"""Print animations.json validation issues if present."""
@@ -6,9 +6,9 @@ Public API:
- create_pptx_with_native_svg(): Build PPTX from SVG files
"""
from .pptx_cli import main
from .drawingml_converter import convert_svg_to_slide_shapes
from .pptx_builder import create_pptx_with_native_svg
from .pptx_package.cli import main
from .drawingml.converter import convert_svg_to_slide_shapes
from .pptx_package.builder import create_pptx_with_native_svg
__all__ = [
'main',
@@ -9,7 +9,7 @@ from pathlib import Path
from typing import Any
from xml.etree import ElementTree as ET
from .drawingml_utils import SVG_NS
from .drawingml.utils import SVG_NS
try:
from pptx_animations import ANIMATIONS, TRANSITIONS
@@ -0,0 +1 @@
"""DrawingML conversion helpers for SVG-to-PPTX export."""
@@ -34,11 +34,15 @@ class ConvertContext:
translate_y: float = 0.0
scale_x: float = 1.0
scale_y: float = 1.0
viewport_width: float = 1280.0
viewport_height: float = 720.0
transform_matrix: AffineMatrix = IDENTITY_MATRIX
use_transform_matrix: bool = False
filter_id: str | None = None
media_files: dict[str, bytes] = field(default_factory=dict)
rel_entries: list[dict[str, str]] = field(default_factory=list)
package_files: dict[str, bytes] = field(default_factory=dict)
content_type_overrides: dict[str, str] = field(default_factory=dict)
rel_id_counter: int = 2 # rId1 reserved for slideLayout
svg_dir: Path | None = None
inherited_styles: dict[str, str] = field(default_factory=dict)
@@ -50,6 +54,9 @@ class ConvertContext:
# Default-on flag: merge mergeable paragraph blocks into one editable
# text frame with multiple <a:p>. Disable it for strict line fidelity.
merge_paragraphs: bool = True
# Explicit opt-in: convert data-pptx-native table/chart marker groups to
# native PowerPoint graphicFrames. Default stays off to preserve SVG output.
native_objects_enabled: bool = False
# Native PPTX image optimization. Keeps generated decks compact by
# downsampling oversized raster assets to their rendered size.
image_optimize: bool = True
@@ -148,11 +155,15 @@ class ConvertContext:
translate_y=self.translate_y + dy,
scale_x=self.scale_x * sx,
scale_y=self.scale_y * sy,
viewport_width=self.viewport_width,
viewport_height=self.viewport_height,
transform_matrix=combined_matrix,
use_transform_matrix=self.use_transform_matrix or transform_matrix is not None,
filter_id=filter_id or self.filter_id,
media_files=self.media_files,
rel_entries=self.rel_entries,
package_files=self.package_files,
content_type_overrides=self.content_type_overrides,
rel_id_counter=self.rel_id_counter,
svg_dir=self.svg_dir,
inherited_styles=merged,
@@ -160,6 +171,7 @@ class ConvertContext:
# anim_targets is intentionally a fresh list on the child;
# only the root-level context's list is read by the builder.
merge_paragraphs=self.merge_paragraphs,
native_objects_enabled=self.native_objects_enabled,
image_optimize=self.image_optimize,
image_max_dimension=self.image_max_dimension,
image_sizing=self.image_sizing,
@@ -8,18 +8,22 @@ from pathlib import Path
from typing import Any
from xml.etree import ElementTree as ET
from .drawingml_context import ConvertContext, ShapeResult
from .drawingml_utils import (
from resource_paths import icon_search_dirs_for_svg
from .context import ConvertContext, ShapeResult
from .utils import (
SVG_NS, EMU_PER_PX,
_extract_inheritable_styles, parse_transform_matrix, resolve_url_id,
parse_svg_length,
)
from .drawingml_styles import build_effect_xml
from .drawingml_elements import (
from .styles import build_effect_xml
from .elements import (
convert_rect, convert_circle, convert_ellipse,
convert_line, convert_path,
convert_polygon, convert_polyline,
convert_text, convert_image, convert_nested_svg,
)
from ..native_objects import convert_native_object
class SvgNativeConversionError(RuntimeError):
@@ -108,6 +112,24 @@ _ROTATE_RE = re.compile(
)
def _root_viewport_size(root: ET.Element) -> tuple[float, float]:
"""Return the SVG root viewport size in user units."""
view_box = root.get('viewBox')
if view_box:
raw_parts = re.split(r'[\s,]+', view_box.strip())
if len(raw_parts) == 4:
try:
parts = [float(n) for n in raw_parts]
except ValueError:
parts = []
if parts and parts[2] > 0 and parts[3] > 0:
return parts[2], parts[3]
width = parse_svg_length(root.get('width'), 1280.0)
height = parse_svg_length(root.get('height'), 720.0)
return max(width, 1.0), max(height, 1.0)
def _extract_rotate_pivot(transform_str: str) -> tuple[float, float] | None:
"""Return the (cx, cy) pivot of a sole ``rotate(...)`` in *transform_str*.
@@ -181,6 +203,16 @@ def convert_g(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None:
else:
child_ctx = ctx.child(dx, dy, sx, sy, filter_id=filter_id, style_overrides=style_overrides)
if child_ctx.native_objects_enabled:
native_result = convert_native_object(elem, child_ctx)
if native_result:
ctx.sync_from_child(child_ctx)
if should_animate_group:
shape_match = re.search(r'<p:cNvPr id="(\d+)"', native_result.xml)
if shape_match:
ctx.anim_targets.append((int(shape_match.group(1)), elem_id))
return native_result
child_results: list[ShapeResult] = []
for child in elem:
result = convert_element(child, child_ctx)
@@ -426,8 +458,16 @@ def convert_svg_to_slide_shapes(
image_sizing: str = 'cap',
image_scale: float = 2.0,
image_quality: int = 85,
native_objects: bool = False,
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,
dict[str, bytes],
dict[str, str],
]:
"""Convert an SVG file to a complete DrawingML slide XML.
Args:
@@ -444,19 +484,27 @@ def convert_svg_to_slide_shapes(
size from rendered SVG boxes.
image_scale: Target image pixels per SVG display pixel.
image_quality: JPEG quality used for opaque optimized rasters.
native_objects: Convert explicit ``data-pptx-native`` table/chart
markers to native PowerPoint objects. Default off.
trace_out: Optional list populated with one per-slide trace dictionary.
Returns:
(slide_xml, media_files, rel_entries, anim_targets) where:
(slide_xml, media_files, rel_entries, anim_targets,
package_files, content_type_overrides) where:
- slide_xml: Complete slide XML string.
- media_files: Dict of {filename: bytes} for media to write.
- rel_entries: List of relationship entries to add.
- anim_targets: List of (shape_id, svg_id) tuples for top-level
semantic groups, in z-order; consumed by the builder's optional
per-element entrance timing emitter.
- package_files: Dict of {pptx internal path: bytes} for non-media
OOXML parts such as native chart XML and embedded workbooks.
- content_type_overrides: Dict of {pptx internal path: content type}
for package_files that require [Content_Types].xml overrides.
"""
tree = ET.parse(str(svg_path))
root = tree.getroot()
viewport_width, viewport_height = _root_viewport_size(root)
trace_events: list[dict[str, Any]] | None = [] if trace_out is not None else None
trace_steps: list[dict[str, Any]] = []
@@ -465,10 +513,10 @@ def convert_svg_to_slide_shapes(
# both ignore data-icon, so without expansion icons would silently drop.
# The on-disk finalize_svg pipeline does the same expansion for svg_final/;
# running this here makes the two pipelines behaviourally aligned.
icons_dir = Path(__file__).resolve().parent.parent.parent / 'templates' / 'icons'
icons_dir, icons_fallback_dir = icon_search_dirs_for_svg(svg_path)
if icons_dir.exists():
from .use_expander import expand_use_data_icons
expanded = expand_use_data_icons(root, icons_dir)
from ..use_expander import expand_use_data_icons
expanded = expand_use_data_icons(root, icons_dir, icons_fallback_dir)
if expanded:
trace_steps.append({'action': 'expand-use-data-icons', 'count': expanded})
if verbose and expanded:
@@ -482,7 +530,7 @@ def convert_svg_to_slide_shapes(
# correct when reading raw svg_output/.
# merge_paragraphs additionally folds mergeable paragraph blocks into a
# single annotated <text> for downstream multi-<a:p> conversion.
from .tspan_flattener import flatten_positional_tspans
from ..tspan_flattener import flatten_positional_tspans
flattened = flatten_positional_tspans(tree, merge_paragraphs=merge_paragraphs)
if flattened:
trace_steps.append({
@@ -504,6 +552,8 @@ def convert_svg_to_slide_shapes(
ctx = ConvertContext(
defs=defs,
slide_num=slide_num,
viewport_width=viewport_width,
viewport_height=viewport_height,
svg_dir=Path(svg_path).parent,
merge_paragraphs=merge_paragraphs,
image_optimize=image_optimize,
@@ -511,6 +561,7 @@ def convert_svg_to_slide_shapes(
image_sizing=image_sizing,
image_scale=image_scale,
image_quality=image_quality,
native_objects_enabled=native_objects,
trace_events=trace_events,
)
@@ -557,6 +608,7 @@ def convert_svg_to_slide_shapes(
'converted': converted,
'skipped': skipped,
'media_files': len(ctx.media_files),
'package_files': len(ctx.package_files),
'relationships': len(ctx.rel_entries),
'animation_targets': len(ctx.anim_targets),
},
@@ -586,4 +638,11 @@ def convert_svg_to_slide_shapes(
<p:clrMapOvr><a:masterClrMapping/></p:clrMapOvr>
</p:sld>'''
return slide_xml, ctx.media_files, ctx.rel_entries, ctx.anim_targets
return (
slide_xml,
ctx.media_files,
ctx.rel_entries,
ctx.anim_targets,
ctx.package_files,
ctx.content_type_overrides,
)
@@ -8,25 +8,29 @@ import re
import base64
from pathlib import Path
from typing import Any
from urllib.parse import unquote_to_bytes
from xml.etree import ElementTree as ET
from .drawingml_context import ConvertContext, ShapeResult
from .drawingml_utils import (
from resource_paths import resolve_external_image_reference
from .context import ConvertContext, ShapeResult
from .utils import (
SVG_NS, XLINK_NS, ANGLE_UNIT, FONT_PX_TO_HUNDREDTHS_PT, DASH_PRESETS,
px_to_emu, _f, _get_attr,
px_to_emu, _f, _get_attr, parse_svg_length,
svg_length_x, svg_length_y, svg_length_size,
ctx_x, ctx_y, ctx_w, ctx_h,
rect_to_dml_xfrm,
parse_hex_color, resolve_url_id, get_effective_filter_id,
parse_font_family, is_cjk_char, estimate_text_width,
parse_inline_style, parse_font_family, is_cjk_char, estimate_text_width,
detect_text_lang, resolve_text_run_fonts,
matrix_multiply, parse_transform_matrix, transform_point, _xml_escape,
)
from .drawingml_styles import (
from .styles import (
build_solid_fill, build_gradient_fill,
build_fill_xml, build_stroke_xml, build_effect_xml, classify_filter_effect,
get_fill_opacity, get_stroke_opacity,
)
from .drawingml_paths import (
from .paths import (
PathCommand, parse_svg_path, svg_path_to_absolute,
normalize_path_commands, path_commands_to_drawingml,
)
@@ -41,17 +45,39 @@ def _resolve_external_image(svg_dir: Path, href: str) -> Path:
(legacy flat-copied template assets). Raises ``FileNotFoundError`` if none
of these exist.
"""
for candidate in (
svg_dir / href,
svg_dir.parent / href,
svg_dir.parent / 'images' / href,
svg_dir.parent / 'templates' / href,
):
if candidate.exists():
return candidate
candidate = resolve_external_image_reference(svg_dir, href)
if candidate is not None:
return candidate
raise FileNotFoundError(f'External image not found: {href}')
def _decode_data_image_uri(href: str) -> tuple[str, bytes] | None:
"""Decode SVG image data URIs, including URL-encoded non-base64 payloads."""
if not href.startswith('data:') or ',' not in href:
return None
header, payload = href.split(',', 1)
match = re.match(r'data:image/([^;,]+)', header, flags=re.IGNORECASE)
if not match:
return None
img_format = match.group(1).lower()
if img_format == 'svg+xml':
img_format = 'svg'
elif img_format == 'jpeg':
img_format = 'jpg'
is_base64 = any(
part.strip().lower() == 'base64'
for part in header.split(';')[1:]
)
if is_base64:
img_data = base64.b64decode(payload)
else:
img_data = unquote_to_bytes(payload)
return img_format, img_data
def _wrap_shape(
shape_id: int, name: str,
off_x: int, off_y: int,
@@ -301,10 +327,10 @@ def convert_rect(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None:
for now current corpora contain none, but the branch keeps callers from
silently producing distorted custom geometry if one ever appears.
"""
raw_x = _f(elem.get('x'))
raw_y = _f(elem.get('y'))
raw_w = _f(elem.get('width'))
raw_h = _f(elem.get('height'))
raw_x = svg_length_x(elem.get('x'), ctx)
raw_y = svg_length_y(elem.get('y'), ctx)
raw_w = svg_length_x(elem.get('width'), ctx)
raw_h = svg_length_y(elem.get('height'), ctx)
x = ctx_x(raw_x, ctx)
y = ctx_y(raw_y, ctx)
w = ctx_w(raw_w, ctx)
@@ -318,8 +344,8 @@ def convert_rect(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None:
# be inferred to keep round corners from collapsing to zero on one axis.
rx_attr = elem.get('rx')
ry_attr = elem.get('ry')
rx_raw = _f(rx_attr) if rx_attr is not None else 0.0
ry_raw = _f(ry_attr) if ry_attr is not None else 0.0
rx_raw = svg_length_x(rx_attr, ctx) if rx_attr is not None else 0.0
ry_raw = svg_length_y(ry_attr, ctx) if ry_attr is not None else 0.0
if rx_attr is not None and ry_attr is None:
ry_raw = rx_raw
elif ry_attr is not None and rx_attr is None:
@@ -475,8 +501,8 @@ def _is_donut_circle(elem: ET.Element, ctx: ConvertContext) -> bool:
if not stroke or stroke == 'none':
return False
sw = _f(_get_attr(elem, 'stroke-width', ctx), 0)
r = _f(elem.get('r'), 0)
sw = svg_length_size(_get_attr(elem, 'stroke-width', ctx), ctx, 0)
r = svg_length_size(elem.get('r'), ctx, 0)
if sw <= 0 or r <= 0:
return False
@@ -494,9 +520,9 @@ def _is_donut_circle(elem: ET.Element, ctx: ConvertContext) -> bool:
def convert_circle(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None:
"""Convert SVG <circle> to DrawingML ellipse or donut-arc shape."""
cx_ = _f(elem.get('cx'))
cy_ = _f(elem.get('cy'))
r = _f(elem.get('r'))
cx_ = svg_length_x(elem.get('cx'), ctx)
cy_ = svg_length_y(elem.get('cy'), ctx)
r = svg_length_size(elem.get('r'), ctx)
if r <= 0:
return None
@@ -506,8 +532,8 @@ def convert_circle(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None:
dasharray = _get_attr(elem, 'stroke-dasharray', ctx)
dash_vals = re.split(r'[\s,]+', dasharray.strip())
dash_len = float(dash_vals[0]) if dash_vals else 0
dash_offset = _f(elem.get('stroke-dashoffset'), 0)
stroke_width = _f(_get_attr(elem, 'stroke-width', ctx), 1)
dash_offset = svg_length_size(elem.get('stroke-dashoffset'), ctx, 0)
stroke_width = svg_length_size(_get_attr(elem, 'stroke-width', ctx), ctx, 1)
rotate_deg = 0.0
transform = elem.get('transform', '')
@@ -613,8 +639,18 @@ def convert_line(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None:
use custom geometry which is sufficient and avoids flipH/flipV complexity.
"""
transform = elem.get('transform')
x1, y1 = _transformed_point(ctx, _f(elem.get('x1')), _f(elem.get('y1')), transform)
x2, y2 = _transformed_point(ctx, _f(elem.get('x2')), _f(elem.get('y2')), transform)
x1, y1 = _transformed_point(
ctx,
svg_length_x(elem.get('x1'), ctx),
svg_length_y(elem.get('y1'), ctx),
transform,
)
x2, y2 = _transformed_point(
ctx,
svg_length_x(elem.get('x2'), ctx),
svg_length_y(elem.get('y2'), ctx),
transform,
)
min_x = min(x1, x2)
min_y = min(y1, y2)
@@ -790,7 +826,7 @@ def convert_path(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None:
def _parse_points(points_str: str) -> list[tuple[float, float]]:
"""Parse SVG points attribute into a list of (x, y) tuples."""
nums = re.findall(r'[-+]?(?:\d+\.?\d*|\.\d+)', points_str)
nums = re.findall(r'[-+]?(?:\d+\.?\d*|\.\d+)(?:[eE][-+]?\d+)?', points_str)
if len(nums) < 4:
return []
return [(float(nums[i]), float(nums[i + 1])) for i in range(0, len(nums) - 1, 2)]
@@ -1086,34 +1122,47 @@ def _override_run_attrs(
) -> dict[str, Any]:
"""Layer a tspan's styling attributes over the inherited run attrs."""
run_attrs = dict(parent_attrs)
if tspan.get('font-weight'):
run_attrs['font_weight'] = tspan.get('font-weight')
if tspan.get('fill'):
child_fill = tspan.get('fill')
inline_style = parse_inline_style(tspan.get('style'))
def tspan_attr(name: str) -> str | None:
return inline_style.get(name) or tspan.get(name)
if tspan_attr('font-weight'):
run_attrs['font_weight'] = tspan_attr('font-weight')
if tspan_attr('fill'):
child_fill = tspan_attr('fill')
run_attrs['fill_raw'] = child_fill
c = parse_hex_color(child_fill)
if c:
run_attrs['fill'] = c
if tspan.get('stroke'):
run_attrs['stroke_raw'] = tspan.get('stroke')
if tspan.get('stroke-width'):
run_attrs['stroke_width'] = _f(tspan.get('stroke-width'), run_attrs.get('stroke_width', 1.0))
if tspan.get('stroke-opacity'):
if tspan_attr('stroke'):
run_attrs['stroke_raw'] = tspan_attr('stroke')
if tspan_attr('stroke-width'):
run_attrs['stroke_width'] = parse_svg_length(
tspan_attr('stroke-width'),
run_attrs.get('stroke_width', 1.0),
font_size=float(run_attrs.get('font_size', 16)),
)
if tspan_attr('stroke-opacity'):
try:
run_attrs['stroke_opacity'] = float(tspan.get('stroke-opacity', '1'))
run_attrs['stroke_opacity'] = float(tspan_attr('stroke-opacity') or '1')
except ValueError:
pass
if tspan.get('font-size'):
run_attrs['font_size'] = _f(tspan.get('font-size'), run_attrs['font_size'])
if tspan.get('font-family'):
run_attrs['font_family'] = tspan.get('font-family')
if tspan.get('font-style'):
run_attrs['font_style'] = tspan.get('font-style')
if tspan.get('text-decoration'):
run_attrs['text_decoration'] = tspan.get('text-decoration')
if tspan.get('letter-spacing'):
if tspan_attr('font-size'):
run_attrs['font_size'] = parse_svg_length(
tspan_attr('font-size'),
run_attrs['font_size'],
font_size=float(run_attrs.get('font_size', 16)),
)
if tspan_attr('font-family'):
run_attrs['font_family'] = tspan_attr('font-family')
if tspan_attr('font-style'):
run_attrs['font_style'] = tspan_attr('font-style')
if tspan_attr('text-decoration'):
run_attrs['text_decoration'] = tspan_attr('text-decoration')
if tspan_attr('letter-spacing'):
run_attrs['letter_spacing'] = _parse_letter_spacing_px(
tspan.get('letter-spacing'),
tspan_attr('letter-spacing'),
font_size=float(run_attrs.get('font_size', 16)),
scale_x=float(run_attrs.get('_scale_x', 1.0)),
)
@@ -1194,7 +1243,7 @@ def _build_text_fill_xml(
ctx: ConvertContext | None,
) -> str:
"""Build DrawingML fill XML for a text run."""
if fill_raw == 'none':
if fill_raw.strip().lower() in ('none', 'transparent'):
return '<a:noFill/>'
grad_id = resolve_url_id(fill_raw)
@@ -1210,7 +1259,7 @@ def _build_text_fill_xml(
def _build_text_outline_xml(run: dict[str, Any]) -> str:
"""Build DrawingML outline XML for a text run from SVG stroke attributes."""
stroke_raw = run.get('stroke_raw')
if not stroke_raw or stroke_raw == 'none':
if not stroke_raw or stroke_raw.strip().lower() in ('none', 'transparent'):
return ''
color = parse_hex_color(stroke_raw)
@@ -1284,9 +1333,12 @@ def _build_run_xml(
def convert_text(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None:
"""Convert SVG <text> to DrawingML text shape with multi-run support."""
x = ctx_x(_f(elem.get('x')), ctx)
y = ctx_y(_f(elem.get('y')), ctx)
font_size = _f(_get_attr(elem, 'font-size', ctx), 16) * ctx.scale_y
x = ctx_x(svg_length_x(elem.get('x'), ctx), ctx)
y = ctx_y(svg_length_y(elem.get('y'), ctx), ctx)
font_size = (
parse_svg_length(_get_attr(elem, 'font-size', ctx), 16, font_size=16)
* ctx.scale_y
)
font_weight = _get_attr(elem, 'font-weight', ctx) or '400'
font_family_str = _get_attr(elem, 'font-family', ctx) or ''
text_anchor = _get_attr(elem, 'text-anchor', ctx) or 'start'
@@ -1294,7 +1346,7 @@ def convert_text(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None:
fill_color = parse_hex_color(fill_raw) or '000000'
opacity = get_fill_opacity(elem, ctx)
stroke_raw = _get_attr(elem, 'stroke', ctx) or ''
stroke_width = _f(_get_attr(elem, 'stroke-width', ctx), 1.0)
stroke_width = svg_length_size(_get_attr(elem, 'stroke-width', ctx), ctx, 1.0)
stroke_opacity = get_stroke_opacity(elem, ctx)
font_style = _get_attr(elem, 'font-style', ctx) or ''
text_decoration = _get_attr(elem, 'text-decoration', ctx) or ''
@@ -1958,6 +2010,14 @@ def _optimize_image_for_pptx(
except (UnidentifiedImageError, OSError, ValueError):
return img_data, img_format
# Multi-frame images (animated GIF / WebP / APNG): resize/re-encode
# below keeps frame 0 only, flattening the animation in the exported
# PPTX. Pass the original bytes through — animations are exempt from
# optimization and the size cap (before this optimizer existed, the
# native path embedded raster bytes verbatim and animations survived).
if getattr(img, 'is_animated', False):
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],
@@ -2124,10 +2184,10 @@ def convert_image(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None:
return None
# Raw coordinates (pre-context-transform) for clip path calculations
raw_x = _f(elem.get('x'))
raw_y = _f(elem.get('y'))
raw_w = _f(elem.get('width'))
raw_h = _f(elem.get('height'))
raw_x = svg_length_x(elem.get('x'), ctx)
raw_y = svg_length_y(elem.get('y'), ctx)
raw_w = svg_length_x(elem.get('width'), ctx)
raw_h = svg_length_y(elem.get('height'), ctx)
if ctx.use_transform_matrix:
x = raw_x
@@ -2145,15 +2205,10 @@ def convert_image(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None:
# Extract image data
if href.startswith('data:'):
match = re.match(r'data:image/([A-Za-z0-9.+-]+);base64,(.+)', href, re.DOTALL)
if not match:
decoded = _decode_data_image_uri(href)
if decoded is None:
return None
img_format = match.group(1).lower()
if img_format == 'svg+xml':
img_format = 'svg'
if img_format == 'jpeg':
img_format = 'jpg'
img_data = base64.b64decode(match.group(2))
img_format, img_data = decoded
else:
if ctx.svg_dir is None:
return None
@@ -2260,10 +2315,10 @@ def convert_image(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None:
def convert_ellipse(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None:
"""Convert SVG <ellipse> to DrawingML ellipse shape."""
raw_cx = _f(elem.get('cx'))
raw_cy = _f(elem.get('cy'))
raw_rx = _f(elem.get('rx'))
raw_ry = _f(elem.get('ry'))
raw_cx = svg_length_x(elem.get('cx'), ctx)
raw_cy = svg_length_y(elem.get('cy'), ctx)
raw_rx = svg_length_x(elem.get('rx'), ctx)
raw_ry = svg_length_y(elem.get('ry'), ctx)
cx_ = ctx_x(raw_cx, ctx)
cy_ = ctx_y(raw_cy, ctx)
rx = raw_rx * ctx.scale_x
@@ -2340,10 +2395,10 @@ def convert_nested_svg(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | N
if not href:
return None
svg_x = _f(elem.get('x'))
svg_y = _f(elem.get('y'))
svg_w = _f(elem.get('width'))
svg_h = _f(elem.get('height'))
svg_x = svg_length_x(elem.get('x'), ctx)
svg_y = svg_length_y(elem.get('y'), ctx)
svg_w = svg_length_x(elem.get('width'), ctx)
svg_h = svg_length_y(elem.get('height'), ctx)
if svg_w <= 0 or svg_h <= 0:
return None
@@ -2372,15 +2427,10 @@ def convert_nested_svg(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | N
src_rect_xml = f'<a:srcRect l="{l}" t="{t}" r="{r}" b="{b}"/>'
if href.startswith('data:'):
match = re.match(r'data:image/([A-Za-z0-9.+-]+);base64,(.+)', href, re.DOTALL)
if not match:
decoded = _decode_data_image_uri(href)
if decoded is None:
return None
img_format = match.group(1).lower()
if img_format == 'svg+xml':
img_format = 'svg'
if img_format == 'jpeg':
img_format = 'jpg'
img_data = base64.b64decode(match.group(2))
img_format, img_data = decoded
else:
if ctx.svg_dir is None:
return None
@@ -6,7 +6,7 @@ import math
import re
from dataclasses import dataclass, field
from .drawingml_utils import px_to_emu
from .utils import px_to_emu
@dataclass
@@ -6,10 +6,10 @@ import math
import re
from xml.etree import ElementTree as ET
from .drawingml_context import ConvertContext
from .drawingml_utils import (
from .context import ConvertContext
from .utils import (
SVG_NS, ANGLE_UNIT, DASH_PRESETS,
px_to_emu, _f, _get_attr,
px_to_emu, _f, _get_attr, parse_svg_length,
parse_hex_color, parse_stop_style, resolve_url_id,
)
@@ -118,7 +118,7 @@ def build_fill_xml(
if fill is None:
fill = '#000000' # SVG default fill is black
if fill == 'none':
if fill.strip().lower() in ('none', 'transparent'):
return '<a:noFill/>'
ref_id = resolve_url_id(fill)
@@ -343,10 +343,10 @@ def build_stroke_xml(
) -> str:
"""Build <a:ln> XML for stroke, with inherited style support."""
stroke = _get_attr(elem, 'stroke', ctx)
if not stroke or stroke == 'none':
if not stroke or stroke.strip().lower() in ('none', 'transparent'):
return '<a:ln><a:noFill/></a:ln>'
width = _f(_get_attr(elem, 'stroke-width', ctx), 1.0)
width = parse_svg_length(_get_attr(elem, 'stroke-width', ctx), 1.0)
width_emu = px_to_emu(width)
# Dash pattern
@@ -6,7 +6,7 @@ import re
import math
from xml.etree import ElementTree as ET
from .drawingml_context import AffineMatrix, ConvertContext, IDENTITY_MATRIX
from .context import AffineMatrix, ConvertContext, IDENTITY_MATRIX
# ---------------------------------------------------------------------------
# Constants
@@ -155,6 +155,66 @@ def _f(val: str | None, default: float = 0.0) -> float:
return default
_LENGTH_RE = re.compile(r'^\s*([-+]?(?:\d*\.\d+|\d+\.?)(?:[eE][-+]?\d+)?)\s*([A-Za-z%]*)\s*$')
def parse_svg_length(
val: str | None,
default: float = 0.0,
*,
percent_base: float | None = None,
font_size: float = 16.0,
) -> float:
"""Parse SVG/CSS length values into SVG px.
Unitless and ``px`` values are already SVG px. Percentages need a caller
supplied reference length because SVG uses different bases for x, y,
width, height, and radii.
"""
if val is None:
return default
match = _LENGTH_RE.match(str(val))
if not match:
return default
number = float(match.group(1))
unit = match.group(2).lower() or 'px'
if unit == '%':
if percent_base is None:
return default
return percent_base * number / 100.0
if unit in ('', 'px'):
return number
if unit == 'pt':
return number * 96.0 / 72.0
if unit in ('pc', 'pica'):
return number * 16.0
if unit == 'in':
return number * 96.0
if unit == 'cm':
return number * 96.0 / 2.54
if unit == 'mm':
return number * 96.0 / 25.4
if unit == 'q':
return number * 96.0 / 101.6
if unit in ('em', 'rem'):
return number * font_size
return default
def svg_length_x(val: str | None, ctx: ConvertContext, default: float = 0.0) -> float:
return parse_svg_length(val, default, percent_base=ctx.viewport_width)
def svg_length_y(val: str | None, ctx: ConvertContext, default: float = 0.0) -> float:
return parse_svg_length(val, default, percent_base=ctx.viewport_height)
def svg_length_size(val: str | None, ctx: ConvertContext, default: float = 0.0) -> float:
base = min(ctx.viewport_width, ctx.viewport_height)
return parse_svg_length(val, default, percent_base=base)
# ---------------------------------------------------------------------------
# SVG transform matrix helpers
# ---------------------------------------------------------------------------
@@ -296,11 +356,19 @@ def _extract_inheritable_styles(elem: ET.Element) -> dict[str, str]:
val = elem.get(attr)
if val is not None:
styles[attr] = val
styles.update({
attr: val
for attr, val in parse_inline_style(elem.get('style')).items()
if attr in INHERITABLE_ATTRS
})
return styles
def _get_attr(elem: ET.Element, attr: str, ctx: ConvertContext) -> str | None:
"""Get effective attribute: element's own value first, then inherited."""
style_val = parse_inline_style(elem.get('style')).get(attr)
if style_val is not None:
return style_val
val = elem.get(attr)
if val is not None:
return val
@@ -331,11 +399,82 @@ def ctx_h(val: float, ctx: ConvertContext) -> float:
# Color / style parsing
# ---------------------------------------------------------------------------
_CSS_NAMED_COLORS = {
'black': '000000',
'silver': 'C0C0C0',
'gray': '808080',
'grey': '808080',
'white': 'FFFFFF',
'maroon': '800000',
'red': 'FF0000',
'purple': '800080',
'fuchsia': 'FF00FF',
'magenta': 'FF00FF',
'green': '008000',
'lime': '00FF00',
'olive': '808000',
'yellow': 'FFFF00',
'navy': '000080',
'blue': '0000FF',
'teal': '008080',
'aqua': '00FFFF',
'cyan': '00FFFF',
'orange': 'FFA500',
'brown': 'A52A2A',
'pink': 'FFC0CB',
'gold': 'FFD700',
'transparent': None,
'lightgray': 'D3D3D3',
'lightgrey': 'D3D3D3',
'darkgray': 'A9A9A9',
'darkgrey': 'A9A9A9',
}
def parse_inline_style(style_str: str | None) -> dict[str, str]:
"""Parse an SVG inline style declaration into ``property: value`` pairs."""
styles: dict[str, str] = {}
if not style_str:
return styles
for part in style_str.split(';'):
if ':' not in part:
continue
name, value = part.split(':', 1)
name = name.strip().lower()
value = value.strip()
if name and value:
styles[name] = value
return styles
def _parse_color_channel(raw: str) -> int:
raw = raw.strip()
if raw.endswith('%'):
value = float(raw[:-1]) * 255.0 / 100.0
else:
value = float(raw)
return max(0, min(255, int(round(value))))
def parse_hex_color(color_str: str) -> str | None:
"""Parse '#RRGGBB' or '#RGB' to 'RRGGBB'. Returns None on failure."""
"""Parse SVG color values to 'RRGGBB'. Returns None on failure."""
if not color_str:
return None
color_str = color_str.strip()
named = _CSS_NAMED_COLORS.get(color_str.lower())
if named is not None or color_str.lower() in _CSS_NAMED_COLORS:
return named
rgb_match = re.match(r'rgba?\((.+)\)$', color_str, flags=re.IGNORECASE)
if rgb_match:
channels = re.findall(r'[-+]?(?:\d*\.\d+|\d+\.?)(?:[eE][-+]?\d+)?%?', rgb_match.group(1))
if len(channels) >= 3:
try:
r, g, b = (_parse_color_channel(ch) for ch in channels[:3])
return f'{r:02X}{g:02X}{b:02X}'
except ValueError:
return None
if color_str.startswith('#'):
color_str = color_str[1:]
if len(color_str) == 3:
@@ -0,0 +1,229 @@
"""Native PowerPoint table/chart converters for explicit SVG metadata markers."""
from __future__ import annotations
import sys
from typing import Any
from xml.etree import ElementTree as ET
from ..drawingml.context import ConvertContext, ShapeResult
from ..drawingml.utils import _xml_escape
from .chart_data import _chart_data
from .chart_style import (
_axis_titles,
_chart_companion_entries,
_chart_companion_text_xml,
_chart_text_sizes,
_classic_chart_style,
_native_chart_chrome_errors,
_native_chart_chrome_warnings,
_native_chart_export_payload,
)
from .chart_xml import _chart_rels_xml, _chart_xml
from .chartex import (
_chart_ex_colors_xml,
_chart_ex_rels_xml,
_chart_ex_style_xml,
_chart_ex_xml,
)
from .marker_common import (
CHART_CONTENT_TYPE,
CHARTEX_CONTENT_TYPE,
CHARTEX_REL_TYPE,
CHARTEX_URI,
CHART_COLOR_STYLE_CONTENT_TYPE,
CHART_REL_TYPE,
CHART_STYLE_CONTENT_TYPE,
CHART_URI,
_NATIVE_KINDS,
_bounds,
_load_payload,
_local_tag,
_validate_bounds_inputs,
)
from .table import (
_build_native_table,
_native_table_warnings,
_validate_table_payload,
)
from .workbook import (
_minimal_category_chart_workbook,
_minimal_chart_ex_workbook,
_minimal_xy_chart_workbook,
)
__all__ = [
"convert_native_object",
"native_object_marker_warnings",
"validate_native_object_marker",
"validate_native_object_marker_with_warnings",
]
def _build_native_chart(elem: ET.Element, ctx: ConvertContext, payload: dict[str, Any]) -> ShapeResult:
chart_data = _chart_data(payload)
off_x, off_y, ext_cx, ext_cy = _bounds(elem, payload, ctx)
shape_id = ctx.next_id()
rel_id = ctx.next_rel_id()
local_index = 1 + sum(1 for part in ctx.package_files if part.startswith("ppt/charts/chart"))
part_index = ctx.slide_num * 100 + local_index
workbook_name = f"Microsoft_Excel_Sheet{part_index}.xlsx"
workbook_part = f"ppt/embeddings/{workbook_name}"
if chart_data["kind"] == "chartex":
chart_name = f"chartEx{part_index}.xml"
style_name = f"style{part_index}.xml"
colors_name = f"colors{part_index}.xml"
chart_part = f"ppt/charts/{chart_name}"
chart_rels_part = f"ppt/charts/_rels/{chart_name}.rels"
style_part = f"ppt/charts/{style_name}"
colors_part = f"ppt/charts/{colors_name}"
graphic_uri = CHARTEX_URI
chart_ref_xml = (
f'<cx:chart xmlns:cx="{CHARTEX_URI}" '
f'xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" '
f'r:id="{rel_id}"/>'
)
ctx.rel_entries.append({
"id": rel_id,
"type": CHARTEX_REL_TYPE,
"target": f"../charts/{chart_name}",
})
ctx.package_files[chart_part] = _chart_ex_xml(payload, chart_data, chart_rels_id="rId1")
ctx.package_files[chart_rels_part] = _chart_ex_rels_xml(
f"../embeddings/{workbook_name}",
style_name,
colors_name,
)
ctx.package_files[style_part] = _chart_ex_style_xml()
ctx.package_files[colors_part] = _chart_ex_colors_xml()
ctx.package_files[workbook_part] = _minimal_chart_ex_workbook(chart_data)
ctx.content_type_overrides[chart_part] = CHARTEX_CONTENT_TYPE
ctx.content_type_overrides[style_part] = CHART_STYLE_CONTENT_TYPE
ctx.content_type_overrides[colors_part] = CHART_COLOR_STYLE_CONTENT_TYPE
else:
chart_name = f"chart{part_index}.xml"
chart_part = f"ppt/charts/{chart_name}"
chart_rels_part = f"ppt/charts/_rels/{chart_name}.rels"
graphic_uri = CHART_URI
chart_ref_xml = (
'<c:chart xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart" '
f'r:id="{rel_id}"/>'
)
ctx.rel_entries.append({
"id": rel_id,
"type": CHART_REL_TYPE,
"target": f"../charts/{chart_name}",
})
ctx.package_files[chart_part] = _chart_xml(
elem,
payload,
chart_rels_id="rId1",
chart_data=chart_data,
inherited_styles=ctx.inherited_styles,
)
ctx.package_files[chart_rels_part] = _chart_rels_xml(f"../embeddings/{workbook_name}")
if chart_data["kind"] == "xy":
ctx.package_files[workbook_part] = _minimal_xy_chart_workbook(chart_data)
else:
ctx.package_files[workbook_part] = _minimal_category_chart_workbook(chart_data)
ctx.content_type_overrides[chart_part] = CHART_CONTENT_TYPE
name = _xml_escape(str(payload.get("name") or elem.get("id") or f"Native Chart {shape_id}"))
chart_frame_xml = f'''<p:graphicFrame>
<p:nvGraphicFramePr>
<p:cNvPr id="{shape_id}" name="{name}"/>
<p:cNvGraphicFramePr><a:graphicFrameLocks noGrp="1"/></p:cNvGraphicFramePr>
<p:nvPr/>
</p:nvGraphicFramePr>
<p:xfrm><a:off x="{off_x}" y="{off_y}"/><a:ext cx="{ext_cx}" cy="{ext_cy}"/></p:xfrm>
<a:graphic>
<a:graphicData uri="{graphic_uri}">
{chart_ref_xml}
</a:graphicData>
</a:graphic>
</p:graphicFrame>'''
text_sizes = _chart_text_sizes(payload, elem, ctx.inherited_styles)
chart_style = _classic_chart_style(payload, elem, ctx.inherited_styles)
companion_xml = _chart_companion_text_xml(
ctx,
payload,
chart_bounds=(off_x, off_y, ext_cx, ext_cy),
chart_style=chart_style,
note_font_size=text_sizes["note"],
title_font_size=text_sizes["title"],
include_title=chart_data["kind"] == "chartex",
include_subtitle_as_caption=chart_data["kind"] == "chartex",
)
xml = chart_frame_xml + companion_xml
return ShapeResult(xml=xml, bounds_emu=(off_x, off_y, off_x + ext_cx, off_y + ext_cy))
def _validate_native_object_marker_payload(
elem: ET.Element,
*,
validate_chrome: bool = True,
) -> tuple[str, dict[str, Any], list[list[Any]] | None]:
kind = (elem.get("data-pptx-native") or "").strip().lower()
if not kind:
return "", {}, None
if kind not in _NATIVE_KINDS:
raise RuntimeError(f"Unsupported data-pptx-native value: {kind}")
if _local_tag(elem) != "g":
raise RuntimeError("Native PPTX table/chart markers must be <g> elements")
transform = elem.get("transform", "")
if transform and any(token in transform for token in ("rotate", "matrix", "skew")):
raise RuntimeError("Native PPTX table/chart markers support translate/scale transforms only")
payload = _load_payload(elem, kind)
_validate_bounds_inputs(elem, payload)
table_rows = None
if kind == "table":
table_rows, _ = _validate_table_payload(payload)
else:
_chart_data(payload)
if validate_chrome:
chrome_errors = _native_chart_chrome_errors(elem, payload)
if chrome_errors:
raise RuntimeError("; ".join(chrome_errors))
return kind, payload, table_rows
def validate_native_object_marker(elem: ET.Element) -> None:
"""Validate a data-pptx-native marker without mutating the PPTX package."""
_validate_native_object_marker_payload(elem)
def validate_native_object_marker_with_warnings(elem: ET.Element) -> list[str]:
"""Validate a data-pptx-native marker and return non-fatal warnings."""
kind, payload, table_rows = _validate_native_object_marker_payload(elem)
if kind == "table" and table_rows is not None:
return _native_table_warnings(elem, table_rows)
if kind == "chart":
return _native_chart_chrome_warnings(elem, payload)
return []
def native_object_marker_warnings(elem: ET.Element) -> list[str]:
"""Return non-fatal warnings for a data-pptx-native marker."""
return validate_native_object_marker_with_warnings(elem)
def convert_native_object(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None:
"""Convert a marked SVG group to a native PowerPoint table or chart."""
kind = (elem.get("data-pptx-native") or "").strip().lower()
if not kind:
return None
kind, payload, _ = _validate_native_object_marker_payload(elem, validate_chrome=False)
if kind == "table":
return _build_native_table(elem, ctx, payload)
payload, warnings = _native_chart_export_payload(elem, payload)
marker_id = elem.get("id") or "<unnamed>"
for warning in warnings:
print(
f" Warning: data-pptx-native marker {marker_id}: {warning}",
file=sys.stderr,
)
return _build_native_chart(elem, ctx, payload)
@@ -0,0 +1,800 @@
"""Native chart metadata normalization."""
from __future__ import annotations
from typing import Any
from .marker_common import (
_chart_bool,
_clean_hex,
_compact_key,
_first_present,
_number,
)
def _chart_number(value: Any) -> int | float:
if isinstance(value, bool):
raise RuntimeError("Native PPTX chart values must be numeric")
try:
number = float(value)
except (TypeError, ValueError) as exc:
raise RuntimeError(f"Native PPTX chart value is not numeric: {value}") from exc
return int(number) if number.is_integer() else number
def _chart_list(value: Any, field_name: str) -> list[Any]:
if value is None:
return []
if not isinstance(value, list):
raise RuntimeError(f"Native PPTX chart {field_name} must be a list")
return value
def _data_labels_config(payload: dict[str, Any]) -> dict[str, Any] | None:
raw = _first_present(payload.get("data_labels"), payload.get("dataLabels"))
if raw is None:
return None
if isinstance(raw, bool):
return {} if raw else None
if not isinstance(raw, dict):
raise RuntimeError("Native PPTX chart data_labels must be a boolean or object")
return raw
_CATEGORY_CHART_TYPES = {
"area",
"bar",
"column",
"doughnut",
"line",
"of_pie",
"pie",
"radar",
}
_XY_CHART_TYPES = {"scatter", "bubble"}
_CHARTEX_CHART_TYPES = {
"box_whisker",
"funnel",
"histogram",
"pareto",
"sunburst",
"treemap",
"waterfall",
}
_DEFERRED_CHART_TYPES = {
"bullet",
"gantt",
"heatmap",
"map",
}
_UNSUPPORTED_3D_CHART_TYPES = {
"area3d",
"bar3d",
"column3d",
"line3d",
"pie3d",
"surface",
}
_DEFAULT_CHART_COLORS = [
"4472C4",
"ED7D31",
"A5A5A5",
"FFC000",
"5B9BD5",
"70AD47",
"264478",
"9E480E",
]
def _chart_kind(payload: dict[str, Any]) -> tuple[str, str | None, str | None]:
raw_type = payload.get("type") or payload.get("chart_type") or "column"
key = _compact_key(raw_type)
aliases: dict[str, tuple[str, str | None, str | None]] = {
"area": ("area", "standard", None),
"areastacked": ("area", "stacked", None),
"areastacked100": ("area", "percentStacked", None),
"area100": ("area", "percentStacked", None),
"bar": ("bar", "clustered", None),
"barofpie": ("of_pie", None, "bar"),
"barclustered": ("bar", "clustered", None),
"barstacked": ("bar", "stacked", None),
"barstacked100": ("bar", "percentStacked", None),
"boxandwhisker": ("box_whisker", None, None),
"boxplot": ("box_whisker", None, None),
"boxwhisker": ("box_whisker", None, None),
"bubble": ("bubble", None, None),
"bullet": ("bullet", None, None),
"bulletchart": ("bullet", None, None),
"combo": ("combo", None, None),
"combochart": ("combo", None, None),
"choropleth": ("map", None, None),
"conebarclustered": ("bar3d", "clustered", "cone"),
"conebarstacked": ("bar3d", "stacked", "cone"),
"conebarstacked100": ("bar3d", "percentStacked", "cone"),
"conecol": ("column3d", "clustered", "cone"),
"conecolclustered": ("column3d", "clustered", "cone"),
"conecolstacked": ("column3d", "stacked", "cone"),
"conecolstacked100": ("column3d", "percentStacked", "cone"),
"col": ("column", "clustered", None),
"column": ("column", "clustered", None),
"columnclustered": ("column", "clustered", None),
"columnstacked": ("column", "stacked", None),
"columnstacked100": ("column", "percentStacked", None),
"contour": ("surface", None, "topView"),
"contourwireframe": ("surface", None, "topViewWireframe"),
"cylinderbarclustered": ("bar3d", "clustered", "cylinder"),
"cylinderbarstacked": ("bar3d", "stacked", "cylinder"),
"cylinderbarstacked100": ("bar3d", "percentStacked", "cylinder"),
"cylindercol": ("column3d", "clustered", "cylinder"),
"cylindercolclustered": ("column3d", "clustered", "cylinder"),
"cylindercolstacked": ("column3d", "stacked", "cylinder"),
"cylindercolstacked100": ("column3d", "percentStacked", "cylinder"),
"doughnut": ("doughnut", None, None),
"doughnutexploded": ("doughnut", None, "exploded"),
"donut": ("doughnut", None, None),
"donutexploded": ("doughnut", None, "exploded"),
"filledmap": ("map", None, None),
"funnel": ("funnel", None, None),
"funnelchart": ("funnel", None, None),
"gantt": ("gantt", None, None),
"ganttchart": ("gantt", None, None),
"geo": ("map", None, None),
"geomap": ("map", None, None),
"heatmap": ("heatmap", None, None),
"heatmapchart": ("heatmap", None, None),
"histogram": ("histogram", None, None),
"histogramchart": ("histogram", None, None),
"line": ("line", "standard", "line"),
"linemarkers": ("line", "standard", "lineMarker"),
"linemarkersstacked": ("line", "stacked", "lineMarker"),
"linemarkersstacked100": ("line", "percentStacked", "lineMarker"),
"linestacked": ("line", "stacked", "line"),
"linestacked100": ("line", "percentStacked", "line"),
"linestackedmarkers": ("line", "stacked", "lineMarker"),
"linestackedmarkers100": ("line", "percentStacked", "lineMarker"),
"pie": ("pie", None, None),
"pieexploded": ("pie", None, "exploded"),
"ofpie": ("of_pie", None, "pie"),
"pieofpie": ("of_pie", None, "pie"),
"pareto": ("pareto", None, None),
"paretochart": ("pareto", None, None),
"pyramidbarclustered": ("bar3d", "clustered", "pyramid"),
"pyramidbarstacked": ("bar3d", "stacked", "pyramid"),
"pyramidbarstacked100": ("bar3d", "percentStacked", "pyramid"),
"pyramidcol": ("column3d", "clustered", "pyramid"),
"pyramidcolclustered": ("column3d", "clustered", "pyramid"),
"pyramidcolstacked": ("column3d", "stacked", "pyramid"),
"pyramidcolstacked100": ("column3d", "percentStacked", "pyramid"),
"radar": ("radar", None, "line"),
"radarfilled": ("radar", None, "filled"),
"radarmarkers": ("radar", None, "lineMarker"),
"scatter": ("scatter", None, "marker"),
"stock": ("stock", None, "hlc"),
"stockhlc": ("stock", None, "hlc"),
"stockohlc": ("stock", None, "ohlc"),
"stockvhlc": ("stock", None, "vhlc"),
"stockvohlc": ("stock", None, "vohlc"),
"surface": ("surface", None, "surface3D"),
"surface3d": ("surface", None, "surface3D"),
"surfacewireframe": ("surface", None, "surface3DWireframe"),
"surfacetopview": ("surface", None, "topView"),
"surfacetopviewwireframe": ("surface", None, "topViewWireframe"),
"sunburst": ("sunburst", None, None),
"sunburstchart": ("sunburst", None, None),
"map": ("map", None, None),
"mapchart": ("map", None, None),
"threedarea": ("area3d", "standard", None),
"threedareastacked": ("area3d", "stacked", None),
"threedareastacked100": ("area3d", "percentStacked", None),
"threedbar": ("bar3d", "clustered", "box"),
"threedbarclustered": ("bar3d", "clustered", "box"),
"threedbarstacked": ("bar3d", "stacked", "box"),
"threedbarstacked100": ("bar3d", "percentStacked", "box"),
"threedcolumn": ("column3d", "clustered", "box"),
"threedcolumnclustered": ("column3d", "clustered", "box"),
"threedcolumnstacked": ("column3d", "stacked", "box"),
"threedcolumnstacked100": ("column3d", "percentStacked", "box"),
"threedline": ("line3d", "standard", None),
"threedpie": ("pie3d", None, None),
"threedpieexploded": ("pie3d", None, "exploded"),
"treemap": ("treemap", None, None),
"treemapchart": ("treemap", None, None),
"waterfall": ("waterfall", None, None),
"waterfallchart": ("waterfall", None, None),
"xy": ("scatter", None, "marker"),
"xyscatter": ("scatter", None, "marker"),
"xyscatterlines": ("scatter", None, "lineMarker"),
"xyscatterlinesnomarkers": ("scatter", None, "line"),
"xyscattersmooth": ("scatter", None, "smoothMarker"),
"xyscattersmoothnomarkers": ("scatter", None, "smooth"),
}
if key.startswith("100percentstacked"):
key = key.replace("100percentstacked", "", 1) + "stacked100"
if key.startswith("percentstacked"):
key = key.replace("percentstacked", "", 1) + "stacked100"
if key.startswith("3d"):
key = "threed" + key[2:]
chart_type, grouping, style = aliases.get(key, (key, None, None))
if chart_type in _UNSUPPORTED_3D_CHART_TYPES:
raise RuntimeError("Native PPTX 3D charts are intentionally unsupported")
if chart_type in _DEFERRED_CHART_TYPES:
raise RuntimeError(
f"Native PPTX {chart_type} chart is outside current basic chart support"
)
supported = sorted(_CATEGORY_CHART_TYPES | _XY_CHART_TYPES | _CHARTEX_CHART_TYPES | {"combo", "stock"})
if chart_type not in supported:
raise RuntimeError(f"Native PPTX chart type must be one of: {', '.join(supported)}")
return chart_type, grouping, style
def _chart_grouping(
chart_type: str,
payload: dict[str, Any],
alias_grouping: str | None,
) -> str | None:
grouping = payload.get("grouping") or payload.get("chart_grouping") or alias_grouping
if not grouping and payload.get("stacked"):
grouping = "stacked"
if not grouping:
return "clustered" if chart_type in {"bar", "column"} else "standard"
aliases = {
"100": "percentStacked",
"100percent": "percentStacked",
"100percentstacked": "percentStacked",
"clustered": "clustered",
"percent": "percentStacked",
"percentstacked": "percentStacked",
"stacked": "stacked",
"standard": "standard",
}
normalized = aliases.get(_compact_key(grouping))
if chart_type in {"bar", "column"}:
allowed = {"clustered", "stacked", "percentStacked"}
elif chart_type in {"area", "line"}:
allowed = {"standard", "stacked", "percentStacked"}
else:
allowed = {"standard"}
if normalized not in allowed:
if normalized in {"clustered", "standard"}:
allowed_text = ", ".join(sorted(allowed))
raise RuntimeError(f"Native PPTX {chart_type} chart grouping must be one of: {allowed_text}")
raise RuntimeError(
f"Native PPTX {grouping} grouping is outside current basic chart support"
)
return normalized
def _line_style(payload: dict[str, Any], alias_style: str | None) -> str:
raw_style = payload.get("line_style") or payload.get("lineStyle") or alias_style
if raw_style is None:
raw_style = "lineMarker" if payload.get("markers") else "line"
aliases = {
"line": "line",
"linemarker": "lineMarker",
"marker": "lineMarker",
"markers": "lineMarker",
"none": "line",
"nomarker": "line",
"nomarkers": "line",
}
style = aliases.get(_compact_key(raw_style))
if not style:
raise RuntimeError("Native PPTX line_style must be one of: line, lineMarker")
return style
def _radar_style(payload: dict[str, Any], alias_style: str | None) -> tuple[str, str | None]:
raw_style = payload.get("radar_style") or payload.get("radarStyle") or alias_style or "line"
aliases = {
"filled": ("filled", None),
"line": ("marker", "none"),
"linemarker": ("marker", "circle"),
"marker": ("marker", "none"),
"markers": ("marker", "circle"),
"standard": ("marker", "none"),
}
style = aliases.get(_compact_key(raw_style))
if not style:
raise RuntimeError(
f"Native PPTX radar_style {raw_style} is outside current basic chart support"
)
return style
def _category_series(payload: dict[str, Any], categories: list[str]) -> list[dict[str, Any]]:
raw_series = payload.get("series", [])
if not categories or not isinstance(raw_series, list) or not raw_series:
raise RuntimeError("Native PPTX chart requires non-empty categories and series")
root_point_colors = _first_present(
payload.get("point_colors"),
payload.get("pointColors"),
)
if root_point_colors is not None and len(raw_series) != 1:
raise RuntimeError("Native PPTX chart root point_colors is only valid for one series")
series: list[dict[str, Any]] = []
for idx, item in enumerate(raw_series, start=1):
if not isinstance(item, dict):
raise RuntimeError("Native PPTX chart series entries must be objects")
values = [
_chart_number(value)
for value in _chart_list(item.get("values", []), "series[].values")
]
if len(values) != len(categories):
raise RuntimeError("Native PPTX chart series values must match categories length")
raw_point_colors = _first_present(
item.get("point_colors"),
item.get("pointColors"),
root_point_colors if idx == 1 else None,
)
point_colors = [
_clean_hex(color, "#4472C4")
for color in _chart_list(raw_point_colors, "series[].point_colors")
]
if point_colors and len(point_colors) != len(values):
raise RuntimeError("Native PPTX chart series point_colors must match values length")
series_item = {"name": str(item.get("name") or f"Series {idx}"), "values": values}
if point_colors:
series_item["point_colors"] = point_colors
fill_opacity = _first_present(
item.get("fill_opacity"),
item.get("fillOpacity"),
)
if fill_opacity is not None:
series_item["fill_opacity"] = fill_opacity
line_width = _first_present(
item.get("line_width"),
item.get("lineWidth"),
)
if line_width is not None:
series_item["line_width"] = line_width
series.append(series_item)
return series
def _category_chart_data(
payload: dict[str, Any],
chart_type: str,
alias_grouping: str | None,
alias_style: str | None,
) -> dict[str, Any]:
categories = [str(item) for item in _chart_list(payload.get("categories", []), "categories")]
style = payload.get("style") if isinstance(payload.get("style"), dict) else {}
series = _category_series(payload, categories)
if chart_type in {"doughnut", "of_pie", "pie"}:
if len(series) != 1:
raise RuntimeError("Native PPTX pie-family charts support exactly one series")
of_pie_type = None
if chart_type == "of_pie":
raw_of_pie_type = (
payload.get("of_pie_type")
or payload.get("ofPieType")
or payload.get("secondary_type")
or alias_style
or "pie"
)
of_pie_aliases = {
"bar": "bar",
"barofpie": "bar",
"pie": "pie",
"pieofpie": "pie",
}
of_pie_type = of_pie_aliases.get(_compact_key(raw_of_pie_type))
if not of_pie_type:
raise RuntimeError("Native PPTX of_pie_type must be one of: bar, pie")
line_style = _line_style(payload, alias_style) if chart_type == "line" else None
radar_style = None
radar_marker_style = None
if chart_type == "radar":
radar_style, radar_marker_style = _radar_style(payload, alias_style)
if alias_style == "exploded" or payload.get("exploded"):
raise RuntimeError("Native PPTX exploded pie/doughnut is outside current basic chart support")
return {
"kind": "category",
"type": chart_type,
"categories": categories,
"grouping": _chart_grouping(chart_type, payload, alias_grouping)
if chart_type in {"bar", "column", "line", "area"}
else None,
"of_pie_type": of_pie_type,
"line_style": line_style,
"radar_marker_style": radar_marker_style,
"radar_style": radar_style,
"show_value_axis_labels": _chart_bool(
_first_present(
payload.get("show_value_axis_labels"),
payload.get("showValueAxisLabels"),
style.get("show_value_axis_labels"),
style.get("showValueAxisLabels"),
),
True,
),
"data_labels": _data_labels_config(payload),
"series": series,
}
def _combo_axis_name(plot_payload: dict[str, Any]) -> str:
axis = plot_payload.get("axis") or plot_payload.get("value_axis")
if axis is None and plot_payload.get("secondary_axis"):
axis = "secondary"
axis_key = _compact_key(axis or "primary")
aliases = {
"left": "primary",
"primary": "primary",
"right": "secondary",
"secondary": "secondary",
"secondaryaxis": "secondary",
}
normalized = aliases.get(axis_key)
if not normalized:
raise RuntimeError("Native PPTX combo plot axis must be primary or secondary")
return normalized
def _combo_plot_type(plot_payload: dict[str, Any]) -> tuple[str, str | None, str | None]:
chart_type, alias_grouping, alias_style = _chart_kind(plot_payload)
if chart_type not in {"area", "column", "line"}:
raise RuntimeError("Native PPTX combo plots support column, line, and area only")
has_area_fill = bool(_first_present(plot_payload.get("area_fill"), plot_payload.get("areaFill")))
if chart_type == "line" and has_area_fill:
chart_type = "area"
return chart_type, alias_grouping, alias_style
def _plot_series_area_style(plot_payload: dict[str, Any]) -> bool:
for item in _chart_list(plot_payload.get("series", []), "series"):
if not isinstance(item, dict):
continue
if _first_present(
item.get("fill_opacity"),
item.get("fillOpacity"),
) is not None:
return True
return False
def _combo_plot_entry(
plot_payload: dict[str, Any],
categories: list[str],
*,
fallback_series: list[dict[str, Any]] | None = None,
) -> dict[str, Any]:
chart_type, alias_grouping, alias_style = _combo_plot_type(plot_payload)
if chart_type == "line" and _plot_series_area_style(plot_payload):
raise RuntimeError(
"Native PPTX combo line plot with series fill_opacity requires area_fill: true"
)
plot_series = fallback_series or _category_series(plot_payload, categories)
entry: dict[str, Any] = {
"axis": _combo_axis_name(plot_payload),
"data_labels": _data_labels_config(plot_payload),
"grouping": _chart_grouping(chart_type, plot_payload, alias_grouping)
if chart_type in {"area", "column", "line"}
else None,
"series": plot_series,
"type": chart_type,
}
if chart_type == "line":
entry["line_style"] = _line_style(plot_payload, alias_style)
return entry
def _combo_chart_data(payload: dict[str, Any]) -> dict[str, Any]:
categories = [str(item) for item in _chart_list(payload.get("categories", []), "categories")]
raw_plots = payload.get("plots", payload.get("chart_plots"))
plots: list[dict[str, Any]] = []
if raw_plots is not None:
for item in _chart_list(raw_plots, "plots"):
if not isinstance(item, dict):
raise RuntimeError("Native PPTX combo plots must be objects")
plots.append(_combo_plot_entry(item, categories))
else:
raw_series = _chart_list(payload.get("series", []), "series")
if not raw_series:
raise RuntimeError("Native PPTX combo chart requires plots or typed series")
for idx, item in enumerate(raw_series, start=1):
if not isinstance(item, dict):
raise RuntimeError("Native PPTX chart series entries must be objects")
if not (item.get("type") or item.get("chart_type")):
raise RuntimeError("Native PPTX combo series entries require type")
one_series = _category_series({"series": [item]}, categories)
plot = _combo_plot_entry(item, categories, fallback_series=one_series)
signature = (
plot["axis"],
plot.get("grouping"),
plot.get("line_style"),
plot["type"],
)
previous = plots[-1] if plots else None
previous_signature = (
previous.get("axis"),
previous.get("grouping"),
previous.get("line_style"),
previous.get("type"),
) if previous else None
if previous is not None and signature == previous_signature:
previous["series"].extend(plot["series"])
else:
plots.append(plot)
if not plots:
raise RuntimeError("Native PPTX combo chart requires at least one plot")
flat_series: list[dict[str, Any]] = []
for plot in plots:
plot["start_index"] = len(flat_series)
flat_series.extend(plot["series"])
if not flat_series:
raise RuntimeError("Native PPTX combo chart requires at least one series")
return {
"categories": categories,
"kind": "combo",
"plots": plots,
"series": flat_series,
"type": "combo",
}
def _chart_values(payload: dict[str, Any], field_name: str = "values") -> list[int | float]:
raw_values = payload.get(field_name)
if raw_values is None and isinstance(payload.get("series"), list) and payload["series"]:
first_series = payload["series"][0]
if isinstance(first_series, dict):
raw_values = first_series.get("values")
values = [_chart_number(value) for value in _chart_list(raw_values, field_name)]
if not values:
raise RuntimeError(f"Native PPTX chart {field_name} must be a non-empty list")
return values
def _chart_categories(payload: dict[str, Any], count: int | None = None) -> list[str]:
raw_categories = payload.get("categories", payload.get("labels", []))
categories = [str(item) for item in _chart_list(raw_categories, "categories")]
if count is not None:
if not categories:
categories = [f"Category {idx + 1}" for idx in range(count)]
if len(categories) != count:
raise RuntimeError("Native PPTX chart categories length must match values length")
elif not categories:
raise RuntimeError("Native PPTX chart requires non-empty categories")
return categories
def _hierarchy_levels(payload: dict[str, Any], count: int) -> list[list[str]]:
raw_levels = payload.get("levels")
if raw_levels is not None:
levels = [
[str(value) for value in _chart_list(level, "levels[]")]
for level in _chart_list(raw_levels, "levels")
]
else:
raw_categories = _chart_list(payload.get("categories", []), "categories")
if raw_categories and all(isinstance(item, list) for item in raw_categories):
path_rows = [[str(value) for value in item] for item in raw_categories]
else:
path_rows = [[str(item)] for item in raw_categories]
if len(path_rows) != count:
raise RuntimeError("Native PPTX hierarchical chart categories length must match values length")
max_depth = max((len(row) for row in path_rows), default=0)
levels = [
[row[depth] if depth < len(row) else "" for row in path_rows]
for depth in range(max_depth)
]
if not levels:
raise RuntimeError("Native PPTX hierarchical charts require levels or path categories")
for level in levels:
if len(level) != count:
raise RuntimeError("Native PPTX hierarchical chart levels must match values length")
return levels
def _treemap_parent_labels(payload: dict[str, Any]) -> str:
raw = payload.get("parent_label_layout", payload.get("parent_labels", "overlapping"))
aliases = {
"banner": "banner",
"none": "none",
"overlapping": "overlapping",
}
layout = aliases.get(_compact_key(raw))
if not layout:
raise RuntimeError(
"Native PPTX treemap parent_label_layout must be one of: banner, none, overlapping"
)
return layout
def _chartex_chart_data(payload: dict[str, Any], chart_type: str) -> dict[str, Any]:
if chart_type in {"sunburst", "treemap"}:
values = _chart_values(payload)
levels = _hierarchy_levels(payload, len(values))
data = {
"kind": "chartex",
"levels": levels,
"type": chart_type,
"values": values,
}
if chart_type == "treemap":
data["parent_labels"] = _treemap_parent_labels(payload)
return data
if chart_type == "histogram":
return {
"kind": "chartex",
"type": chart_type,
"values": _chart_values(payload),
}
if chart_type in {"funnel", "pareto", "waterfall"}:
values = _chart_values(payload)
data = {
"categories": _chart_categories(payload, len(values)),
"kind": "chartex",
"type": chart_type,
"values": values,
}
if chart_type == "waterfall":
subtotals = payload.get("subtotals", payload.get("subtotal_indices", []))
data["subtotals"] = [
int(_chart_number(value))
for value in _chart_list(subtotals, "subtotals")
]
return data
if chart_type == "box_whisker":
raw_series = _chart_list(payload.get("series", []), "series")
if not raw_series:
raise RuntimeError("Native PPTX boxWhisker chart requires non-empty series")
series: list[dict[str, Any]] = []
for idx, item in enumerate(raw_series, start=1):
if not isinstance(item, dict):
raise RuntimeError("Native PPTX chart series entries must be objects")
values = [_chart_number(value) for value in _chart_list(item.get("values", []), "series[].values")]
if not values:
raise RuntimeError("Native PPTX boxWhisker series values must be non-empty")
categories = item.get("categories")
if categories is None:
categories = [str(item.get("name") or f"Series {idx}")] * len(values)
categories_list = [str(value) for value in _chart_list(categories, "series[].categories")]
if len(categories_list) != len(values):
raise RuntimeError("Native PPTX boxWhisker series categories must match values length")
series.append({
"categories": categories_list,
"name": str(item.get("name") or f"Series {idx}"),
"values": values,
})
return {
"kind": "chartex",
"series": series,
"type": chart_type,
}
raise RuntimeError(f"Native PPTX {chart_type} chart is outside current basic chart support")
def _stock_chart_data(payload: dict[str, Any]) -> dict[str, Any]:
categories = [
_chart_number(item)
for item in _chart_list(payload.get("categories", payload.get("dates", [])), "categories")
]
if not categories:
raise RuntimeError("Native PPTX stock chart requires non-empty categories or dates")
raw_series = payload.get("series")
if raw_series is None:
field_names = [("open", "Open"), ("high", "High"), ("low", "Low"), ("close", "Close")]
raw_series = [
{"name": default_name, "values": payload.get(field_name, [])}
for field_name, default_name in field_names
]
series = _category_series({"series": raw_series}, categories)
if len(series) != 4:
raise RuntimeError("Native PPTX stock chart requires exactly four series: open, high, low, close")
return {
"categories": categories,
"kind": "category",
"series": series,
"type": "stock",
}
def _point_values(point: Any, *, chart_type: str) -> tuple[Any, Any, Any | None]:
if isinstance(point, dict):
return point.get("x"), point.get("y"), point.get("size", point.get("bubble_size"))
if isinstance(point, (list, tuple)):
if len(point) < 2:
raise RuntimeError("Native PPTX XY chart points require x and y")
size = point[2] if len(point) > 2 else None
return point[0], point[1], size
raise RuntimeError("Native PPTX XY chart points must be objects or arrays")
def _xy_chart_data(
payload: dict[str, Any],
chart_type: str,
alias_style: str | None,
) -> dict[str, Any]:
raw_series = payload.get("series", [])
if not isinstance(raw_series, list) or not raw_series:
raise RuntimeError("Native PPTX XY chart requires non-empty series")
series: list[dict[str, Any]] = []
for idx, item in enumerate(raw_series, start=1):
if not isinstance(item, dict):
raise RuntimeError("Native PPTX chart series entries must be objects")
if item.get("points") is not None:
points = [
_point_values(point, chart_type=chart_type)
for point in _chart_list(item.get("points"), "series[].points")
]
x_values = [_chart_number(point[0]) for point in points]
y_values = [_chart_number(point[1]) for point in points]
size_values = [_chart_number(point[2]) for point in points if point[2] is not None]
else:
x_raw = _chart_list(item.get("x", item.get("xs", [])), "series[].x")
y_raw = _chart_list(
item.get("y", item.get("ys", item.get("values", []))),
"series[].y",
)
size_raw = _chart_list(
item.get("size", item.get("sizes", item.get("bubble_size", []))),
"series[].size",
)
x_values = [_chart_number(value) for value in x_raw]
y_values = [_chart_number(value) for value in y_raw]
size_values = [_chart_number(value) for value in size_raw]
if not x_values or len(x_values) != len(y_values):
raise RuntimeError("Native PPTX XY chart x/y values must be non-empty and same length")
if chart_type == "bubble" and len(size_values) != len(x_values):
raise RuntimeError("Native PPTX bubble chart requires one size per x/y value")
series.append({
"name": str(item.get("name") or f"Series {idx}"),
"sizes": size_values,
"x": x_values,
"y": y_values,
})
scatter_style = _compact_key(payload.get("scatter_style") or alias_style or "marker")
style_aliases = {
"line": "line",
"linemarker": "lineMarker",
"markers": "marker",
"marker": "marker",
"smooth": "smooth",
"smoothmarker": "smoothMarker",
}
if chart_type == "scatter" and scatter_style not in style_aliases:
raise RuntimeError("Native PPTX scatter_style is unsupported")
return {
"kind": "xy",
"type": chart_type,
"scatter_style": style_aliases.get(scatter_style, "marker"),
"series": series,
}
def _chart_data(payload: dict[str, Any]) -> dict[str, Any]:
chart_type, alias_grouping, alias_style = _chart_kind(payload)
if chart_type == "combo":
return _combo_chart_data(payload)
if chart_type in _CHARTEX_CHART_TYPES:
return _chartex_chart_data(payload, chart_type)
if chart_type == "stock":
return _stock_chart_data(payload)
if chart_type in _XY_CHART_TYPES:
return _xy_chart_data(payload, chart_type, alias_style)
return _category_chart_data(payload, chart_type, alias_grouping, alias_style)
@@ -0,0 +1,776 @@
"""Native chart styling and companion text helpers."""
from __future__ import annotations
from typing import Any
from xml.etree import ElementTree as ET
from ..drawingml.context import ConvertContext
from ..drawingml.utils import detect_text_lang, parse_font_family, px_to_emu, _xml_escape
from .chart_data import _DEFAULT_CHART_COLORS
from .marker_common import (
_bool_attr,
_bounds,
_chart_bool,
_clean_hex,
_compact_key,
_fallback_fill_candidates,
_fallback_stroke_colors,
_fallback_text_colors,
_first_present,
_font_size_hpt,
_hex_or_none,
_inferred_chart_background,
_local_tag,
_maybe_number,
_most_common_color,
_number,
_normalized_fallback_text,
_relative_luminance,
_style_attr,
_visible_fallback_texts,
)
def _chart_style_value(payload: dict[str, Any], *keys: str) -> Any:
style = payload.get("style") if isinstance(payload.get("style"), dict) else {}
for source in (payload, style):
for key in keys:
if source.get(key) is not None:
return source.get(key)
return None
def _chart_style_color(
payload: dict[str, Any],
keys: tuple[str, ...],
default: str | None,
) -> str | None:
raw = _chart_style_value(payload, *keys)
if raw is None:
return default
if str(raw).strip().lower() in {"none", "transparent"}:
return None
return _hex_or_none(raw) or default
def _fallback_text_attr_values(
elem: ET.Element,
attr: str,
inherited_value: str | None = None,
) -> list[str]:
tag = _local_tag(elem)
if tag == "metadata" or tag in {"defs", "clipPath", "mask", "filter", "style"}:
return []
if elem.get("display") == "none" or elem.get("visibility") == "hidden":
return []
own_value = _style_attr(elem, attr)
next_value = own_value if own_value is not None else inherited_value
values: list[str] = []
if tag in {"text", "tspan"} and next_value:
values.append(str(next_value).strip())
for child in elem:
values.extend(_fallback_text_attr_values(child, attr, next_value))
return values
def _most_common_value(values: list[str]) -> str | None:
if not values:
return None
counts: dict[str, int] = {}
for value in values:
counts[value] = counts.get(value, 0) + 1
return max(counts.items(), key=lambda item: item[1])[0]
def _most_common_font_size(values: list[str]) -> str | None:
if not values:
return None
counts: dict[str, int] = {}
for value in values:
counts[value] = counts.get(value, 0) + 1
max_count = max(counts.values())
candidates = [value for value, count in counts.items() if count == max_count]
numeric_candidates = [
(float(value), value)
for value in candidates
if _maybe_number(value) is not None
]
if numeric_candidates:
return min(numeric_candidates, key=lambda item: item[0])[1]
return candidates[0]
def _classic_chart_style(
payload: dict[str, Any],
elem: ET.Element,
inherited_styles: dict[str, str] | None = None,
) -> dict[str, str | None]:
inherited_styles = inherited_styles or {}
fallback_background = _inferred_chart_background(elem)
text_color = _most_common_color(
_fallback_text_colors(elem, inherited_styles.get("fill"))
) or "404040"
stroke_colors = _fallback_stroke_colors(elem, inherited_styles.get("stroke"))
darkest_stroke = min(stroke_colors, key=_relative_luminance) if stroke_colors else None
lightest_stroke = max(stroke_colors, key=_relative_luminance) if stroke_colors else None
raw_font_face = _chart_style_value(payload, "font_family", "fontFamily", "font_face", "fontFace")
fallback_font_face = _most_common_value(
_fallback_text_attr_values(
elem,
"font-family",
inherited_styles.get("font-family"),
)
)
font_face = str(raw_font_face).strip() if raw_font_face is not None else fallback_font_face
axis_color = darkest_stroke or text_color
grid_color = (
lightest_stroke
if lightest_stroke and _relative_luminance(lightest_stroke) > _relative_luminance(axis_color)
else "D9DED8"
)
chart_fill = _chart_style_color(
payload,
(
"chart_area_fill",
"chartAreaFill",
"chart_fill",
"chartFill",
"background",
"background_color",
"backgroundColor",
"fill",
),
fallback_background,
)
return {
"axis_color": _chart_style_color(
payload,
("axis_color", "axisColor", "axis_line_color", "axisLineColor"),
axis_color,
),
"chart_fill": chart_fill,
"grid_color": _chart_style_color(
payload,
("grid_color", "gridColor", "gridline_color", "gridlineColor"),
grid_color,
),
"plot_fill": _chart_style_color(
payload,
("plot_area_fill", "plotAreaFill", "plot_background", "plotBackground"),
None,
),
"text_color": _chart_style_color(
payload,
("text_color", "textColor", "label_color", "labelColor", "font_color", "fontColor"),
text_color,
),
"font_face": font_face or None,
}
def _chart_text_sizes(
payload: dict[str, Any],
elem: ET.Element | None = None,
inherited_styles: dict[str, str] | None = None,
) -> dict[str, int]:
style = payload.get("style") if isinstance(payload.get("style"), dict) else {}
inherited_styles = inherited_styles or {}
fallback_font_size = (
_most_common_font_size(
_fallback_text_attr_values(
elem,
"font-size",
inherited_styles.get("font-size"),
)
)
if elem is not None else None
)
base_raw = _first_present(
payload.get("font_size"),
payload.get("chart_font_size"),
payload.get("chartFontSize"),
style.get("font_size"),
style.get("chart_font_size"),
style.get("chartFontSize"),
fallback_font_size,
)
axis_raw = _first_present(
payload.get("axis_font_size"),
payload.get("axisFontSize"),
payload.get("tick_font_size"),
payload.get("tickFontSize"),
style.get("axis_font_size"),
style.get("axisFontSize"),
style.get("tick_font_size"),
style.get("tickFontSize"),
base_raw,
)
axis_title_raw = _first_present(
payload.get("axis_title_font_size"),
payload.get("axisTitleFontSize"),
style.get("axis_title_font_size"),
style.get("axisTitleFontSize"),
axis_raw,
)
legend_raw = _first_present(
payload.get("legend_font_size"),
payload.get("legendFontSize"),
style.get("legend_font_size"),
style.get("legendFontSize"),
axis_raw,
)
title_raw = _first_present(
payload.get("title_font_size"),
payload.get("titleFontSize"),
style.get("title_font_size"),
style.get("titleFontSize"),
)
subtitle_raw = _first_present(
payload.get("subtitle_font_size"),
payload.get("subtitleFontSize"),
style.get("subtitle_font_size"),
style.get("subtitleFontSize"),
base_raw,
)
note_raw = _first_present(
payload.get("note_font_size"),
payload.get("noteFontSize"),
style.get("note_font_size"),
style.get("noteFontSize"),
style.get("caption_font_size"),
style.get("captionFontSize"),
base_raw,
)
return {
"axis": _font_size_hpt(axis_raw, 12),
"axis_title": _font_size_hpt(axis_title_raw, 12),
"base": _font_size_hpt(base_raw, 12),
"legend": _font_size_hpt(legend_raw, 12),
"note": _font_size_hpt(note_raw, 12),
"subtitle": _font_size_hpt(subtitle_raw, 12),
"title": _font_size_hpt(title_raw, 16),
}
def _solid_fill_xml(color: str | None) -> str:
if not color:
return "<a:noFill/>"
return f'<a:solidFill><a:srgbClr val="{color}"/></a:solidFill>'
def _chart_area_sp_pr_xml(fill_color: str | None) -> str:
return f"<c:spPr>{_solid_fill_xml(fill_color)}<a:ln><a:noFill/></a:ln></c:spPr>"
def _chart_line_sp_pr_xml(color: str | None, *, width: int = 9525) -> str:
if not color:
line_xml = "<a:ln><a:noFill/></a:ln>"
else:
line_xml = (
f'<a:ln w="{width}" cap="flat" cmpd="sng" algn="ctr">'
f'<a:solidFill><a:srgbClr val="{color}"/></a:solidFill>'
"<a:round/></a:ln>"
)
return f"<c:spPr>{line_xml}</c:spPr>"
def _major_gridlines_xml(color: str | None) -> str:
return f'<c:majorGridlines>{_chart_line_sp_pr_xml(color, width=6350)}</c:majorGridlines>'
def _font_face_xml(font_face: str | None) -> str:
if not font_face:
return ""
fonts = parse_font_family(font_face)
latin_font = _xml_escape(fonts["latin"])
ea_font = _xml_escape(fonts["ea"])
return f'<a:latin typeface="{latin_font}"/><a:ea typeface="{ea_font}"/>'
def _chart_tx_pr_xml(
font_size: int,
color: str | None = None,
*,
bold: bool = False,
font_face: str | None = None,
) -> str:
fill_xml = (
f'<a:solidFill><a:srgbClr val="{color}"/></a:solidFill>'
if color else ""
)
bold_attr = ' b="1"' if bold else ""
return (
"<c:txPr><a:bodyPr/><a:lstStyle/><a:p><a:pPr>"
f'<a:defRPr sz="{font_size}"{bold_attr}>{fill_xml}{_font_face_xml(font_face)}</a:defRPr>'
'</a:pPr><a:endParaRPr lang="en-US"/></a:p></c:txPr>'
)
def _chart_text_entry(value: Any) -> tuple[str, dict[str, Any]] | None:
if isinstance(value, dict):
text = _first_present(value.get("text"), value.get("value"), value.get("content"))
if text is None or not str(text).strip():
return None
return str(text).strip(), value
if value is None or not str(value).strip():
return None
return str(value).strip(), {}
def _chart_text_entry_font_size(item: dict[str, Any], fallback: int) -> int:
raw = _first_present(item.get("font_size"), item.get("fontSize"))
if raw is None:
return fallback
return _font_size_hpt(raw, 12)
def _chart_text_entry_color(item: dict[str, Any], fallback: str | None) -> str | None:
return _hex_or_none(_first_present(
item.get("color"),
item.get("font_color"),
item.get("fontColor"),
)) or fallback
def _chart_text_entry_font_face(item: dict[str, Any], fallback: str | None) -> str | None:
raw = _first_present(
item.get("font_family"),
item.get("fontFamily"),
item.get("font_face"),
item.get("fontFace"),
)
if raw is None:
return fallback
font_face = str(raw).strip()
return font_face or fallback
def _alpha_xml(value: Any, field_name: str = "fill_opacity") -> str:
if value is None:
return ""
if isinstance(value, bool):
raise RuntimeError(f"Native PPTX chart {field_name} must be numeric")
try:
alpha = float(value)
except (TypeError, ValueError):
raise RuntimeError(f"Native PPTX chart {field_name} must be numeric") from None
if alpha < 0 or alpha > 1:
raise RuntimeError(f"Native PPTX chart {field_name} must be between 0 and 1")
return f'<a:alpha val="{int(round(alpha * 100000))}"/>'
def _axis_title_xml(
title: Any,
*,
font_size: int,
color: str | None = None,
font_face: str | None = None,
) -> str:
entry = _chart_text_entry(title)
if entry is None:
return ""
text, item = entry
text_color = _chart_text_entry_color(item, color)
fill_xml = (
f'<a:solidFill><a:srgbClr val="{text_color}"/></a:solidFill>'
if text_color else ""
)
lang = detect_text_lang(text)
return (
"<c:title><c:tx><c:rich><a:bodyPr/><a:lstStyle/>"
f'<a:p><a:r><a:rPr lang="{lang}" sz="{_chart_text_entry_font_size(item, font_size)}">'
f"{fill_xml}{_font_face_xml(_chart_text_entry_font_face(item, font_face))}</a:rPr>"
f"<a:t>{_xml_escape(text)}</a:t></a:r></a:p>"
"</c:rich></c:tx><c:layout/><c:overlay val=\"0\"/></c:title>"
)
_AXIS_TITLE_KEY_GROUPS = {
"category": (
("category",),
("category_axis_title", "categoryAxisTitle"),
),
"value": (
("value",),
("value_axis_title", "valueAxisTitle"),
),
"x": (
("x",),
("x_axis_title", "xAxisTitle"),
),
"y": (
("y",),
("y_axis_title", "yAxisTitle"),
),
"secondary_value": (
("secondary_value", "secondaryValue"),
("secondary_value_axis_title", "secondaryValueAxisTitle"),
),
}
def _axis_titles(payload: dict[str, Any]) -> dict[str, Any]:
style = payload.get("style") if isinstance(payload.get("style"), dict) else {}
raw = payload.get("axis_titles", payload.get("axisTitles"))
style_raw = style.get("axis_titles", style.get("axisTitles"))
axis_map = raw if isinstance(raw, dict) else {}
style_axis_map = style_raw if isinstance(style_raw, dict) else {}
def pick(axis_keys: tuple[str, ...], root_keys: tuple[str, ...]) -> Any:
values: list[Any] = []
for key in root_keys:
values.extend((
payload.get(key),
style.get(key),
))
for key in axis_keys + root_keys:
values.extend((
axis_map.get(key),
style_axis_map.get(key),
))
return _first_present(*values)
return {
field_name: pick(axis_keys, root_keys)
for field_name, (axis_keys, root_keys) in _AXIS_TITLE_KEY_GROUPS.items()
}
def _metadata_text(value: Any) -> str | None:
entry = _chart_text_entry(value)
if entry is None:
return None
text, _ = entry
return _normalized_fallback_text(text)
def _native_chart_chrome_errors(elem: ET.Element, payload: dict[str, Any]) -> list[str]:
fallback_texts = set(_visible_fallback_texts(elem))
missing: list[str] = []
for field_name in ("title", "subtitle"):
text = _metadata_text(payload.get(field_name))
if text and text not in fallback_texts:
missing.append(f"{field_name}={text!r}")
for field_name, value in _axis_titles(payload).items():
text = _metadata_text(value)
if text and text not in fallback_texts:
missing.append(f"axis_titles.{field_name}={text!r}")
if not missing:
return []
sample = ", ".join(missing[:5])
suffix = "" if len(missing) <= 5 else f", and {len(missing) - 5} more"
return [
"Native PPTX chart metadata contains title/axis text that is not visible "
f"inside the fallback marker and would appear only after --native-objects: {sample}{suffix}. "
"Use `name` for object naming, or draw the same text in the chart fallback."
]
def _native_chart_export_payload(
elem: ET.Element,
payload: dict[str, Any],
) -> tuple[dict[str, Any], list[str]]:
fallback_texts = set(_visible_fallback_texts(elem))
output = payload
messages: list[str] = []
def mutable_payload() -> dict[str, Any]:
nonlocal output
if output is payload:
output = dict(payload)
return output
def mutable_style(target: dict[str, Any]) -> dict[str, Any] | None:
style = target.get("style")
if not isinstance(style, dict):
return None
if target.get("style") is payload.get("style"):
style = dict(style)
target["style"] = style
return style
def drop_map_keys(source: dict[str, Any], map_key: str, keys: tuple[str, ...]) -> None:
raw_map = source.get(map_key)
if not isinstance(raw_map, dict):
return
next_map = dict(raw_map)
for key in keys:
next_map.pop(key, None)
if next_map:
source[map_key] = next_map
else:
source.pop(map_key, None)
for key in ("title", "subtitle"):
text = _metadata_text(payload.get(key))
if text and text not in fallback_texts:
mutable_payload().pop(key, None)
messages.append(
f"omitted native chart {key} {text!r} because it is not visible in the fallback"
)
for field_name, value in _axis_titles(payload).items():
text = _metadata_text(value)
if not text or text in fallback_texts:
continue
axis_keys, root_keys = _AXIS_TITLE_KEY_GROUPS[field_name]
target = mutable_payload()
for key in root_keys:
target.pop(key, None)
for map_key in ("axis_titles", "axisTitles"):
drop_map_keys(target, map_key, axis_keys + root_keys)
style = mutable_style(target)
if style is not None:
for key in root_keys:
style.pop(key, None)
for map_key in ("axis_titles", "axisTitles"):
drop_map_keys(style, map_key, axis_keys + root_keys)
messages.append(
f"omitted native chart axis_titles.{field_name} {text!r} "
"because it is not visible in the fallback"
)
style = payload.get("style") if isinstance(payload.get("style"), dict) else {}
show_legend = payload.get("show_legend", style.get("show_legend", False))
if show_legend:
legend_texts = _legend_candidate_texts(payload)
if legend_texts and not any(text in fallback_texts for text in legend_texts):
mutable_payload()["show_legend"] = False
messages.append(
"omitted native chart legend because show_legend=true has no "
"visible fallback legend text"
)
return output, messages
def _legend_candidate_texts(payload: dict[str, Any]) -> list[str]:
series_values: list[str] = []
category_values: list[str] = []
categories = payload.get("categories")
if isinstance(categories, list):
category_values.extend(str(item) for item in categories if str(item).strip())
series = payload.get("series")
if isinstance(series, list):
for item in series:
if isinstance(item, dict) and item.get("name") is not None:
series_values.append(str(item.get("name")))
plots = payload.get("plots")
if isinstance(plots, list):
for plot in plots:
if not isinstance(plot, dict):
continue
plot_series = plot.get("series")
if not isinstance(plot_series, list):
continue
for item in plot_series:
if isinstance(item, dict) and item.get("name") is not None:
series_values.append(str(item.get("name")))
chart_type = _compact_key(payload.get("type") or payload.get("chart_type") or "")
category_legend_types = {"pie", "doughnut", "donut", "ofpie", "pieofpie", "barofpie"}
values = category_values if chart_type in category_legend_types else series_values
normalized: list[str] = []
for value in values:
text = _normalized_fallback_text(value)
if text:
normalized.append(text)
return normalized
def _native_chart_chrome_warnings(elem: ET.Element, payload: dict[str, Any]) -> list[str]:
fallback_texts = set(_visible_fallback_texts(elem))
warnings: list[str] = []
style = payload.get("style") if isinstance(payload.get("style"), dict) else {}
show_legend = payload.get("show_legend", style.get("show_legend", False))
if show_legend:
legend_texts = _legend_candidate_texts(payload)
if legend_texts and not any(text in fallback_texts for text in legend_texts):
warnings.append(
"Native PPTX chart has show_legend=true, but no series/category "
"legend text is visible inside the fallback marker. Add the "
"fallback legend or remove show_legend."
)
companion_entries = _chart_companion_entries(
payload,
include_title=False,
include_subtitle_as_caption=False,
)
missing_companion: list[str] = []
for item in companion_entries:
text = _normalized_fallback_text(item.get("text"))
if text and text not in fallback_texts:
missing_companion.append(text)
if missing_companion:
sample = ", ".join(repr(text) for text in missing_companion[:5])
suffix = "" if len(missing_companion) <= 5 else f", and {len(missing_companion) - 5} more"
warnings.append(
"Native PPTX chart companion text is not visible inside the fallback "
f"marker and may appear only after --native-objects: {sample}{suffix}. "
"Keep companion metadata aligned with visible chart annotations."
)
return warnings
def _text_box_xml(
ctx: ConvertContext,
*,
text: str,
role: str,
off_x: int,
off_y: int,
ext_cx: int,
ext_cy: int,
font_size: int,
color: str | None,
align: str = "l",
bold: bool = False,
font_face: str | None = None,
) -> str:
shape_id = ctx.next_id()
align_key = _compact_key(align)
algn = {
"center": "ctr",
"centre": "ctr",
"ctr": "ctr",
"middle": "ctr",
"right": "r",
"r": "r",
"left": "l",
"l": "l",
}.get(align_key, "l")
fill_xml = (
f'<a:solidFill><a:srgbClr val="{color}"/></a:solidFill>'
if color else ""
)
bold_attr = ' b="1"' if bold else ""
lang = detect_text_lang(text)
name = _xml_escape(f"Chart {role.title()} {shape_id}")
return f'''<p:sp>
<p:nvSpPr>
<p:cNvPr id="{shape_id}" name="{name}"/>
<p:cNvSpPr txBox="1"/><p:nvPr/>
</p:nvSpPr>
<p:spPr>
<a:xfrm><a:off x="{off_x}" y="{off_y}"/><a:ext cx="{ext_cx}" cy="{ext_cy}"/></a:xfrm>
<a:prstGeom prst="rect"><a:avLst/></a:prstGeom>
<a:noFill/>
<a:ln><a:noFill/></a:ln>
</p:spPr>
<p:txBody>
<a:bodyPr wrap="square" lIns="0" tIns="0" rIns="0" bIns="0" anchor="t" anchorCtr="0"/>
<a:lstStyle/>
<a:p><a:pPr algn="{algn}"/>
<a:r><a:rPr lang="{lang}" sz="{font_size}"{bold_attr}>{fill_xml}{_font_face_xml(font_face)}</a:rPr><a:t>{_xml_escape(text)}</a:t></a:r>
</a:p>
</p:txBody>
</p:sp>'''
def _chart_companion_entries(
payload: dict[str, Any],
*,
include_title: bool,
include_subtitle_as_caption: bool,
) -> list[dict[str, Any]]:
entries: list[dict[str, Any]] = []
def add(role: str, value: Any) -> None:
if value is None:
return
values = value if isinstance(value, list) else [value]
for item in values:
if isinstance(item, dict):
text = _first_present(item.get("text"), item.get("value"), item.get("content"))
if text:
entries.append({"role": role, **item, "text": str(text)})
elif str(item).strip():
entries.append({"role": role, "text": str(item)})
if include_title:
add("title", payload.get("title"))
caption_value = payload.get("caption")
if caption_value is None and include_subtitle_as_caption:
caption_value = payload.get("subtitle")
add("caption", caption_value)
add("source", payload.get("source"))
for key in ("note", "notes", "footnote", "footnotes"):
add("note", payload.get(key))
return entries
def _chart_companion_text_xml(
ctx: ConvertContext,
payload: dict[str, Any],
*,
chart_bounds: tuple[int, int, int, int],
chart_style: dict[str, str | None],
note_font_size: int,
title_font_size: int,
include_title: bool,
include_subtitle_as_caption: bool,
) -> str:
entries = _chart_companion_entries(
payload,
include_title=include_title,
include_subtitle_as_caption=include_subtitle_as_caption,
)
if not entries:
return ""
chart_off_x, chart_off_y, chart_ext_cx, chart_ext_cy = chart_bounds
parts: list[str] = []
below_index = 0
for item in entries:
role = str(item.get("role") or "note")
text = str(item.get("text") or "").strip()
if not text:
continue
font_size = _font_size_hpt(item.get("font_size", item.get("fontSize")), 16 if role == "title" else 12)
if role == "title" and item.get("font_size") is None and item.get("fontSize") is None:
font_size = title_font_size
elif item.get("font_size") is None and item.get("fontSize") is None:
font_size = note_font_size
color = _hex_or_none(item.get("color")) or chart_style.get("text_color")
font_face = _chart_text_entry_font_face(item, chart_style.get("font_face"))
align = str(item.get("align") or ("ctr" if role == "title" else "l"))
bold = bool(item.get("bold", role == "title"))
has_box = all(_maybe_number(item.get(key)) is not None for key in ("x", "y", "width", "height"))
if has_box:
off_x = px_to_emu(_number(item["x"], "companion text x"))
off_y = px_to_emu(_number(item["y"], "companion text y"))
ext_cx = px_to_emu(_number(item["width"], "companion text width"))
ext_cy = px_to_emu(_number(item["height"], "companion text height"))
elif role == "title":
off_x = chart_off_x
off_y = chart_off_y
ext_cx = chart_ext_cx
ext_cy = px_to_emu(28)
else:
off_x = chart_off_x
off_y = chart_off_y + chart_ext_cy + px_to_emu(4 + below_index * 18)
ext_cx = chart_ext_cx
ext_cy = px_to_emu(16)
below_index += 1
parts.append(_text_box_xml(
ctx,
text=text,
role=role,
off_x=off_x,
off_y=off_y,
ext_cx=ext_cx,
ext_cy=ext_cy,
font_size=font_size,
color=color,
align=align,
bold=bold,
font_face=font_face,
))
return "".join(parts)
File diff suppressed because one or more lines are too long
@@ -0,0 +1,705 @@
"""Shared helpers for native PowerPoint object conversion."""
from __future__ import annotations
import json
import re
from typing import Any
from xml.etree import ElementTree as ET
from ..drawingml.context import ConvertContext, IDENTITY_MATRIX
from ..drawingml.utils import (
FONT_PX_TO_HUNDREDTHS_PT,
ctx_h,
ctx_w,
ctx_x,
ctx_y,
matrix_multiply,
parse_transform_matrix,
px_to_emu,
transform_point,
)
TABLE_URI = "http://schemas.openxmlformats.org/drawingml/2006/table"
CHART_URI = "http://schemas.openxmlformats.org/drawingml/2006/chart"
CHARTEX_URI = "http://schemas.microsoft.com/office/drawing/2014/chartex"
CHART_REL_TYPE = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/chart"
CHARTEX_REL_TYPE = "http://schemas.microsoft.com/office/2014/relationships/chartEx"
CHART_COLOR_STYLE_REL_TYPE = "http://schemas.microsoft.com/office/2011/relationships/chartColorStyle"
CHART_STYLE_REL_TYPE = "http://schemas.microsoft.com/office/2011/relationships/chartStyle"
PACKAGE_REL_TYPE = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/package"
CHART_CONTENT_TYPE = "application/vnd.openxmlformats-officedocument.drawingml.chart+xml"
CHARTEX_CONTENT_TYPE = "application/vnd.ms-office.chartex+xml"
CHART_COLOR_STYLE_CONTENT_TYPE = "application/vnd.ms-office.chartcolorstyle+xml"
CHART_STYLE_CONTENT_TYPE = "application/vnd.ms-office.chartstyle+xml"
_NATIVE_KINDS = {"table", "chart"}
_HEX_RE = re.compile(r"^#?([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$")
_RGB_RE = re.compile(r"^rgba?\(([^)]+)\)$", re.IGNORECASE)
_POINT_RE = re.compile(r"[-+]?(?:\d*\.\d+|\d+\.?)(?:[eE][-+]?\d+)?")
_CSS_NAMED_COLORS = {
"aliceblue": "F0F8FF",
"black": "000000",
"blue": "0000FF",
"brown": "A52A2A",
"cyan": "00FFFF",
"darkgray": "A9A9A9",
"darkgrey": "A9A9A9",
"gold": "FFD700",
"gray": "808080",
"green": "008000",
"grey": "808080",
"lightgray": "D3D3D3",
"lightgrey": "D3D3D3",
"magenta": "FF00FF",
"navy": "000080",
"orange": "FFA500",
"purple": "800080",
"red": "FF0000",
"silver": "C0C0C0",
"transparent": None,
"white": "FFFFFF",
"yellow": "FFFF00",
}
def _local_tag(elem: ET.Element) -> str:
return elem.tag.rsplit("}", 1)[-1] if "}" in elem.tag else elem.tag
def _clean_hex(value: Any, default: str) -> str:
return _hex_or_none(value) or _hex_or_none(default) or "000000"
def _hex_or_none(value: Any) -> str | None:
raw = str(value or "").strip()
if not raw:
return None
named = _CSS_NAMED_COLORS.get(raw.lower())
if named is not None or raw.lower() in _CSS_NAMED_COLORS:
return named
match = _HEX_RE.match(raw)
if match:
color = match.group(1).upper()
if len(color) == 3:
return "".join(channel * 2 for channel in color)
return color
match = _RGB_RE.match(raw)
if not match:
return None
parts = [part.strip() for part in match.group(1).split(",")]
if len(parts) not in {3, 4}:
return None
channels: list[int] = []
for part in parts[:3]:
try:
if part.endswith("%"):
value_float = float(part[:-1]) * 255.0 / 100.0
else:
value_float = float(part)
except ValueError:
return None
channels.append(max(0, min(255, int(round(value_float)))))
return "".join(f"{channel:02X}" for channel in channels)
def _style_attr(elem: ET.Element, name: str) -> str | None:
if elem.get(name) is not None:
return elem.get(name)
style = elem.get("style")
if not style:
return None
for part in style.split(";"):
if ":" not in part:
continue
key, value = part.split(":", 1)
if key.strip() == name:
return value.strip()
return None
def _paint_visible(elem: ET.Element, paint: str) -> bool:
for name in ("opacity", f"{paint}-opacity"):
raw = _style_attr(elem, name)
if raw is None:
continue
try:
if float(raw) <= 0:
return False
except ValueError:
continue
return True
def _normalized_fallback_text(value: Any) -> str:
return re.sub(r"\s+", " ", str(value or "")).strip()
def _visible_fallback_texts(elem: ET.Element, *, include_metadata: bool = False) -> list[str]:
texts: list[str] = []
def visit(node: ET.Element, hidden: bool) -> None:
tag = _local_tag(node)
if tag in {"defs", "clipPath", "mask", "filter", "style"}:
return
if tag == "metadata" and not include_metadata:
return
node_hidden = (
hidden
or _style_attr(node, "display") == "none"
or _style_attr(node, "visibility") == "hidden"
)
if tag == "text" and not node_hidden:
text = _normalized_fallback_text("".join(node.itertext()))
if text:
texts.append(text)
return
for child in node:
visit(child, node_hidden)
visit(elem, False)
return texts
def _number(value: Any, field_name: str) -> float:
try:
return float(value)
except (TypeError, ValueError) as exc:
raise RuntimeError(f"Native PPTX object requires numeric {field_name}") from exc
def _maybe_number(value: Any) -> float | None:
if value is None:
return None
try:
return float(value)
except (TypeError, ValueError):
return None
def _bbox_union(
first: tuple[float, float, float, float] | None,
second: tuple[float, float, float, float] | None,
) -> tuple[float, float, float, float] | None:
if first is None:
return second
if second is None:
return first
return (
min(first[0], second[0]),
min(first[1], second[1]),
max(first[2], second[2]),
max(first[3], second[3]),
)
def _bbox_from_points(points: list[tuple[float, float]]) -> tuple[float, float, float, float] | None:
if not points:
return None
xs = [point[0] for point in points]
ys = [point[1] for point in points]
return min(xs), min(ys), max(xs), max(ys)
def _apply_matrix_bbox(
bbox: tuple[float, float, float, float],
matrix: tuple[float, float, float, float, float, float],
) -> tuple[float, float, float, float]:
x1, y1, x2, y2 = bbox
points = [
transform_point(matrix, x1, y1),
transform_point(matrix, x2, y1),
transform_point(matrix, x2, y2),
transform_point(matrix, x1, y2),
]
result = _bbox_from_points(points)
if result is None:
raise RuntimeError("Native PPTX object fallback bbox inference failed")
return result
def _points_attr_bbox(value: str | None) -> tuple[float, float, float, float] | None:
numbers = [float(item) for item in _POINT_RE.findall(value or "")]
points = [
(numbers[idx], numbers[idx + 1])
for idx in range(0, len(numbers) - 1, 2)
]
return _bbox_from_points(points)
def _path_bbox(value: str | None) -> tuple[float, float, float, float] | None:
tokens = re.findall(r"[AaCcHhLlMmQqSsTtVvZz]|[-+]?(?:\d*\.\d+|\d+\.?)(?:[eE][-+]?\d+)?", value or "")
points: list[tuple[float, float]] = []
index = 0
command = ""
current_x = 0.0
current_y = 0.0
subpath_x = 0.0
subpath_y = 0.0
def read_number() -> float | None:
nonlocal index
if index >= len(tokens) or re.fullmatch(r"[A-Za-z]", tokens[index]):
return None
number = float(tokens[index])
index += 1
return number
def add_point(x_value: float, y_value: float, *, relative: bool) -> tuple[float, float]:
x = current_x + x_value if relative else x_value
y = current_y + y_value if relative else y_value
points.append((x, y))
return x, y
while index < len(tokens):
token = tokens[index]
if re.fullmatch(r"[A-Za-z]", token):
command = token
index += 1
if not command:
break
relative = command.islower()
op = command.upper()
if op == "Z":
current_x, current_y = subpath_x, subpath_y
points.append((current_x, current_y))
command = ""
continue
if op in {"M", "L", "T"}:
x_raw = read_number()
y_raw = read_number()
if x_raw is None or y_raw is None:
break
current_x, current_y = add_point(x_raw, y_raw, relative=relative)
if op == "M":
subpath_x, subpath_y = current_x, current_y
command = "l" if relative else "L"
continue
if op == "H":
x_raw = read_number()
if x_raw is None:
break
current_x = current_x + x_raw if relative else x_raw
points.append((current_x, current_y))
continue
if op == "V":
y_raw = read_number()
if y_raw is None:
break
current_y = current_y + y_raw if relative else y_raw
points.append((current_x, current_y))
continue
if op == "C":
values = [read_number() for _ in range(6)]
if any(item is None for item in values):
break
for point_idx in range(0, 6, 2):
current_x, current_y = add_point(
values[point_idx], # type: ignore[arg-type]
values[point_idx + 1], # type: ignore[arg-type]
relative=relative,
)
continue
if op in {"S", "Q"}:
values = [read_number() for _ in range(4)]
if any(item is None for item in values):
break
for point_idx in range(0, 4, 2):
current_x, current_y = add_point(
values[point_idx], # type: ignore[arg-type]
values[point_idx + 1], # type: ignore[arg-type]
relative=relative,
)
continue
if op == "A":
values = [read_number() for _ in range(7)]
if any(item is None for item in values):
break
current_x, current_y = add_point(
values[5], # type: ignore[arg-type]
values[6], # type: ignore[arg-type]
relative=relative,
)
continue
break
return _bbox_from_points(points)
def _element_local_bbox(elem: ET.Element) -> tuple[float, float, float, float] | None:
tag = _local_tag(elem)
if tag == "metadata":
return None
if tag in {"defs", "clipPath", "mask", "filter", "style"}:
return None
if elem.get("display") == "none" or elem.get("visibility") == "hidden":
return None
if tag in {"g", "svg", "a"}:
bbox = None
for child in elem:
bbox = _bbox_union(bbox, _fallback_bbox(child))
return bbox
if tag in {"rect", "image", "use"}:
x = _maybe_number(elem.get("x")) or 0.0
y = _maybe_number(elem.get("y")) or 0.0
width = _maybe_number(elem.get("width")) or 0.0
height = _maybe_number(elem.get("height")) or 0.0
if width <= 0 or height <= 0:
return None
return x, y, x + width, y + height
if tag == "circle":
cx = _maybe_number(elem.get("cx")) or 0.0
cy = _maybe_number(elem.get("cy")) or 0.0
r = _maybe_number(elem.get("r")) or 0.0
if r <= 0:
return None
return cx - r, cy - r, cx + r, cy + r
if tag == "ellipse":
cx = _maybe_number(elem.get("cx")) or 0.0
cy = _maybe_number(elem.get("cy")) or 0.0
rx = _maybe_number(elem.get("rx")) or 0.0
ry = _maybe_number(elem.get("ry")) or 0.0
if rx <= 0 or ry <= 0:
return None
return cx - rx, cy - ry, cx + rx, cy + ry
if tag == "line":
points = [
(_maybe_number(elem.get("x1")) or 0.0, _maybe_number(elem.get("y1")) or 0.0),
(_maybe_number(elem.get("x2")) or 0.0, _maybe_number(elem.get("y2")) or 0.0),
]
return _bbox_from_points(points)
if tag in {"polygon", "polyline"}:
return _points_attr_bbox(elem.get("points"))
if tag == "path":
# This intentionally approximates path geometry from command endpoints.
# Explicit metadata remains the precise path for complex arcs/curves.
return _path_bbox(elem.get("d"))
if tag == "text":
x = _maybe_number(elem.get("x")) or 0.0
y = _maybe_number(elem.get("y")) or 0.0
font_size = _maybe_number(elem.get("font-size")) or 16.0
text = "".join(elem.itertext())
width = max(len(text), 1) * font_size * 0.55
height = font_size * 1.25
return x, y - height * 0.8, x + width, y + height * 0.2
return None
def _fallback_bbox(
elem: ET.Element,
matrix: tuple[float, float, float, float, float, float] = IDENTITY_MATRIX,
) -> tuple[float, float, float, float] | None:
local_matrix = matrix
transform = elem.get("transform")
if transform:
local_matrix = matrix_multiply(matrix, parse_transform_matrix(transform))
tag = _local_tag(elem)
if tag in {"g", "svg", "a"}:
bbox = None
for child in elem:
bbox = _bbox_union(bbox, _fallback_bbox(child, local_matrix))
return bbox
local_bbox = _element_local_bbox(elem)
if local_bbox is None:
return None
return _apply_matrix_bbox(local_bbox, local_matrix)
def _inferred_bounds(elem: ET.Element) -> tuple[float, float, float, float] | None:
bbox = None
for child in elem:
bbox = _bbox_union(bbox, _fallback_bbox(child))
return bbox
def _fallback_fill_candidates(
elem: ET.Element,
matrix: tuple[float, float, float, float, float, float] = IDENTITY_MATRIX,
inherited_fill: str | None = None,
) -> list[tuple[float, str]]:
tag = _local_tag(elem)
if tag == "metadata" or tag in {"defs", "clipPath", "mask", "filter", "style"}:
return []
if elem.get("display") == "none" or elem.get("visibility") == "hidden":
return []
if not _paint_visible(elem, "fill"):
return []
local_matrix = matrix
transform = elem.get("transform")
if transform:
local_matrix = matrix_multiply(matrix, parse_transform_matrix(transform))
fill = _style_attr(elem, "fill")
next_fill = fill if fill is not None else inherited_fill
if tag in {"g", "svg", "a"}:
candidates: list[tuple[float, str]] = []
for child in elem:
candidates.extend(_fallback_fill_candidates(child, local_matrix, next_fill))
return candidates
if tag != "rect":
return []
if not next_fill or next_fill.strip().lower() in {"none", "transparent"}:
return []
color = _hex_or_none(next_fill)
if not color:
return []
local_bbox = _element_local_bbox(elem)
if local_bbox is None:
return []
x1, y1, x2, y2 = _apply_matrix_bbox(local_bbox, local_matrix)
area = max(x2 - x1, 0.0) * max(y2 - y1, 0.0)
return [(area, color)] if area > 0 else []
def _inferred_chart_background(elem: ET.Element) -> str | None:
bounds = _inferred_bounds(elem)
if bounds is None:
return None
x1, y1, x2, y2 = bounds
bounds_area = max(x2 - x1, 0.0) * max(y2 - y1, 0.0)
if bounds_area <= 0:
return None
candidates: list[tuple[float, str]] = []
for child in elem:
candidates.extend(_fallback_fill_candidates(child))
if not candidates:
return None
area, color = max(candidates, key=lambda item: item[0])
# Avoid mistaking a large data bar for a chart background when no panel /
# plot-area rectangle exists in the fallback drawing.
return color if area >= bounds_area * 0.25 else None
def _fallback_text_colors(
elem: ET.Element,
inherited_fill: str | None = None,
) -> list[str]:
tag = _local_tag(elem)
if tag == "metadata" or tag in {"defs", "clipPath", "mask", "filter", "style"}:
return []
if elem.get("display") == "none" or elem.get("visibility") == "hidden":
return []
if not _paint_visible(elem, "fill"):
return []
fill = _style_attr(elem, "fill")
next_fill = fill if fill is not None else inherited_fill
colors: list[str] = []
if tag in {"text", "tspan"} and next_fill:
color = _hex_or_none(next_fill)
if color:
colors.append(color)
for child in elem:
colors.extend(_fallback_text_colors(child, next_fill))
return colors
def _fallback_stroke_colors(
elem: ET.Element,
inherited_stroke: str | None = None,
) -> list[str]:
tag = _local_tag(elem)
if tag == "metadata" or tag in {"defs", "clipPath", "mask", "filter", "style"}:
return []
if elem.get("display") == "none" or elem.get("visibility") == "hidden":
return []
if not _paint_visible(elem, "stroke"):
return []
stroke = _style_attr(elem, "stroke")
next_stroke = stroke if stroke is not None else inherited_stroke
colors: list[str] = []
if tag in {"circle", "ellipse", "line", "path", "polygon", "polyline", "rect"} and next_stroke:
color = _hex_or_none(next_stroke)
if color:
colors.append(color)
for child in elem:
colors.extend(_fallback_stroke_colors(child, next_stroke))
return colors
def _most_common_color(colors: list[str]) -> str | None:
if not colors:
return None
counts: dict[str, int] = {}
for color in colors:
counts[color] = counts.get(color, 0) + 1
return max(counts.items(), key=lambda item: item[1])[0]
def _relative_luminance(color: str) -> float:
channels = [int(color[idx:idx + 2], 16) / 255.0 for idx in (0, 2, 4)]
linear = [
channel / 12.92 if channel <= 0.04045 else ((channel + 0.055) / 1.055) ** 2.4
for channel in channels
]
return linear[0] * 0.2126 + linear[1] * 0.7152 + linear[2] * 0.0722
def _bounds(elem: ET.Element, payload: dict[str, Any], ctx: ConvertContext) -> tuple[int, int, int, int]:
"""Return object bounds as DrawingML EMU tuple."""
if ctx.use_transform_matrix:
raise RuntimeError("Native PPTX table/chart markers support translate/scale only")
raw_x = payload.get("x", elem.get("data-pptx-x"))
raw_y = payload.get("y", elem.get("data-pptx-y"))
raw_width = payload.get("width", elem.get("data-pptx-width"))
raw_height = payload.get("height", elem.get("data-pptx-height"))
explicit_bounds = all(value is not None for value in (raw_x, raw_y, raw_width, raw_height))
inferred = None
if not explicit_bounds:
inferred = _inferred_bounds(elem)
if inferred is None:
raise RuntimeError(
"Native PPTX object requires x/y/width/height or visible fallback geometry"
)
x = _number(raw_x, "x") if raw_x is not None else inferred[0] # type: ignore[index]
y = _number(raw_y, "y") if raw_y is not None else inferred[1] # type: ignore[index]
width = (
_number(raw_width, "width")
if raw_width is not None else inferred[2] - inferred[0] # type: ignore[index]
)
height = (
_number(raw_height, "height")
if raw_height is not None else inferred[3] - inferred[1] # type: ignore[index]
)
if width <= 0 or height <= 0:
raise RuntimeError("Native PPTX object width/height must be positive")
if explicit_bounds:
resolved_x = x
resolved_y = y
resolved_w = width
resolved_h = height
else:
resolved_x = ctx_x(x, ctx)
resolved_y = ctx_y(y, ctx)
resolved_w = ctx_w(width, ctx)
resolved_h = ctx_h(height, ctx)
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
def _validate_bounds_inputs(elem: ET.Element, payload: dict[str, Any]) -> None:
raw_x = payload.get("x", elem.get("data-pptx-x"))
raw_y = payload.get("y", elem.get("data-pptx-y"))
raw_width = payload.get("width", elem.get("data-pptx-width"))
raw_height = payload.get("height", elem.get("data-pptx-height"))
inferred = None
if any(value is None for value in (raw_x, raw_y, raw_width, raw_height)):
inferred = _inferred_bounds(elem)
if inferred is None:
raise RuntimeError(
"Native PPTX object requires x/y/width/height or visible fallback geometry"
)
width = (
_number(raw_width, "width")
if raw_width is not None else inferred[2] - inferred[0] # type: ignore[index]
)
height = (
_number(raw_height, "height")
if raw_height is not None else inferred[3] - inferred[1] # type: ignore[index]
)
if raw_x is not None:
_number(raw_x, "x")
if raw_y is not None:
_number(raw_y, "y")
if width <= 0 or height <= 0:
raise RuntimeError("Native PPTX object width/height must be positive")
def _load_payload(elem: ET.Element, kind: str) -> dict[str, Any]:
raw = elem.get("data-pptx-json") or elem.get("data-pptx-data")
if raw is None:
for child in elem:
if _local_tag(child) != "metadata":
continue
metadata_kind = (child.get("data-pptx-native") or child.get("data-pptx-kind") or kind).lower()
metadata_type = (child.get("type") or "").lower()
if metadata_kind == kind or metadata_type == "application/json":
raw = "".join(child.itertext()).strip()
break
if not raw:
raise RuntimeError(f"Native PPTX {kind} marker requires JSON metadata")
try:
payload = json.loads(raw)
except json.JSONDecodeError as exc:
raise RuntimeError(f"Native PPTX {kind} metadata is not valid JSON: {exc.msg}") from exc
if not isinstance(payload, dict):
raise RuntimeError(f"Native PPTX {kind} metadata must be a JSON object")
return payload
def _font_size_hpt(value: Any, default_px: int = 18) -> int:
try:
px = float(value)
except (TypeError, ValueError):
px = float(default_px)
return int(round(px * FONT_PX_TO_HUNDREDTHS_PT / 10.0)) * 10
def _first_present(*values: Any) -> Any:
for value in values:
if value is not None:
return value
return None
def _bool_attr(value: bool) -> str:
return "1" if value else "0"
def _chart_bool(value: Any, default: bool = False) -> bool:
if value is None:
return default
if isinstance(value, bool):
return value
key = _compact_key(value)
if key in {"1", "on", "true", "yes"}:
return True
if key in {"0", "false", "no", "off"}:
return False
return bool(value)
def _excel_col(index: int) -> str:
result = ""
while index:
index, remainder = divmod(index - 1, 26)
result = chr(65 + remainder) + result
return result or "A"
def _compact_key(value: Any) -> str:
return re.sub(r"[^a-z0-9]", "", str(value or "").lower())
@@ -0,0 +1,406 @@
"""Native PowerPoint table conversion."""
from __future__ import annotations
from typing import Any
from xml.etree import ElementTree as ET
from ..drawingml.context import ConvertContext, ShapeResult
from ..drawingml.utils import px_to_emu, _xml_escape
from .chart_style import _font_face_xml
from .marker_common import (
TABLE_URI,
_bool_attr,
_bounds,
_chart_bool,
_clean_hex,
_compact_key,
_first_present,
_font_size_hpt,
_normalized_fallback_text,
_number,
_visible_fallback_texts,
)
def _table_text_run(
text: str,
*,
color: str,
bold: bool,
font_size: int,
font_face: str | None,
) -> str:
bold_attr = ' b="1"' if bold else ""
return (
f'<a:r><a:rPr lang="en-US" sz="{font_size}"{bold_attr}>'
f'<a:solidFill><a:srgbClr val="{color}"/></a:solidFill>'
f'{_font_face_xml(font_face)}'
"</a:rPr>"
f"<a:t>{_xml_escape(text)}</a:t></a:r>"
)
def _cell_payload(value: Any) -> dict[str, Any]:
if isinstance(value, dict):
return value
return {"text": "" if value is None else str(value)}
_TABLE_SPAN_KEYS = {
"col_span",
"colSpan",
"grid_span",
"gridSpan",
"hMerge",
"merge",
"merged",
"row_span",
"rowSpan",
"vMerge",
}
_TABLE_TOP_LEVEL_SPAN_KEYS = {
"merge_cells",
"merged_cells",
"merges",
"spans",
}
def _table_rows(payload: dict[str, Any]) -> list[list[Any]]:
columns = payload.get("columns") or []
rows = payload.get("rows") or []
if not isinstance(columns, list) or not isinstance(rows, list):
raise RuntimeError("Native PPTX table requires columns/rows lists")
for idx, row in enumerate(rows, start=1):
if not isinstance(row, list):
raise RuntimeError(f"Native PPTX table row {idx} must be a list")
table_rows = [list(columns)] if columns else []
table_rows.extend(list(row) for row in rows)
return table_rows
def _check_table_spans(payload: dict[str, Any], table_rows: list[list[Any]]) -> None:
for key in _TABLE_TOP_LEVEL_SPAN_KEYS:
if key in payload:
raise RuntimeError(
"Native PPTX table merged cells are not supported; use SVG fallback "
"or merge cells in PowerPoint after export"
)
for row_idx, row in enumerate(table_rows, start=1):
for col_idx, cell in enumerate(row, start=1):
if not isinstance(cell, dict):
continue
used_keys = sorted(key for key in _TABLE_SPAN_KEYS if key in cell)
if used_keys:
keys = ", ".join(used_keys)
raise RuntimeError(
f"Native PPTX table cell R{row_idx}C{col_idx} uses unsupported "
f"merged-cell field(s): {keys}"
)
def _grid_is_strict(payload: dict[str, Any]) -> bool:
return bool(payload.get("strict_grid", payload.get("strictGrid", False)))
def _validate_table_lengths(payload: dict[str, Any], table_rows: list[list[Any]]) -> int:
if not table_rows:
raise RuntimeError("Native PPTX table requires at least one row")
col_count = max(len(row) for row in table_rows)
if col_count <= 0:
raise RuntimeError("Native PPTX table requires at least one column")
if _grid_is_strict(payload) and any(len(row) != col_count for row in table_rows):
raise RuntimeError("Native PPTX table strict_grid requires every row to have the same length")
column_widths = payload.get("column_widths")
if column_widths is not None:
if not isinstance(column_widths, list) or len(column_widths) != col_count:
raise RuntimeError("Native PPTX table column_widths must match the resolved column count")
for idx, width in enumerate(column_widths, start=1):
_number(width, f"column_widths[{idx}]")
row_heights = payload.get("row_heights")
if row_heights is not None:
if not isinstance(row_heights, list) or len(row_heights) != len(table_rows):
raise RuntimeError("Native PPTX table row_heights must match the resolved row count")
for idx, height in enumerate(row_heights, start=1):
_number(height, f"row_heights[{idx}]")
return col_count
def _validate_table_cell_formatting(payload: dict[str, Any], table_rows: list[list[Any]]) -> None:
style = payload.get("style") if isinstance(payload.get("style"), dict) else {}
for row in table_rows:
for cell in row:
cell_data = _cell_payload(cell)
for side in ("left", "right", "top", "bottom"):
_table_padding_value(cell_data, style, side)
_table_border_width(cell_data, style)
_table_anchor(cell_data, style)
def _validate_table_payload(payload: dict[str, Any]) -> tuple[list[list[Any]], int]:
table_rows = _table_rows(payload)
_check_table_spans(payload, table_rows)
col_count = _validate_table_lengths(payload, table_rows)
_validate_table_cell_formatting(payload, table_rows)
return table_rows, col_count
def _native_table_metadata_texts(table_rows: list[list[Any]]) -> dict[str, int]:
counts: dict[str, int] = {}
for row in table_rows:
for cell in row:
cell_data = _cell_payload(cell)
text = _normalized_fallback_text(cell_data.get("text"))
if text:
counts[text] = counts.get(text, 0) + 1
return counts
def _native_table_warnings(elem: ET.Element, table_rows: list[list[Any]]) -> list[str]:
fallback_texts = _visible_fallback_texts(elem)
if not fallback_texts:
return []
metadata_counts = _native_table_metadata_texts(table_rows)
missing: list[str] = []
seen_counts: dict[str, int] = {}
for text in fallback_texts:
seen_counts[text] = seen_counts.get(text, 0) + 1
if seen_counts[text] > metadata_counts.get(text, 0):
missing.append(text)
if not missing:
return []
sample = ", ".join(repr(text) for text in missing[:5])
suffix = "" if len(missing) <= 5 else f", and {len(missing) - 5} more"
return [
"Native PPTX table fallback text is missing from metadata columns/rows "
f"and will disappear with --native-objects: {sample}{suffix}"
]
def _weighted_lengths(
total: int,
count: int,
weights: list[Any] | None,
*,
field_name: str,
) -> list[int]:
if weights is None:
base = max(total // count, 1)
values = [base] * count
values[-1] += total - sum(values)
return values
numeric = [max(_number(weight, field_name), 0.0) for weight in weights]
numeric_total = sum(numeric)
if numeric_total <= 0:
raise RuntimeError(f"Native PPTX table {field_name} values must sum to a positive number")
values = [max(round(total * weight / numeric_total), 1) for weight in numeric]
values[-1] += total - sum(values)
return values
def _table_padding_value(
cell_data: dict[str, Any],
style: dict[str, Any],
side: str,
) -> int | None:
side_keys = {
"left": ("left", "l", "padding_left", "paddingLeft"),
"right": ("right", "r", "padding_right", "paddingRight"),
"top": ("top", "t", "padding_top", "paddingTop"),
"bottom": ("bottom", "b", "padding_bottom", "paddingBottom"),
}
def from_source(source: dict[str, Any]) -> Any:
for key in side_keys[side]:
if key in source:
return source[key]
padding = source.get("padding", source.get("cell_padding"))
if isinstance(padding, dict):
for key in side_keys[side]:
if key in padding:
return padding[key]
elif padding is not None:
return padding
return None
value = from_source(cell_data)
if value is None:
value = from_source(style)
if value is None:
return None
return max(px_to_emu(max(_number(value, f"table {side} padding"), 0.0)), 0)
def _table_padding_attrs(cell_data: dict[str, Any], style: dict[str, Any]) -> str:
attrs = []
for attr, side in (
("marL", "left"),
("marR", "right"),
("marT", "top"),
("marB", "bottom"),
):
value = _table_padding_value(cell_data, style, side)
if value is not None:
attrs.append(f'{attr}="{value}"')
return (" " + " ".join(attrs)) if attrs else ""
def _table_anchor(cell_data: dict[str, Any], style: dict[str, Any]) -> str:
raw = _first_present(
cell_data.get("valign"),
cell_data.get("vertical_align"),
style.get("valign"),
style.get("vertical_align"),
"middle",
)
aliases = {
"bottom": "b",
"b": "b",
"center": "ctr",
"ctr": "ctr",
"middle": "ctr",
"top": "t",
"t": "t",
}
anchor = aliases.get(_compact_key(raw))
if not anchor:
raise RuntimeError("Native PPTX table valign must be one of: top, middle, bottom")
return anchor
def _table_border_width(cell_data: dict[str, Any], style: dict[str, Any]) -> float:
width_raw = cell_data.get("border_width", cell_data.get("borderWidth", style.get("border_width")))
color_raw = cell_data.get("border_color", cell_data.get("borderColor", style.get("border_color")))
if width_raw is None and color_raw is None:
return 0.0
return _number(1 if width_raw is None else width_raw, "table border_width")
def _table_border_xml(cell_data: dict[str, Any], style: dict[str, Any]) -> str:
color_raw = cell_data.get("border_color", cell_data.get("borderColor", style.get("border_color")))
width = _table_border_width(cell_data, style)
if width <= 0:
return ""
color = _clean_hex(color_raw, "#D9DEE7")
line = (
f'<a:solidFill><a:srgbClr val="{color}"/></a:solidFill>'
'<a:prstDash val="solid"/>'
)
line_width = max(px_to_emu(width), 1)
return "".join(
f'<a:{tag} w="{line_width}">{line}</a:{tag}>'
for tag in ("lnL", "lnR", "lnT", "lnB")
)
def _build_native_table(elem: ET.Element, ctx: ConvertContext, payload: dict[str, Any]) -> ShapeResult:
table_rows, col_count = _validate_table_payload(payload)
has_columns = bool(payload.get("columns") or [])
header_rows = int(payload.get("header_rows", 1 if has_columns else 0))
for row in table_rows:
row.extend([""] * (col_count - len(row)))
style = payload.get("style") if isinstance(payload.get("style"), dict) else {}
header_fill = _clean_hex(style.get("header_fill"), "#1F4E79")
header_text = _clean_hex(style.get("header_text"), "#FFFFFF")
body_fill = _clean_hex(style.get("body_fill"), "#FFFFFF")
body_text = _clean_hex(style.get("body_text"), "#1F2937")
band_fill = _clean_hex(style.get("band_fill"), "#F3F6FA")
font_face = str(style["font_family"]) if style.get("font_family") else None
body_font_size = _font_size_hpt(style.get("font_size"), 18)
header_font_size = _font_size_hpt(
style.get("header_font_size", style.get("font_size")),
18,
)
off_x, off_y, ext_cx, ext_cy = _bounds(elem, payload, ctx)
column_widths = payload.get("column_widths")
grid_widths = _weighted_lengths(
ext_cx,
col_count,
column_widths if isinstance(column_widths, list) else None,
field_name="column_widths",
)
row_heights_raw = payload.get("row_heights")
row_heights = _weighted_lengths(
ext_cy,
len(table_rows),
row_heights_raw if isinstance(row_heights_raw, list) else None,
field_name="row_heights",
)
grid_xml = "".join(f'<a:gridCol w="{width}"/>' for width in grid_widths)
rows_xml: list[str] = []
for row_idx, row in enumerate(table_rows):
is_header = row_idx < header_rows
cells_xml: list[str] = []
for cell in row:
cell_data = _cell_payload(cell)
fill = _clean_hex(
cell_data.get("fill"),
header_fill if is_header else (band_fill if row_idx % 2 == 0 and row_idx else body_fill),
)
color = _clean_hex(cell_data.get("color"), header_text if is_header else body_text)
align = str(cell_data.get("align") or ("ctr" if is_header else "l"))
if align not in {"l", "ctr", "r"}:
align = "l"
text = "" if cell_data.get("text") is None else str(cell_data.get("text"))
bold = bool(cell_data.get("bold", is_header))
cell_font_size = (
_font_size_hpt(cell_data.get("font_size"), 18)
if "font_size" in cell_data
else body_font_size
)
if is_header and "font_size" not in cell_data:
cell_font_size = header_font_size
paragraph_props = f'<a:pPr algn="{align}"/>' if align != "l" else "<a:pPr/>"
tc_pr_attrs = (
f' anchor="{_table_anchor(cell_data, style)}"'
f'{_table_padding_attrs(cell_data, style)}'
)
border_xml = _table_border_xml(cell_data, style)
cells_xml.append(
"<a:tc>"
"<a:txBody><a:bodyPr/><a:lstStyle/>"
f"<a:p>{paragraph_props}"
f"{_table_text_run(text, color=color, bold=bold, font_size=cell_font_size, font_face=font_face)}"
"</a:p></a:txBody>"
f'<a:tcPr{tc_pr_attrs}>{border_xml}<a:solidFill><a:srgbClr val="{fill}"/></a:solidFill></a:tcPr>'
"</a:tc>"
)
rows_xml.append(f'<a:tr h="{row_heights[row_idx]}">{"".join(cells_xml)}</a:tr>')
shape_id = ctx.next_id()
first_row = _bool_attr(header_rows > 0)
band_row = _bool_attr(bool(style.get("band_row", True)))
table_style_id = str(style.get("table_style_id") or "{5C22544A-7EE6-4342-B048-85BDC9FD1C3A}")
name = _xml_escape(str(payload.get("name") or elem.get("id") or f"Native Table {shape_id}"))
xml = f'''<p:graphicFrame>
<p:nvGraphicFramePr>
<p:cNvPr id="{shape_id}" name="{name}"/>
<p:cNvGraphicFramePr><a:graphicFrameLocks noGrp="1"/></p:cNvGraphicFramePr>
<p:nvPr/>
</p:nvGraphicFramePr>
<p:xfrm><a:off x="{off_x}" y="{off_y}"/><a:ext cx="{ext_cx}" cy="{ext_cy}"/></p:xfrm>
<a:graphic>
<a:graphicData uri="{TABLE_URI}">
<a:tbl>
<a:tblPr firstRow="{first_row}" bandRow="{band_row}">
<a:tableStyleId>{_xml_escape(table_style_id)}</a:tableStyleId>
</a:tblPr>
<a:tblGrid>{grid_xml}</a:tblGrid>
{''.join(rows_xml)}
</a:tbl>
</a:graphicData>
</a:graphic>
</p:graphicFrame>'''
return ShapeResult(xml=xml, bounds_emu=(off_x, off_y, off_x + ext_cx, off_y + ext_cy))
@@ -0,0 +1,187 @@
"""Minimal workbook generation for native chart data."""
from __future__ import annotations
import io
import zipfile
from typing import Any
try:
from xlsxwriter import Workbook as XlsxWriterWorkbook
except ImportError: # pragma: no cover - optional compatibility enhancement
XlsxWriterWorkbook = None
try:
from openpyxl import Workbook as OpenpyxlWorkbook
except ImportError: # pragma: no cover - optional compatibility enhancement
OpenpyxlWorkbook = None
from ..drawingml.utils import _xml_escape
from .marker_common import _excel_col
def _xlsx_cell_ref(row: int, col: int) -> str:
return f"{_excel_col(col)}{row}"
def _xlsx_cell(value: Any, row: int, col: int) -> str:
if value is None:
return ""
ref = _xlsx_cell_ref(row, col)
if isinstance(value, (int, float)) and not isinstance(value, bool):
return f'<c r="{ref}"><v>{value}</v></c>'
return (
f'<c r="{ref}" t="inlineStr"><is><t>{_xml_escape(str(value))}</t></is></c>'
)
def _minimal_workbook(rows: list[list[Any]]) -> bytes:
if XlsxWriterWorkbook is not None:
buffer = io.BytesIO()
workbook = XlsxWriterWorkbook(buffer, {"in_memory": True})
worksheet = workbook.add_worksheet("Sheet1")
for row_index, row in enumerate(rows):
for col_index, value in enumerate(row):
worksheet.write(row_index, col_index, value)
workbook.close()
return buffer.getvalue()
if OpenpyxlWorkbook is not None:
workbook = OpenpyxlWorkbook()
worksheet = workbook.active
worksheet.title = "Sheet1"
for row in rows:
worksheet.append(row)
buffer = io.BytesIO()
workbook.save(buffer)
workbook.close()
return buffer.getvalue()
sheet_rows = []
for row_index, values in enumerate(rows, start=1):
cells = "".join(
_xlsx_cell(value, row_index, col_index)
for col_index, value in enumerate(values, start=1)
)
sheet_rows.append(f'<row r="{row_index}">{cells}</row>')
entries = {
"[Content_Types].xml": '''<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
<Default Extension="xml" ContentType="application/xml"/>
<Override PartName="/xl/workbook.xml"
ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>
<Override PartName="/xl/worksheets/sheet1.xml"
ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>
<Override PartName="/xl/styles.xml"
ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/>
</Types>''',
"_rels/.rels": '''<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1"
Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument"
Target="xl/workbook.xml"/>
</Relationships>''',
"xl/workbook.xml": '''<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"
xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">
<sheets><sheet name="Sheet1" sheetId="1" r:id="rId1"/></sheets>
</workbook>''',
"xl/_rels/workbook.xml.rels": '''<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1"
Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet"
Target="worksheets/sheet1.xml"/>
<Relationship Id="rId2"
Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles"
Target="styles.xml"/>
</Relationships>''',
"xl/worksheets/sheet1.xml": f'''<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"
xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">
<sheetData>{''.join(sheet_rows)}</sheetData>
</worksheet>''',
"xl/styles.xml": '''<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<styleSheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">
<fonts count="1"><font><sz val="11"/><name val="Calibri"/></font></fonts>
<fills count="1"><fill><patternFill patternType="none"/></fill></fills>
<borders count="1"><border><left/><right/><top/><bottom/><diagonal/></border></borders>
<cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs>
<cellXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/></cellXfs>
</styleSheet>''',
}
buffer = io.BytesIO()
with zipfile.ZipFile(buffer, "w", compression=zipfile.ZIP_DEFLATED) as zout:
for name, data in entries.items():
zout.writestr(name, data.encode("utf-8"))
return buffer.getvalue()
def _minimal_category_chart_workbook(chart_data: dict[str, Any]) -> bytes:
categories = chart_data["categories"]
series = chart_data["series"]
rows: list[list[Any]] = [[None] + [item["name"] for item in series]]
for row_index, category in enumerate(categories):
rows.append([category] + [item["values"][row_index] for item in series])
return _minimal_workbook(rows)
def _minimal_xy_chart_workbook(chart_data: dict[str, Any]) -> bytes:
series = chart_data["series"]
is_bubble = chart_data["type"] == "bubble"
rows: list[list[Any]] = [[]]
for item in series:
rows[0].extend([f"{item['name']} X", item["name"]])
if is_bubble:
rows[0].append(f"{item['name']} Size")
max_points = max(len(item["x"]) for item in series)
for point_idx in range(max_points):
row: list[Any] = []
for item in series:
if point_idx < len(item["x"]):
row.extend([item["x"][point_idx], item["y"][point_idx]])
if is_bubble:
row.append(item["sizes"][point_idx])
else:
row.extend(["", ""])
if is_bubble:
row.append("")
rows.append(row)
return _minimal_workbook(rows)
def _minimal_chart_ex_workbook(chart_data: dict[str, Any]) -> bytes:
chart_type = chart_data["type"]
if chart_type in {"sunburst", "treemap"}:
levels = chart_data["levels"]
rows: list[list[Any]] = [[f"Level {idx + 1}" for idx in range(len(levels))] + ["Value"]]
for row_idx, value in enumerate(chart_data["values"]):
rows.append([level[row_idx] for level in levels] + [value])
return _minimal_workbook(rows)
if chart_type == "histogram":
return _minimal_workbook([["Value"]] + [[value] for value in chart_data["values"]])
if chart_type in {"funnel", "pareto", "waterfall"}:
rows = [["Category", "Value"]]
rows.extend(
[category, chart_data["values"][idx]]
for idx, category in enumerate(chart_data["categories"])
)
return _minimal_workbook(rows)
if chart_type == "box_whisker":
rows = [[]]
for item in chart_data["series"]:
rows[0].extend([f"{item['name']} Category", item["name"]])
max_rows = max(len(item["values"]) for item in chart_data["series"])
for row_idx in range(max_rows):
row: list[Any] = []
for item in chart_data["series"]:
if row_idx < len(item["values"]):
row.extend([item["categories"][row_idx], item["values"][row_idx]])
else:
row.extend(["", ""])
rows.append(row)
return _minimal_workbook(rows)
raise RuntimeError(f"Native PPTX {chart_type} chart is outside current basic chart support")
@@ -0,0 +1 @@
"""PPTX package assembly helpers for SVG-to-PPTX export."""
@@ -23,35 +23,35 @@ from xml.sax.saxutils import escape
from pptx import Presentation
from pptx.util import Emu
from .drawingml_converter import convert_svg_to_slide_shapes
from .pptx_dimensions import (
from ..drawingml.converter import convert_svg_to_slide_shapes
from .dimensions import (
CANVAS_FORMATS,
get_slide_dimensions, get_pixel_dimensions,
get_viewbox_dimensions, detect_format_from_svg,
)
from .pptx_media import (
from .media import (
PNG_RENDERER,
get_png_renderer_info, convert_svg_to_png, convert_svg_to_png_cached,
)
from .pptx_notes import (
from .notes import (
markdown_to_plain_text,
create_notes_master_rels_xml,
create_notes_master_xml,
create_notes_slide_xml,
create_notes_slide_rels_xml,
)
from .pptx_narration import (
from .narration import (
AUDIO_CONTENT_TYPES,
AUDIO_REL_TYPE,
AUDIO_MARKER_PNG_BYTES,
IMAGE_REL_TYPE,
MEDIA_REL_TYPE,
TRANSPARENT_PNG_BYTES,
apply_recorded_timing,
inject_narration,
next_shape_id,
probe_audio_duration,
)
from .pptx_slide_xml import (
from .slide_xml import (
ANIMATIONS_AVAILABLE, TRANSITIONS,
create_slide_xml_with_svg, create_slide_rels_xml,
)
@@ -124,6 +124,15 @@ def _add_default_content_type(content_types: str, extension: str, content_type:
return content_types.replace('</Types>', entry + '\n</Types>')
def _add_content_type_override(content_types: str, part_name: str, content_type: str) -> str:
"""Add an Override content type if it is not already present."""
normalized = '/' + part_name.lstrip('/')
if f'PartName="{normalized}"' in content_types:
return content_types
entry = f' <Override PartName="{normalized}" ContentType="{content_type}"/>'
return content_types.replace('</Types>', entry + '\n</Types>')
_IMAGE_CONTENT_TYPES = {
'png': 'image/png',
'jpg': 'image/jpeg',
@@ -629,6 +638,7 @@ def create_pptx_with_native_svg(
image_sizing: str = 'cap',
image_scale: float = 2.0,
image_quality: int = 85,
native_objects: bool = False,
conversion_trace_path: Path | None = None,
doc_metadata: dict[str, Any] | None = None,
) -> bool:
@@ -664,6 +674,8 @@ def create_pptx_with_native_svg(
from rendered SVG boxes.
image_scale: Target image pixels per SVG display pixel.
image_quality: JPEG quality used for opaque optimized rasters.
native_objects: Convert explicit ``data-pptx-native`` table/chart
markers to native PowerPoint objects. Default off.
conversion_trace_path: Optional JSON path for native conversion diagnostics.
Returns:
@@ -711,6 +723,10 @@ def create_pptx_with_native_svg(
print(f" SVG file count: {len(svg_files)}")
if use_native_shapes:
print(f" Mode: Native DrawingML shapes (directly editable)")
print(
" Native table/chart objects: "
f"{'Enabled' if native_objects else 'Disabled'}"
)
if image_optimize:
if image_sizing == 'display':
image_mode = (
@@ -787,6 +803,8 @@ def create_pptx_with_native_svg(
has_any_image = False
media_cache: dict[tuple[str, str], str] = {}
image_exts_used: set[str] = set()
package_exts_used: set[str] = set()
package_content_overrides: dict[str, str] = {}
notes_slides_created: set[int] = set()
narration_slides_created: set[int] = set()
audio_exts_used: set[str] = set()
@@ -800,7 +818,14 @@ def create_pptx_with_native_svg(
# ---- Native shapes mode ----
if use_native_shapes:
slide_cfg = _slide_config(animation_config, svg_path.stem)
slide_xml, media_files_dict, rel_entries, anim_targets = (
(
slide_xml,
media_files_dict,
rel_entries,
anim_targets,
package_files_dict,
content_type_overrides,
) = (
convert_svg_to_slide_shapes(
svg_path, slide_num=slide_num, verbose=verbose,
merge_paragraphs=merge_paragraphs,
@@ -809,6 +834,7 @@ def create_pptx_with_native_svg(
image_sizing=image_sizing,
image_scale=image_scale,
image_quality=image_quality,
native_objects=native_objects,
trace_out=conversion_trace,
)
)
@@ -903,6 +929,19 @@ def create_pptx_with_native_svg(
if mapped_name:
rel['target'] = f'../media/{mapped_name}'
# Write non-media OOXML package parts produced by native
# object converters, e.g. chart XML, chart rels, and
# embedded workbooks.
for part_name, part_data in package_files_dict.items():
package_path = extract_dir / part_name
package_path.parent.mkdir(parents=True, exist_ok=True)
with open(package_path, 'wb') as f:
f.write(part_data)
suffix = package_path.suffix.lstrip('.').lower()
if suffix:
package_exts_used.add(suffix)
package_content_overrides.update(content_type_overrides)
# Build relationships XML
rels_dir = extract_dir / 'ppt' / 'slides' / '_rels'
rels_dir.mkdir(exist_ok=True)
@@ -917,7 +956,9 @@ def create_pptx_with_native_svg(
rels_xml = f'''<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideLayout" Target="../slideLayouts/slideLayout1.xml"/>{extra_rels}
<Relationship Id="rId1"
Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideLayout"
Target="../slideLayouts/slideLayout1.xml"/>{extra_rels}
</Relationships>'''
with open(rels_path, 'w', encoding='utf-8') as f:
f.write(rels_xml)
@@ -964,7 +1005,10 @@ def create_pptx_with_native_svg(
image_exts_used.add('png')
else:
if verbose:
print(f" [{i}/{len(svg_files)}] {svg_path.name} - PNG generation failed, using pure SVG")
print(
f" [{i}/{len(svg_files)}] {svg_path.name} - "
"PNG generation failed, using pure SVG"
)
svg_rid = 'rId2'
slide_xml_path = extract_dir / 'ppt' / 'slides' / f'slide{slide_num}.xml'
@@ -1037,7 +1081,7 @@ def create_pptx_with_native_svg(
poster_name = 'narration_poster.png'
poster_path = media_dir / poster_name
if not poster_path.exists():
poster_path.write_bytes(TRANSPARENT_PNG_BYTES)
poster_path.write_bytes(AUDIO_MARKER_PNG_BYTES)
has_any_image = True
image_exts_used.add('png')
@@ -1116,6 +1160,14 @@ def create_pptx_with_native_svg(
ext,
_content_type_for_extension(ext),
)
if 'xlsx' in package_exts_used:
content_types = _add_default_content_type(
content_types,
'xlsx',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
)
for part_name, content_type in sorted(package_content_overrides.items()):
content_types = _add_content_type_override(content_types, part_name, content_type)
with open(content_types_path, 'w', encoding='utf-8') as f:
f.write(content_types)

Some files were not shown because too many files have changed in this diff Show More