feat(ppt): skill 重构为 SVG-first(移植 ppt-master,弃 python-pptx 版式件)(bump 0.33.0)

旧 python-pptx 固定组合版式件是版面单调/AI 味的架构天花板。改为 SVG-first:
AI 逐页手写 SVG 设计稿 → 纯 Python 转换器逐元素译成原生可编辑 DrawingML。

- 搬引擎:svg_to_pptx/ 转换器 + finalize_svg/svg_finalize + svg_quality_checker + total_md_split + update_spec(依赖闭包干净,只需 python-pptx)
- 搬知识:references(shared-standards/executor-base/strategist/image-layout-*/canvas-formats)+ 5 叙事骨架 + 19 视觉风格
- 搬模板:templates(layouts/decks/brands/charts + 图标库 1.1w+ + spec 骨架)
- 换 GUI:浏览器 Confirm UI → 聊天 BLOCKING 八条确认;live preview → svg_preview.py(无头 Chrome 渲 SVG→PNG);配图走 zcbot imagegen skill
- 默认主题改自由设计(商务红降为候选之一)
- 修 Windows GBK 控制台 UnicodeEncodeError:6 个入口脚本加 sys.stdout.reconfigure(utf-8) shim
- 端到端验证通过:4 页材料领域 deck,质检 0 error → finalize 嵌图标 → 导出原生 pptx → 渲图肉眼验收(swiss-minimal 设计级,非 AI 味)

移植自 github.com/hugohe3/ppt-master (MIT),适配 zcbot task_dir/聊天确认/imagegen 工作流。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
caoqianming 2026-06-29 16:38:58 +08:00
parent d4aa5ccbec
commit e3a432dcdd
11951 changed files with 173246 additions and 3048 deletions

View File

@ -2,7 +2,7 @@
> 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 1-2 句:做了啥 + 关键判断;细节查 `git log` / `git diff` / `DESIGN §7.9`
最后更新:2026-06-29(channel 长会话上下文软重置 + bump 0.32.0)
最后更新:2026-06-29(ppt skill 重构为 SVG-first,移植 ppt-master + bump 0.33.0)
---
@ -21,6 +21,16 @@
## 已完成关键能力
### 2026-06-29 / ppt skill 清空重构为 SVG-first(移植 ppt-master,bump 0.33.0)
- 背景:旧 ppt skill 用 python-pptx + 固定组合版式件(`add_card_grid` 等),版面被 helper 框死 → 单调、AI 味重,是架构天花板,调参救不了。用户要求"清空重做,参考 github ppt-master"。
- 路线(范围 B:搬引擎+知识、弃 GUI、适配 zcbot):核心改为 **SVG-first** —— AI 逐页手写 SVG 设计稿,再由纯 Python 转换器(`svg_to_pptx/`,只依赖 python-pptx)逐元素译成原生可编辑 DrawingML。依赖闭包干净:转换器/质检/finalize 三套自包含,不碰 ppt-master 的 config/project_manager 重型层。
- 搬入:引擎(`svg_to_pptx.py`+包 / `finalize_svg.py`+`svg_finalize/` / `svg_quality_checker.py` / `total_md_split.py` / `update_spec.py` / 辅助 `project_utils`+`error_helper`);设计知识 references(`shared-standards`/`executor-base`/`strategist`/`image-layout-*`/`canvas-formats` + `modes/`5 + `visual-styles/`19);templates 全量(layouts/decks/brands/charts + **icons 30MB/1.1w+ 图标,用户要求一并入仓**)。
- 弃用/替换:浏览器 Confirm UI → 聊天 BLOCKING 八条确认;live preview server → 新写 `svg_preview.py`(无头 Chrome 渲 SVG→PNG,优先渲 svg_final 显图标);TTS/复杂动画(动画留 opt-in);ppt-master 配图子系统 → 走 zcbot 现有 imagegen skill。默认主题改"自由设计"(商务红降为候选)。
- 踩坑修复:vendored 脚本 print 含 ©/NBSP/emoji,在 zcbot Windows GBK stdout 上 `UnicodeEncodeError` 崩([[feedback_windows_console_emoji]])→ 给 6 个入口脚本顶部加 `sys.stdout.reconfigure(utf-8)` shim。
- 端到端验证通过:造材料领域 4 页 deck(低碳水泥),质检 0 error → 拆备注 → finalize 嵌图标 → 导出 4 页原生 pptx(13.33×7.5in、每页带备注)→ svg_preview 渲 PNG 肉眼确认设计级观感(swiss-minimal,非 AI 味)。
- 文件:`skills/ppt/`(SKILL.md 重写 + scripts/ + references/ + templates/);依赖加 Pillow(svglib/reportlab 注释为可选老 Office 兜底)。
### 2026-06-29 / system prompt 加通用 context 纪律铁律(bump 0.32.5)
- 承上:反复 dump 全文 abstract 烧 2.5M token 不是 brief 专属,任何 skill 让弱模型处理一批长文本都可能踩。故在 system prompt 单一事实源 `prompts/system/general_v1.md` 的「工作原则」段、紧挨「少来回」加一条全局铁律:大段 `run_python`/`shell` 输出会进对话历史每轮重发,中间数据落文件、只 read 用得上的片段、别整批重复打印。

View File

@ -1,7 +1,7 @@
# zcbot Skill 清单
服务对象:中国建筑材料科学研究总院 —— 无机非金属材料 R&D(水泥 / 混凝土 / 玻璃 / 陶瓷 / 耐火 / 新型建材)
最后更新:2026-06-18
最后更新:2026-06-29(ppt skill 重构为 SVG-first,移植自 ppt-master)
Skill 总数:17
zcbot 的"skill"是一份可加载的工作流脚本(`skills/<name>/SKILL.md` + 配套 templates / scripts / Python helper),模型在识别用户意图后挂载对应 skill,按其内置的阶段化流程产出可交付物。本文档面向**使用方 / 协作方**,按"做什么、什么时候用、什么时候别用、典型产物"组织。
@ -19,7 +19,7 @@ zcbot 的"skill"是一份可加载的工作流脚本(`skills/<name>/SKILL.md` +
| 科研写作 | [standard](#standard) | 起草标准:国标 / 行标 / 团标(含 T/CSTM)+ 编制说明 |
| 科研写作 | [patent](#patent) | 写发明专利技术交底书(供代理师转写) |
| 科研写作 | [review](#review) | 审稿 / 润色 / 校对(中英文,长文档分段深审) |
| 演示出图 | [ppt](#ppt) | 生成 PowerPoint 演示稿(商务红主题,大纲对齐后一脚本整建) |
| 演示出图 | [ppt](#ppt) | 生成可编辑 PowerPoint(SVG-first:逐页手写 SVG → 转原生 DrawingML;19 种视觉风格 + 模板库) |
| 演示出图 | [plot_pub](#plot_pub) | 出版级 matplotlib 学术图(中文 + viridis + 矢量 + 投稿级复合图设计纪律) |
| 文献检索 | [research](#research) | 查 paper_server(OpenAlex 元数据 + Sci-Hub 下载) |
| 文献检索 | [documents](#documents) | 查内部 7 学科材料知识库(100W+ 论文,跨语言检索;host-side tool 持 key) |
@ -168,41 +168,31 @@ zcbot 的"skill"是一份可加载的工作流脚本(`skills/<name>/SKILL.md` +
## 演示出图
### ppt
**生成 PowerPoint 演示文稿 (.pptx)。**
**生成可编辑 PowerPoint 演示文稿 (.pptx)。SVG-first 路线。**
把材料(汇报草稿 / 项目方案 / 调研报告)变成可演示的 .pptx。流程:**先定调(8 项 + 逐页大纲)→ 一个脚本建整 deck → quality_check 验收**。方向在大纲阶段对齐,执行阶段一把出稿(不逐页来回)。视觉走**卡片式系统**(圆角卡片 + 柔和投影 + 渐变 + 从主色派生的明暗色阶),原生可编辑,告别扁平办公模板观感
把材料(汇报草稿 / 项目方案 / 调研报告)变成可演示、**可编辑**的 .pptx。流程:**素材摄取 → 八条对齐 + 逐页大纲(spec)→ [配图] → 逐页手写 SVG → SVG 质检 → 后处理 → 导出 PPTX → 渲图验收**。核心是 AI 把每页当**矢量设计稿手写成 SVG**(设计自由度=浏览器级),再由纯 Python 转换器逐元素译成**原生 DrawingML**(形状/文本/渐变都能在 PowerPoint 里选中改)——告别 python-pptx 固定版式件的单调与 AI 味
**触发**:
- ✅ 用户明确点名 PPT / 幻灯片 / 演示文稿 / .pptx / slide / deck
- ⛔ 用户明确说"报告 / 文档 / 纪要"等纯文档产物 → 不走本 skill
- ⚠️ 用户说"汇报 / 方案 / 材料"等产物形态不明 → **先反问** PPT 还是 Word/Markdown,确认后再 load
**默认主题 —— 商务红**(硬约束):
- 主色 `#C00000` / 辅色 `#E15554` / 强调色 `#FFC107`
- ⛔ 不允许擅自换色,除非用户明确点其它配色或提供 brand guideline
**默认主题 —— 自由设计**(content-driven):按内容+受众+选定 visual_style 派生配色版式,spec 阶段给 ≥3 套候选挑;商务红/品牌色作为候选之一,用户点名或素材有 brand guideline 才锁定。
**八条对齐**(spec 阶段定稿):
| # | 项 | 默认值 |
|---|---|---|
| 1 | 画布 | 16:9 (13.33×7.5 in) |
| 2 | 页数 | 封面 + 5-8 页正文 + 尾页(Q&A) = 7-10 页 |
| 3 | 受众 | 看材料推断:领导汇报 / 同行评审 / 客户 pitch |
| 4 | 风格 | 现代简约(白底 + 细线 + 留白) |
| 5 | 配色 | 商务红 |
| 6 | 字体 | 微软雅黑 + Arial |
| 7 | 图标 | Iconify `tabler` 集(主色染色,本地缓存;概念页配图标底块) |
| 8 | 图表 / 配图 | 数据图 matplotlib / 少量数字上 KPI 卡;真实配图 opt-in 走 imagegen(每张 ¥0.22) |
**八条对齐**(spec 阶段定稿,ah):画布 / 页数 / 受众+核心信息+投递目的 / mode+visual_style / 配色 / 图标库 / 字体+字号 / 配图。确认后产出两份引擎契约:`design_spec.md`(人读叙事)+ `spec_lock.md`(机读执行锁,executor 每页重读、抗长 deck 漂移)。
**核心能力**:
- **信息设计纪律(咨询级的真功)**:论断式标题(写结论不写主题)、Takeaway 结论框、数据语境化(数字带对比基准+趋势)、page_rhythm 节奏(anchor/dense/breathing,breathing 页强制打破卡片网格)
- **组合版式件**(一函数一整块):`add_card_grid`(均衡网格)/ `add_timeline`(时间轴)/ `add_cycle`(闭环)/ `add_toc`(目录)/ `add_kpi`(数字卡带对比+升降)/ `add_takeaway` / `add_source`
- **质感工具箱**:`add_card`(圆角卡,投影克制——平铺卡默认平)/ `add_gradient_rect` / `add_icon_tile` / `add_pill` / 派生明暗色阶 + 语义色 `GOOD/BAD`
- **混合背景** `render_bg.py`:无头 Chrome 渲杂志级背景图 + 其上原生可编辑文字(封面/章节)
- **观感验收** `pptx_preview.py`:把 .pptx 渲成 PNG 肉眼验版面(quality_check 查结构,预览查好看)
- 演讲者备注 `add_notes` + 业务图标双层兜底(Iconify → 本地缓存 → unicode)
- `quality_check.py` 结构验收(越界 / 溢出 / 按列 bullet / 按色系三色制 / 重叠)+ markitdown 素材摄取
- **SVG→原生 PPTX 转换器**:逐元素译 DrawingML(圆角矩形/渐变/阴影/箭头/裁切图都映射原生),非截图嵌图,完全可编辑;默认嵌演讲者备注 + Office 兼容兜底
- **19 种视觉风格 + 5 种叙事骨架**:editorial / swiss-minimal / glassmorphism / dark-tech / data-journalism… × pyramid / narrative / instructional / showcase / briefing —— 去 AI 味的关键
- **模板库**:layouts(版式)/ decks(整套:中汽研/招商银行/重庆大学等)/ brands(品牌)/ charts(71 个图表信息图)/ icons(5 套共 1.1w+ 图标,finalize 自动内嵌)
- **逐页节奏纪律**:论断式标题、page_rhythm(anchor/dense/breathing,breathing 页禁卡片墙)、内容→版式映射、图文版式 72 式
- **SVG 质检** `svg_quality_checker.py`:禁用特性 / viewBox / spec_lock 漂移 / 配色越界(error 必改,回写 SVG)
- **渲图验收** `svg_preview.py`:无头 Chrome 把 SVG 渲成 PNG 肉眼/vision 验版面;`update_spec.py` 一键改色/字体传播到所有 SVG
- AI 配图走 imagegen skill;markitdown 素材摄取
**典型产物**:`<task>.pptx` + `build_deck.py`(整 deck 构建脚本,改稿/修验收项都改它重跑)。
**典型产物**:`exports/<topic>_<ts>.pptx`(原生可编辑)+ `svg_output/*.svg`(逐页设计源,改稿对象)+ `design_spec.md`/`spec_lock.md`。
> 引擎/知识/模板移植自开源 **ppt-master**(github.com/hugohe3/ppt-master,MIT),适配 zcbot 的 task_dir / 聊天确认 / imagegen 工作流。
---

View File

@ -1,3 +1,3 @@
# zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。
# 改版本只动这一行。
__version__ = "0.32.5"
__version__ = "0.33.0"

View File

@ -7,6 +7,10 @@ rich>=13.7.0
python-pptx>=0.6.21
python-docx>=1.1.0
matplotlib>=3.8.0
Pillow>=9.0.0 # ppt skill(SVG-first)svg_finalize:配图裁切/内嵌
# ppt skill 可选 —— 老版 Office(<2019)的 SVG→PNG 兜底;现代 PowerPoint 直接渲 SVG 无需,核心不依赖:
# svglib>=1.5.0
# reportlab>=4.0.0
markdown>=3.5 # skills/_shared/render_pdf.py: md→HTML→chromium 出 PDF(纯 Python,host/sandbox 通吃)
# 素材摄取: PDF/DOCX/PPTX/XLSX/HTML/URL → Markdown (ppt 阶段零 + proposal 阶段零)

28
skills/ppt/ATTRIBUTION.md Normal file
View File

@ -0,0 +1,28 @@
# 第三方来源与许可 (Attribution)
本 skill 的 SVG→PPTX 引擎、设计知识 references、模板与图标库**移植自开源项目 ppt-master**,并适配 zcbot 的 task_dir / 聊天确认 / imagegen 工作流。
## ppt-master
- 仓库:https://github.com/hugohe3/ppt-master
- 许可:MIT License
- 作者:Hugo He
- 移植范围(范围 B):
- **引擎**:`scripts/svg_to_pptx/`、`scripts/svg_finalize/`、`svg_quality_checker.py`、`finalize_svg.py`、`svg_to_pptx.py`、`total_md_split.py`、`update_spec.py`、`project_utils.py`、`error_helper.py`
- **设计知识**:`references/`(shared-standards / executor-base / strategist / image-layout-* / canvas-formats / modes / visual-styles / animations)
- **模板库**:`templates/`(layouts / decks / brands / charts / icons + spec 骨架)
- **未移植**:浏览器 Confirm UI、live preview server、TTS 配音子系统、AI 配图/网图子系统(zcbot 走自己的 imagegen skill)。
- zcbot 侧改动:`SKILL.md` 重写为两阶段聊天确认流;新增 `svg_preview.py`(无头 Chrome 渲 SVG→PNG 验收);入口脚本加 Windows GBK 控制台兼容 shim。
## 图标库 (templates/icons/)
各图标集沿用其上游许可,商用前以上游为准:
| 库 | 上游 | 许可 |
|---|---|---|
| tabler-outline / tabler-filled | Tabler Icons | MIT |
| phosphor-duotone | Phosphor Icons | MIT |
| simple-icons | Simple Icons | CC0 1.0(品牌标识版权归各品牌方,仅按其品牌规范使用) |
| chunk-filled | 见 templates/icons/README.md | 见上游 |
详见 `templates/icons/README.md`

View File

@ -3,228 +3,203 @@ name: ppt
description: 生成 PowerPoint 演示文稿 (.pptx) 文件。✅ 触发:用户明确点名 PPT / 幻灯片 / 演示文稿 / .pptx / slide / deck 之一。⛔ 不触发:用户明确说要"报告 / 文档 / 纪要"等指向纯文档形式的产物。⚠️ 歧义先反问:用户说"汇报 / 方案 / 材料"等产物形态不明的词、且没说成品形式时,不要直接 load 本 skill 也不要假定走文档,先反问一句"这份要做成 PPT 演示稿,还是 Word/Markdown 文档?" 用户确认 PPT 后再 load。
---
# PPT
# PPT(SVG-first)
把材料变成可演示的 .pptx。**先定调(spec + 逐页大纲),再出稿(一个脚本建整 deck),再验收(quality_check)** —— 方向在大纲阶段对齐,不在逐页阶段反复来回。
把材料变成**可演示、可编辑**的 .pptx。
进度展示建议:多页 deck 任务用 `task_progress` 标记「摄取素材 / 八条对齐 + 逐页大纲 / 图标预取 / 脚本建 deck / 质量检查 / 交付」等关键阶段;不要把每一页的内部写入都作为进度步骤。
**核心管线**:`素材 → 策略(spec)→ [配图] → 执行(逐页手写 SVG)→ SVG 质检 → 后处理 → 导出 PPTX → 渲图验收`
> **为什么是 SVG**:不再用 python-pptx 拼固定版式件(那是版面单调/AI 味的天花板)。AI 把每页当**矢量设计稿手写成 SVG**(设计自由度 = 浏览器级),再由纯 Python 转换器逐元素译成**原生可编辑的 DrawingML**(形状/文本/渐变都能在 PowerPoint 里选中改)。SVG 与 DrawingML 是同一套"绝对坐标 2D 矢量"世界观的两种方言,转换是翻译而非格式硬凑。详见 `references/shared-standards.md`
> 进度展示:多页 deck 用 `task_progress` 标记「摄取素材 / 八条对齐 + 逐页大纲 / [配图] / 逐页 SVG / 质检 / 导出 + 验收」等关键阶段;不要把每页内部写入都当进度步骤。
## 资源
- `scripts/pptx_helpers.py` —— **卡片式视觉工具箱模块**:配色/字体常量 + 派生明暗色阶(`PRIMARY_WASH/SOFT/DARK`)+ 语义色 `GOOD/BAD` + `new_presentation`/`set_palette` + **组合版式件**(一个函数摆一整块):`add_card_grid`(均衡网格)/`add_timeline`(时间轴)/`add_cycle`(流程闭环)/`add_toc`(目录)/`add_kpi`(数字卡,带 baseline+delta)/`add_takeaway`(结论框)/`add_source`(数据来源)+ 质感件 `add_card`(圆角卡,**默认平卡**)/`add_gradient_rect`/`add_icon_tile`/`add_pill`/`add_eyebrow`/`add_picture_bg`(混合背景)+ `add_notes`(演讲者备注)+ 基础件 `add_textbox`/`page_title`/`apply_brand`。`import pptx_helpers as P` 调用,**不默写源码**。⚠️ helper 的 `name=` 会写进形状名,quality_check 靠它判标签/bullet
- `references/design_principles.md` —— **§信息设计纪律(论断标题/Takeaway/数据语境化/page_rhythm)** + 画布/字号/配色/投影克制/字数预算等硬规则。**先读这节**
- `references/layouts.md` —— 13+ 版式与组合件调用示例 + helper API 速查 + 安全区保护
- `references/icons.md` —— 业务图标两层:Iconify (在线/本地缓存) / unicode 字形兜底
- `assets/icons/` —— **只读**种子图标库 (商务红 tabler 集,见 `INDEX.md`;新拉的图标写 `<task_dir>/assets/icons/`)
- 素材摄取: 用 `markitdown` CLI 把 PDF/DOCX/PPTX/XLSX/HTML/URL 转干净 Markdown,落到 `<task_dir>/source/<name>.md`
- `scripts/fetch_icon.py` —— 从 Iconify CDN 拉 SVG/PNG (染主题色;**PNG 转换需 cairosvg/svglib,没装会只出 SVG** —— 优先用种子库现成 PNG)
- `scripts/render_icon.py` —— unicode 字形 → 透明 PNG (Iconify 没有时兜底)
- `scripts/render_bg.py` —— 无头 Chrome 把主题化 HTML 渲成**杂志级背景 PNG**(混合方案:封面/章节背景图 + 其上原生可编辑文字)
- `scripts/pptx_preview.py` —— **把 .pptx 渲成 PNG 预览**(无头 Chrome),交付前**肉眼验收版面**(quality_check 查结构,预览查观感;能抓到多行不上色这类渲染 bug)
- `scripts/quality_check.py` —— 产物 .pptx 结构验收 (越界 / 文本溢出 / 按列 bullet / 按色系三色制 / 重叠)
## 默认主题 — 商务红 (硬约束)
**脚本**(host 上用 `.venv/Scripts/python.exe <skill_dir>/scripts/xxx.py ...` 跑;`<skill_dir>` = 本 skill 绝对路径):
- `svg_quality_checker.py` —— **SVG 结构质检**(禁用特性 / viewBox / spec_lock 漂移 / 配色越界等)。引擎,自包含
- `finalize_svg.py` —— **SVG 后处理**(图标内嵌 / 配图裁切内嵌 / tspan 展平 / 圆角矩形转 path)→ 产出 `svg_final/`
- `svg_to_pptx.py` —— **SVG → 原生 PPTX**(逐元素译 DrawingML;默认嵌演讲者备注 + Office 兼容 PNG 兜底)
- `total_md_split.py` —— 把 `notes/total.md` 拆成逐页备注(导出前跑)
- `update_spec.py` —— 改 `spec_lock.md` 的颜色/字体后,**一键传播到所有已生成 SVG**(改稿用)
- `svg_preview.py` —— **无头 Chrome 把 SVG 渲成 PNG** 供肉眼/vision 验收(SVG 是视觉真相;**替代**了浏览器 live preview)
- `project_utils.py` / `error_helper.py` —— 引擎辅助(canvas 校验 / 友好报错),被上面脚本 import,不直接调
**主色 `#C00000` / 辅色 `#E15554` / 强调色 `#FFC107`。**
**设计知识(references/,先读相关的,不默写)**:
- `shared-standards.md` —— **SVG→PPT 硬约束(禁用特性清单 / XML 良构陷阱 / 字体栈纪律)**,执行前**必读**
- `executor-base.md` —— 执行通则(模板继承 / 逐页 spec_lock 重读 / 字号纪律 / 内容→版式)
- `strategist.md` —— 策略通则(八条对齐内容 / 配色派生 / 字号阶 §g / 配图意图 §h / spec 产出);**注:其中"Confirm UI 浏览器确认页"机制在 zcbot 里用聊天确认替代,只取其设计判断**
- `image-layout-patterns.md` / `image-layout-spec.md` / `svg-image-embedding.md` —— 图文版式 72 式 + 并排尺寸算法 + 配图嵌入规范
- `canvas-formats.md` —— 画布格式(viewBox / 安全区)
- `modes/`(5 种叙事骨架:pyramid/narrative/instructional/showcase/briefing)+ `visual-styles/`(**19 种视觉风格**:editorial/swiss-minimal/glassmorphism/dark-tech/data-journalism/…)—— **去 AI 味的关键**,执行时按 spec 锁定的那一个读
- `animations.md` —— 导出动画(可选,默认只翻页淡入、无逐元素动画)
**不允许擅自换色**。除非满足以下任一条件,否则 spec 必须填这套红色:
- 用户在请求里**明确**点名其它配色 (例:"做成蓝色"、"用我们公司的紫色")
- 用户提供素材里有明确的 brand guideline / 配色卡
**模板库(templates/,opt-in,默认自由设计不读)**:
- `layouts/`(版式模板)/ `decks/`(整套替换:中汽研/招商银行/重庆大学等)/ `brands/`(品牌身份)/ `charts/`(71 个图表/信息图 SVG)—— 索引见各自 `*_index.json`
- `icons/` —— **5 套图标库**(tabler-outline/tabler-filled/chunk-filled/phosphor-duotone/simple-icons,共 1.1w+)。executor 写 `<use data-icon="<lib>/<name>">`,finalize 自动从这里内嵌(默认目录,无需预取);锁 inventory 前用 `ls templates/icons/<lib>/ | grep <关键词>` 验名
- `design_spec_reference.md` / `spec_lock_reference.md` —— **spec 产出骨架**,策略阶段写 spec 前必读
**禁止的自我合理化**(都属违规):
- "这个场景蓝色更专业" / "学术汇报红色不合适" / "财务用蓝更稳重"
- "我觉得 XX 主题更适合"
要换色,**先问用户**,不要在 spec 里塞自己的偏好。其它备选见 `design_principles.md §2`
## 两阶段工作流
### 阶段一: 策略 (Strategist) — 八条对齐
产物:**task 级 spec 文件** —— 整个 deck 的"宪法",阶段二每页前都要重读。文件路径按 system prompt 的《task 级「宪法」文件命名约定》:
<task_dir>/<today>-<task_short_id>-<task_name>.spec.md
`<today>` / `<task_short_id>` / `<task_name>` 用 system prompt 注入的实际值替换。
**0. 先检测已有 spec**:
```
glob <task_dir>/*-<task_short_id>-*.spec.md → 按文件名字典序排,取最大者作 current
```
(按 short_id 主锚,name 部分不参与匹配 — 用户改过 task name 时旧文件仍能定位)
- 有 current(当前 task 已有 spec) → 展示给用户,问「**沿用进阶段二** / **重定调**(以 today 写新版,旧版保留)」,⛔ BLOCKING 等用户决定
- 仅有其它 task 的(`*-<别的 short_id>-*.spec.md`)→ 不当 current 用,继续走下面流程
- 完全没有 → 直接走下面流程
按下表**一次性给出推荐方案**,然后 ⛔ **BLOCKING:等用户确认/修改后才能进阶段二**。不要一条一条问。
| # | 项 | 默认值 |
|---|----|-------|
| 1 | 画布 | **16:9** (13.33×7.5 in) |
| 2 | 页数 | **封面 + 5-8 页正文 + 尾页(Q&A)** = 共 7-10 页。**封面 / 尾页强制必有**,不在 5-8 页预算里 |
| 3 | 受众 | 看材料推断:领导汇报 / 同行评审 / 客户 pitch |
| 4 | 风格 | **现代简约** (白底 + 细线 + 留白) |
| 5 | 配色 | **商务红** `#C00000` `#E15554` `#FFC107` (见上"默认主题") |
| 6 | 字体 | **微软雅黑 + Arial** |
| 7 | 图标 | **Iconify `tabler` 集** (描边商务图标,主色染色;`fetch_icon.py` 拉到 `<task_dir>/assets/icons/`;业务概念页用 `add_icon_tile` 配图标底块) |
| 8 | 图表 / 配图 | 数据 ≥ 3 个点 → matplotlib 图(或 ≤4 个数字直接上 KPI 卡 L10);**真实配图 opt-in**:封面/章节/图片页可走 imagegen 生图(**每张 ¥0.22**,默认不开,要用在大纲里标 `[img]` 并经用户确认) |
把这 8 项写进上面那个 task 级 spec 文件,以表格形式给用户预览,问一句"按这个开干?"。**spec 写定后不再改**(要改就走 §0 的「重定调」分支,以 today 为前缀写新版,旧版保留)。
**8 项之外,spec 还要含一张「逐页大纲」表** —— 阶段二一个脚本建整 deck 的输入,也是替代"逐页确认"的前置 checkpoint。**标题写论断、每页标节奏**(见 design_principles §信息设计纪律):
| 页 | 节奏 | 版式 | **论断式标题** | 核心信息 / Takeaway | 图标 / 图表 / 配图 |
|---|---|---|---|---|---|
| 1 | anchor | L1 封面 | <主标题> | <副标题 / 定位> | 可选 `[img]` 主图 |
| 2 | anchor | 目录 | 目录 | <5 + 各一句副标> | — |
| 3 | dense | 卡片网格 | "大模型靠规模涌现出通用智能" | <3-5 概念 + 一句 takeaway> | `brain`/`cpu`/… |
| 4 | dense | 时间轴 | "六年能力指数跃迁" | <里程碑 + takeaway + 来源> | — |
| 5 | **breathing** | 大字页 | "2 个月,月活破亿" | <单个大数字 + 一句语境对比> | — |
| … | … | … | … | … | … |
| N | anchor | 尾页 | 致谢 / Q&A | <联系方式> | — |
> **三条硬纪律(大纲阶段就定死)**:
> - **论断标题**:标题列写"结论"不写"主题"("渗透率破 60%" 不是 "行业背景");
> - **节奏不雷同**:相邻内容页不同版式;**每隔 2-3 页插一个 `breathing` 页**(大字/金句/整图,禁卡片网格)打破"全卡 = AI 味";**卡片网格全 deck ≤2 次**;
> - **内容→版式映射**:历程→时间轴、循环→闭环、2-4 数字→KPI 卡(带对比基准)、并列概念→均衡网格、单震撼数字→breathing 大字。
>
> 内容页正文优先压成一句 **Takeaway 结论**;含数据的页要有**对比基准 + 来源**。版式见 layouts.md §选版式速查。配图页标 `[img]` + 一句画面。
大纲连同 8 项一起给用户预览,**BLOCKING 等用户确认整份结构**(页数、每页讲什么、节奏、版式)后再进阶段二。用户在这一步推翻方向 = 改表格文字,零 slide 返工。
### 阶段二: 执行 (Executor) — 一个脚本建整 deck
方向已在阶段一的「逐页大纲」里跟用户对齐过,执行阶段就是把大纲机械落成 slide。**不逐页 run_python**(每页一轮来回烧轮数/token);整 deck 在一个脚本、一个进程内构建,坐标天然一致(`pptx_helpers` 已把画布常量统一,漂移问题已解决)。
流程:
1. **读 current spec**(按 §0 的 glob 规则拿字典序最大那份),含 8 项 + 逐页大纲;只用里面定的颜色/字体/图标/页结构,**不凭记忆发挥**。
2. **图标批量预取(全 deck 一次,不逐页)**: 把大纲里所有页需要的图标概念汇总,`glob` 两处看现成 —— 种子库 `<skill_dir>/assets/icons/`(只读)+ 本 task `<task_dir>/assets/icons/`;缺的在**一个 `run_python` 里批量** `fetch_icon.py <name> --set tabler --color C00000 --size 128 -o <task_dir>/assets/icons/...` 拉齐。**几何形状(圆点/徽章/装饰线)不算图标,走 layouts.md helper**。
3. **真实配图(opt-in,仅当大纲标了 `[img]`)**: 把标 `[img]` 的页(封面/章节/图片页)汇总,**load `imagegen` skill 走它自己的确认流程**逐张生成(每张 ¥0.22,有强制确认门,不要绕过),产物落 `<task_dir>/figures/`;build_deck 里 `add_picture(<figures 路径>)` 引用。**没标 `[img]` 的 deck 跳过这步**,图标/卡片/渐变已足够撑视觉。
4. **混合背景(opt-in)**:封面/章节想要杂志级背景时,`run_python` 调 `render_bg.py --out <task_dir>/figures/cover_bg.png --kind cover --primary <主色>`(+ section),build_deck 里 `P.add_picture_bg(slide, bg)` 铺底再叠**白色**文字。**背景图不可编辑、文字可编辑**——这是 editable 前提下的最高观感。
5. **写 `build_deck.py` 到 `<task_dir>`,一次建整 deck**: 顶部 `import pptx_helpers as P``P.new_presentation``P.set_palette(spec_path=...)`**按大纲循环每页**(每页一个小函数)→ 末尾 `prs.save`。落实**信息内功**(见 design_principles §信息设计纪律):
- **论断式标题**(写结论)+ 内容页 `P.add_takeaway(slide, "<一句话结论>")`;
- 含数据用 `P.add_kpi(..., baseline=, delta=)` + `P.add_source`;**数字别孤立**;
- **节奏**:按大纲的 anchor/dense/breathing 落版式,breathing 页走大字/金句/整图(**禁卡片网格**);
- **投影克制**:平铺网格卡用 `add_card`(默认平卡),投影只给悬浮/被挑出的卡,每页 ≤2-3 个;
- 每页 `P.add_notes` 写 2-4 句**结论先行的口语**演讲稿。
helper 一律 `P.xxx` 不默写源码;版式见 layouts.md。先 `write` 脚本再 `run_python(script_path=...)`
6. **quality_check + 预览双验收**(见阶段三)→ 按报告**改 `build_deck.py` 重跑**(不逐页 edit 成品)。
7. 报整份 deck:页数、各页版式/节奏、用到的图标/配图;问用户要不要改。
8. 用户确认了**实质改动**后,追加一行到 `<task_dir>/REVISIONS.md` —— 见 §修订日志。
**风格探针(可选,降视觉返工险)**: 用户对观感没底、或这是全新风格时,可先只建**封面 + 1 内页**给用户看一眼,确认后把 `build_deck.py` 的页范围放开重跑补齐其余页 —— 仍是改一个脚本,不退回逐页。用户要快("直接全做")就跳过探针,整 deck 一把出。
**为什么不再逐页?** 逐页的两个理由都已消解:① 防坐标漂移 → `pptx_helpers` 模块化已解决;② 早发现方向问题 → 前移到阶段一「逐页大纲」确认(改文字比改 slide 便宜),视觉观感由可选探针 + 整 deck 后批改兜底。代价是放弃"逐页即时纠错",换来 N 页从 ~2N 轮降到 ~3-4 轮。
### 阶段三: 验收 (结构 + 观感 双验)
**① 结构验收** `quality_check.py`(越界/溢出/三色/重叠):
```bash
python <skill_dir>/scripts/quality_check.py <task_dir>/<output.pptx> --spec <task_dir>/<today>-<task_short_id>-<task_name>.spec.md
```
**② 观感验收** `pptx_preview.py`(渲成 PNG **肉眼看版面**)—— quality_check 查不出"好不好看 / 文字层级 / 留白 / 多行文本掉色"这类问题,**交付前必须渲几页关键页用 `read` 亲眼过**:
```bash
python <skill_dir>/scripts/pptx_preview.py <task_dir>/<output.pptx> -o <task_dir>/preview --pages 1,3,5
```
看封面、一个内容页、breathing 页是否如预期(标题层级、卡片是否过挤/过空、文字是否都正常上色、节奏是否单调)。
两项不通过的,**改 `build_deck.py` 重跑**(改源脚本可复现;不要直接 edit 成品 .pptx)。
## 设计原则 (硬规则速查)
- **每页一个核心信息**: 一页讲一件事,塞两件就拆页
- **内容装进卡片**: 内容页主力容器是 `add_card`(圆角+柔和投影),白底之上靠卡片浮起分层,别让元素裸贴白纸
- **概念配图标底块**: 业务概念(能力/模块/策略)用 L11 卡片网格 + `add_icon_tile`,**别只摆圆点 bullet**(视觉太单薄)
- **数字上 KPI 卡**: 2-4 个关键数字用 L10 `add_kpi`,优先于硬画柱状图;单个震撼数字用 L13
- **bullet ≤ 5 条/列**: 单列超过就拆页或改卡片网格;双栏对比左右各 ≤5
- **正文不写完整段落**: 列要点;长句留给演讲者口述(写进 `add_notes`)
- **数据 ≥ 3 个点应有图表**: matplotlib 生成 .png 嵌入(或转 KPI 卡)
- **中文标题 ≤ 30 字**
- **配色三色封顶 + 派生阶**: 主 + 辅 + 强调三色系,浅底/卡片底走 `set_palette` 自动派生的 `PRIMARY_WASH/SOFT`,不算新色
- **渐变只用在大色块**: 封面/章节用 `apply_brand` 内置渐变;渐变深底上文字一律用白/`ACCENT_SOFT`
- **每页演讲者备注**: `add_notes` 写 2-4 句口述要点(正式产物标配)
- **Shape 不能越界**: helper 内置 `assert_inside` 生成时即报错
- **字数按预算来**: 写 bullet 前查 `design_principles.md §4.1` 字数预算表;卡片内按"卡宽 - 0.8"算框宽
- 详细规则见 `references/design_principles.md`
**素材摄取**:用 `markitdown` CLI 把 PDF/DOCX/PPTX/XLSX/HTML/URL 转 Markdown,落 `<project_dir>/sources/<name>.md`
## 工作目录约定
下文 `<task_dir>` = system prompt 里「task_dir」给的**绝对路径**(host 下形如 `…/workspace/users/<uid>/<wd>/`,docker 沙盒里是 `/workspace/<wd>/`)。**所有产物都写到 task_dir 下**,不要写到 cwd / `skills/` / repo 根;图标分两处:skill 自带的**只读种子库**走 `<skill_dir>/assets/icons/`(docker 沙盒里 skills 只读,只读不写),`fetch_icon.py` 新拉的图标写 `<task_dir>/assets/icons/`(详见 references/icons.md §A)。
`<task_dir>` = system prompt 注入的绝对路径。**每份 deck 用一个独立 project 目录** `<project_dir> = <task_dir>/<deck_slug>/`(`deck_slug` 按主题取,多 deck 不撞)。引擎契约文件(`design_spec.md`/`spec_lock.md`)和各产物子目录都在 `<project_dir>` 下:
```
<task_dir>/
├── source/ # markitdown 转出的素材(同 working_dir 多 task 共享;用 markitdown -o <task_dir>/source/<name>.md)
├── <today>-<task_short_id>-<task_name>.spec.md # 八条对齐落定,task 级宪法;命名见 system prompt 约定;按 short_id 主锚,重定调时写新日期,旧版保留
├── slides/ # 各页 matplotlib 图表 (chart_p3.png 等),多 task 时文件名前缀区分
├── figures/ # imagegen 生成的真实配图 (opt-in;封面/章节主图),由 imagegen skill 落盘
├── assets/icons/ # fetch_icon.py 新拉的主题色图标(种子库在 skill 只读侧)
├── build_deck.py # 整 deck 构建脚本(一次建完所有页);改稿/修 quality_check 项都改它重跑
├── REVISIONS.md # 修订日志:每次卡点用户确认的实质改动,见 §修订日志
└── <topic>.pptx # 最终产物 (按主题命名,多 task 时主题必须不同)
<project_dir>/
├── sources/ # markitdown 转出的素材
├── design_spec.md # 人读:设计叙事(受众/风格/配色理由/逐页大纲)——引擎契约之一
├── spec_lock.md # 机读:执行锁(HEX/字体栈/图标/图片清单/page_rhythm/page_layouts)——executor 每页重读
├── images/ # 配图(imagegen 生成 / 用户提供 / 公式 PNG);SVG 里用 ../images/ 引用
├── templates/ # 仅当用户给了模板路径才有(模板 SVG + 其 design_spec)
├── icons/ # 可选:项目本地图标(没有则 finalize 回退到 skill 的 templates/icons/)
├── svg_output/*.svg # ★ executor 逐页手写的 SVG(视觉真相、改稿对象)
├── svg_final/ # finalize 产出(图标/配图已内嵌,供预览)
├── notes/total.md # 演讲者备注(逐页),total_md_split 拆分后导出嵌入
├── preview/ # svg_preview 渲的验收 PNG
├── exports/<slug>_<ts>.pptx # ★ 最终产物(原生 DrawingML,可编辑)
├── backup/<ts>/svg_output/ # SVG 源快照(可不跑模型重新导出)
└── REVISIONS.md # 修订日志(见 §修订日志)
```
**所有产物写 `<project_dir>` 下**,不写 cwd / `skills/` / repo 根。
## 默认主题 — 自由设计(content-driven)
**默认不锁死配色**:策略阶段根据**内容 + 受众 + 选定的 visual_style** 派生一套协调配色与版式(在 spec 阶段给用户 ≥3 个配色/风格候选挑)。模板是地板也是天花板 —— 默认自由设计让版面跟着内容走,而非被固定语汇框死。
- 商务红 `#C00000` / 中建材等品牌色,只作为**候选之一**;用户点名("做成蓝色 / 用我们公司紫色")或素材里有 brand guideline → 按其锁定。
- 想用模板/品牌库 → 用户给 `templates/` 下的明确路径才触发(见 strategist.md 模板分发);不主动猜、不模糊匹配。
---
## 阶段一:策略(Strategist)—— 八条对齐 + 逐页大纲,产出 spec
**先读** `references/strategist.md`(取其设计判断)+ `templates/design_spec_reference.md` + `templates/spec_lock_reference.md`(产出骨架)。
**0. 先检测已有 spec**:`glob <task_dir>/*/spec_lock.md`。
- 当前 task 已有 project → 展示给用户,问「**沿用进阶段二** / **重定调**(新建 project 目录,旧的保留)」,⛔ BLOCKING 等决定。
- 没有 → 走下面。
**八条对齐(ah)**——按下表**一次性给推荐方案**(默认自由设计),然后 ⛔ **BLOCKING:等用户确认/修改**。不要一条条问。zcbot 走**聊天确认**(不开浏览器 Confirm UI),内容与 strategist.md 的 ah 一致:
| # | 项 | 默认 |
|---|----|------|
| a | 画布 | **16:9**(viewBox `0 0 1280 720`)。其它见 canvas-formats.md |
| b | 页数 | 内容量 × 投递目的推导;**封面 + 正文 + 尾页**,常 815 页 |
| c | 受众 + 核心信息 + 投递目的 | 看材料推断受众;投递目的 `text`(读)/`balanced`(商务,默认)/`presentation`(演讲)定正文字号与密度 |
| d | mode + visual_style | mode 选 5 骨架之一;**visual_style 给 ≥3 个候选**(safe/shifted/bold)让用户挑 —— 这是观感主轴 |
| e | 配色 | 按 visual_style + 内容**派生 ≥3 套候选**(每套含 bg/primary/accent/text…);自由设计默认 |
| f | 图标 | 选 1 个库(tabler-outline 等),stroke 库要定 stroke_width;**锁 inventory 前 `ls templates/icons/<lib>/|grep` 验名** |
| g | 字体 + 字号 | CJK+Latin 字体栈(栈尾必须是预装字体,见 shared-standards §字体);正文字号按投递目的一个定值;公式策略 mixed/render-all/text-only |
| h | 配图 | `none`/`ai`(走 imagegen skill)/`provided`/`placeholder`;ai 要定 image_rendering + image_palette(deck 级锁) |
**逐页大纲**(写进 design_spec.md §IX,也是 spec_lock 的 page_rhythm/page_layouts 依据):**论断式标题 + 每页标节奏**(`anchor`/`dense`/`breathing`)。三条硬纪律(大纲阶段定死):
- **论断标题**:写结论不写主题("渗透率破 60%" 不是 "行业背景");
- **节奏不雷同**:相邻内容页不同版式;narrative 真正停顿处插 `breathing`(单概念/金句/大图,**禁多卡网格**);不要为凑节奏造填充页;
- **内容→版式映射**:历程→时间轴、循环→闭环、2-4 数字→KPI、并列→网格、单震撼数字→breathing 大字、≥3 数据点→图表(charts/ 模板或自绘)。
大纲连同 ah **一起给用户预览,⛔ BLOCKING 等确认整份结构**后再进阶段二(改文字比改 slide 便宜)。
**确认后产出两份引擎契约**(按骨架填,**只填实际用到的行**):
- `<project_dir>/design_spec.md` —— 人读叙事(IXI 节,见 design_spec_reference.md)
- `<project_dir>/spec_lock.md` —— 机读执行锁(canvas/mode/visual_style/colors/typography/icons/images/page_rhythm/page_layouts/page_charts/forbidden,见 spec_lock_reference.md)。**executor 每页重读它**,是长 deck 抗漂移的命门。
> 公式策略 mixed/render-all 且有公式 → 写 `images/formula_manifest.json` 后渲染(ppt-master 的 latex_render 未搬;zcbot 可用现有公式渲染或转图后按 `images` 行登记)。
## 阶段二:配图(条件触发)
**仅当 spec §VIII 有 `ai` 行**:把要 AI 生成的配图汇总,**load `imagegen` skill 走它自己的成本确认流**逐张生成(有强制确认门,不要绕过),产物落 `<project_dir>/images/`。`web`/`provided`/`placeholder`/`none` → 跳过本阶段。
> ppt-master 自带的 image_gen.py / image_search.py 配图子系统**未搬**;zcbot 统一走 imagegen skill。spec 的 §VIII 图片清单格式照用,只是获取机制不同。
## 阶段三:执行(Executor)—— 逐页手写 SVG
**先读**(按本 deck spec_lock 锁定值):
```
references/executor-base.md # 执行通则
references/shared-standards.md # SVG/PPT 硬约束
references/modes/<locked-mode>.md # 锁定的叙事骨架
references/visual-styles/<locked-style>.md # 锁定的视觉风格
```
只读锁定的那一个 mode + 一个 visual-style,别 glob 整个目录。
**纪律(来自 SKILL 全局 + executor-base,务必遵守)**:
1. **逐页串行手写,不批量、不脚本生成**:每页由当前主 agent 在同一上下文里手写 SVG;**禁止写循环脚本批量产 SVG**(跨页视觉一致性靠逐页带上游上下文,生成器做不到),也不要 5 页一组。
2. **每页前重读 `spec_lock.md`**:颜色/字体/图标/图片只能来自它;查本页 `page_rhythm`/`page_layouts`/`page_charts`。抗上下文压缩漂移。
3. **模板供结构不供皮**(非 mirror):继承几何/标签位置/编码逻辑,**重新上 visual_style + spec_lock.colors 的皮**;字号按 spec_lock 角色锁定值,不继承模板占位字号。
4. **图标**:写 `<use data-icon="<lib>/<name>" x= y= width= height= fill= [stroke-width=]>`,name 必须在 inventory 内、文件在 `templates/icons/<lib>/`
5. **配图**:`<image href="../images/<file>">`,croppable 用 `preserveAspectRatio="xMidYMid slice"`,`| no-crop` 行用 `meet`;意图与版式见 image-layout-*。
逐页写到 `<project_dir>/svg_output/<NN>_<page>.svg`。**演讲者备注**写 `<project_dir>/notes/total.md`(每页 24 句结论先行口语)。
## 阶段四:SVG 质检(强制门)
```
.venv/Scripts/python.exe <skill_dir>/scripts/svg_quality_checker.py <project_dir>
```
- **任何 `error`(禁用特性 / viewBox 不符 / spec_lock 漂移等)必须改:回阶段三重写该页再跑**,不放过。
- `warning`(低分辨率图 / 非 PPT 安全字体等):能顺手改就改,否则知会后放行。
- 跑 `svg_output/`(不要在 finalize 后跑 —— finalize 改写 SVG 会掩盖源级违规)。
## 阶段五:后处理 + 导出
⚠️ 三步**一步步来**,别合并成一条命令:
```
# 5.1 拆备注
.venv/Scripts/python.exe <skill_dir>/scripts/total_md_split.py <project_dir>
# 5.2 SVG 后处理(图标/配图内嵌 / 文本展平 / 圆角转 path)
.venv/Scripts/python.exe <skill_dir>/scripts/finalize_svg.py <project_dir>
# 5.3 导出原生 PPTX(默认嵌备注 + Office 兼容 PNG 兜底)
.venv/Scripts/python.exe <skill_dir>/scripts/svg_to_pptx.py <project_dir>
# 产物:exports/<slug>_<ts>.pptx(原生,读 svg_output/)+ backup/<ts>/svg_output/(源快照)
```
- ❌ 别用 `cp` 代替 finalize_svg(它做了多步关键处理);❌ 别加 `--only` / 强制 `-s output`
- 动画可选:`-t fade`(翻页,默认)/ `-a auto`(逐元素入场,**默认 none**,用户要才开)。全表见 animations.md。
- 改稿:只改 `spec_lock.md` 的颜色/字体 → `update_spec.py <project_dir>` 传播到所有 SVG;改版式/内容 → 重写对应页 SVG 再跑 5.25.3,**不要直接 edit 成品 .pptx**。
## 阶段六:验收(渲图肉眼/vision 看)
```
.venv/Scripts/python.exe <skill_dir>/scripts/svg_preview.py <project_dir> --pages 1,3,5 -o <project_dir>/preview
```
`read` 渲出的 PNG 亲眼过:封面、一个内容页、一个 breathing 页 —— 看标题层级、卡片过挤/过空、文字是否都正常、节奏是否单调、配图位置。不通过的回阶段三改对应页 SVG 重跑。
> svg_preview 渲的是 SVG(视觉真相,与导出的 pptx 1:1),比渲最终 pptx 更早更准暴露观感问题。需要校验"SVG→DrawingML 转换是否保真",再开导出的 pptx 在 PowerPoint 里看。
完成后:用 `update_spec` / 重写页迭代;用户确认**实质改动**后追加一行到 `REVISIONS.md`
## 修订日志(REVISIONS.md)
`<task_dir>/REVISIONS.md` 是产物迭代过程的紧凑可读 changelog。**spec 是宪法(定调一次),REVISIONS 是实施日志(每次卡点累加)** —— 两份独立但互参,后期 review / 复盘 / 跨周回看"上周这页为啥改成这样"靠这份。
### 何时记 / 何时不记
`<project_dir>/REVISIONS.md` 是迭代 changelog。**spec 是宪法(定调一次),REVISIONS 是实施日志(每次卡点累加)**。
| 情形 | 记? |
|---|---|
| 用户确认改**版式 / 主色 / 字体方向** | ✅ 必记 |
| 用户确认换 / 增 / 删**页 / 关键图标 / 数据图表** | ✅ 必记 |
| 用户确认改**文案要点 / 核心信息 / 受众定位** | ✅ 必记 |
| 自查阶段发现版式越界 / 颜色不一致后的修正 | ✅ 必记(说明触发 quality_check 项) |
| 页首次起草(从 0 加出来) | ❌ 不记(初稿不是改动) |
| 字号 / 间距 / 对齐微调 | ❌ 不记 |
| 模型自己改改撤撤、用户没明确确认 | ❌ 不记 |
> 拿不准 → 倾向不记。`REVISIONS.md` 是"用户与 LLM 共同沉淀的实质决策",不是流水账(那是对话历史的事)。
### 格式
文件首次创建时写头(只写一次):
```markdown
# 修订日志
> 产物迭代过程中每次用户确认的实质改动,按时间倒序追加(最新在上)。spec 是宪法定调,本文件是实施日志。
```
每次记一笔追加在头注释之后、最新一笔的顶部(一行 = 一次改动):
| 用户确认改**版式/主色/字体/mode/visual_style 方向** | ✅ |
| 用户确认换/增/删**页/关键图标/数据图表** | ✅ |
| 用户确认改**文案要点/核心信息/受众定位** | ✅ |
| 自查发现越界/不一致后的修正 | ✅(注明触发的 quality_check 项) |
| 页首次起草 / 字号间距微调 / 模型自己改撤未经确认 | ❌ |
格式(倒序,最新在上,插在头注释之后):
```
- `<YYYY-MM-DD HH:MM>` | < N / spec §X> | <一句话改了什么><为什么>
```
### 实例
```
- `2026-03-12 16:20` | 第 5 页 | 版式从 layouts.md "两栏文+图"改为"单栏图占主体" — 用户反馈原版式右侧文字太挤,核心数据需放大
- `2026-03-12 14:05` | 第 3 页 | 删 chart 图,换成 3 个 KPI 数字块 — 数据点只有 3 个,bar chart 浪费版面
- `2026-03-11 10:30` | spec §5 配色 | 主色 `#C00000``#1F4E79` — 用户给的品牌指南要求蓝色,商务红默认被覆盖
```
### 操作
每次卡点用户确认后,用 `edit` 在头注释之后插入新一行(不要 append 到文件末尾 —— 倒序读才能秒看最新)。文件不存在就 `write` 创建带头注释的新文件。
## 反模式
- 用户没给材料就开始硬编内容
- 八条没对齐就跑 python-pptx
- **基于"场景判断"自行换配色**(见上"默认主题"违规清单)
- **缺封面 / 缺尾页(Q&A)** —— 两端都是强制项,不算在正文页数预算内
- **裸白纸版式** —— 所有版式起手都必须 `apply_brand(slide, kind)`,见 layouts.md
- **业务概念页只用几何形状 / 裸圆点 bullet** —— "战略目标 / 三大能力"这类页摆光圆点没图标没卡片,视觉太单薄;用 L11 卡片网格 + `add_icon_tile`,图标按 §阶段二第 2 步先拉
- **数字页硬画柱图** —— 只有 2-4 个数字却画 bar chart 浪费版面,用 L10 KPI 卡
- **元素裸贴白纸不进卡片** —— 内容页一坨文字/图标直接铺白底,显扁平;装进 `add_card`(自带投影)分层
- **演讲者备注全空** —— 正式产物每页应有口述要点,`add_notes` 顺手写,别交白板
- **逐页 run_python 建 deck**(每页一轮来回烧轮数;改用一个 `build_deck.py` 整建,方向风险靠阶段一大纲 + 可选探针兜)
- **没经阶段一大纲对齐就直接整建** —— 大纲是替代逐页确认的 checkpoint,跳过它整建才会"改方向全推翻"
- 跑完不做 `quality_check.py` 就交付
- 起名 `output.pptx` / `untitled.pptx` —— 务必按主题给文件名
- 用户没给材料就硬编内容(没材料只给主题 → 先补素材/反问,别凭空发挥)
- 八条没对齐、没产出 spec_lock 就开始写 SVG
- **写脚本批量生成 SVG**(破坏跨页一致性,禁;逐页手写)
- **执行时不每页重读 spec_lock**(长 deck 必漂色/漂字号)
- **同 deck 混用多个图标库** / 用 inventory 外的图标名
- 用了 `<style>`/`class`/`<mask>`/`<symbol>+<use>`/`@font-face`/`rgba()`/HTML 命名实体 等 **shared-standards 禁用特性**(导出会丢元素或报错)
- 字体栈尾不是预装字体(PPTX 无运行时回退,会变默认字体)
- **breathing 页堆多卡网格**(违节奏,显 AI 味)
- 模板照搬不重上皮(直接用模板默认渐变/阴影/字号)
- 质检没过就交付 / 直接 edit 成品 .pptx 改稿
- 起名 `output.pptx` —— 按主题命名
## 输出
完成后告诉用户:文件路径、页数、用到的版式列表、是否有未满足的 spec 项。问一句要不要再改。
完成后告诉用户:文件路径、页数、用到的 mode + visual_style + 版式列表、是否有未满足的 spec 项。问一句要不要再改。
---
> 本 skill 的 SVG→PPTX 引擎、references 设计知识、templates 模板/图标库移植自开源项目 **ppt-master**(github.com/hugohe3/ppt-master,MIT License),适配 zcbot 的 task_dir / 聊天确认 / imagegen 工作流;浏览器 Confirm UI、live preview server、TTS 配音等桌面交互件未移植。

View File

@ -1,66 +0,0 @@
# 本地图标库
> 这里是 skill 自带的**只读种子图标库**,**已入库一组商务红 tabler 种子集**(target / brain / chart-bar / users / trophy / alert-triangle / cpu / building-factory / cloud-network / database 等),覆盖大部分商务汇报场景 —— 直接 `glob` 读用即可。docker 沙盒里 `skills/` 是只读挂载,**不能往这儿写**。新场景按需 `fetch_icon.py` 拉,落点是 `<task_dir>/assets/icons/`(可写),本 task 内再用直接读不发请求。
## 缓存命名规约
```
<set>_<name>_<colorhex>_<sizepx>.png
<set>_<name>_<colorhex>.svg
```
例: `tabler_rocket_C00000_128.png` / `lucide_target_FFC107_96.svg`
## 推荐图标清单 (按业务主题)
种子集已含下列大部分;若某个本 task 缺,按下面命令拉到 `<task_dir>/assets/icons/`(种子库只读,新图标进 task 目录):
```bash
ICONS_DIR=<task_dir>/assets/icons # 可写落点;<skill_dir>/scripts 来自 load_skill 头(只读可执行)
# 战略 / 目标 / 启动
for n in target rocket flag bulb; do
python <skill_dir>/scripts/fetch_icon.py $n --set tabler --color C00000 --size 128 \
-o "$ICONS_DIR/tabler_${n}_C00000_128.png"
done
# 数据 / 趋势 / 报表
for n in chart-bar chart-line trending-up calculator; do
python <skill_dir>/scripts/fetch_icon.py $n --set tabler --color C00000 --size 128 \
-o "$ICONS_DIR/tabler_${n}_C00000_128.png"
done
# 团队 / 流程 / 时间
for n in users settings calendar clock check shield-check arrow-right alert-triangle currency-yuan circle-check; do
python <skill_dir>/scripts/fetch_icon.py $n --set tabler --color C00000 --size 128 \
-o "$ICONS_DIR/tabler_${n}_C00000_128.png"
done
```
## 图标集对照
| 集名 | 风格 | 数量 | License |
|-----|-----|-----|---------|
| **tabler** ⭐ 推荐 | 描边、商务、克制 | 4500+ | MIT |
| lucide | 描边、克制 | 1500+ | ISC |
| heroicons | Tailwind 风、双重粗细 | 300+ | MIT |
| material-symbols | Google Material 描边/填充 | 3000+ | Apache 2.0 |
| carbon | IBM、克制专业 | 2000+ | Apache 2.0 |
| fluent | Microsoft、温和现代 | 4000+ | MIT |
| mdi | Material Design Icons 社区 | 7000+ | Apache 2.0 |
## 浏览找名字
打开 https://icon-sets.iconify.design/ 搜中英文关键词,复制图标名 (如 `tabler:rocket`),回来用 `--set tabler rocket` 拉。
## 主题色变体
同一图标按主色/辅色/强调色/灰各拉一份,文件名只在 `<colorhex>` 段不同:
- `tabler_target_C00000_128.png` (主红)
- `tabler_target_E15554_128.png` (辅红)
- `tabler_target_FFC107_128.png` (强调金)
- `tabler_target_595959_128.png` (灰)
## 用图标的硬规则
`references/icons.md §C` —— 风格统一、颜色限定、大小克制、不替表意、避 emoji。

View File

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128" viewBox="0 0 24 24"><path fill="none" stroke="#C00000" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v4m-1.637-9.409L2.257 17.125a1.914 1.914 0 0 0 1.636 2.871h16.214a1.914 1.914 0 0 0 1.636-2.87L13.637 3.59a1.914 1.914 0 0 0-3.274 0M12 16h.01"/></svg>

Before

Width:  |  Height:  |  Size: 343 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

View File

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128" viewBox="0 0 24 24"><g fill="none" stroke="#C00000" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M5 6a1 1 0 0 1 1-1h12a1 1 0 0 1 1 1v12a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1z"/><path d="M9 9h6v6H9zm-6 1h2m-2 4h2m5-11v2m4-2v2m7 5h-2m2 4h-2m-5 7v-2m-4 2v-2"/></g></svg>

Before

Width:  |  Height:  |  Size: 352 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

View File

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128" viewBox="0 0 24 24"><g fill="none" stroke="#C00000" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M4 6a8 3 0 1 0 16 0A8 3 0 1 0 4 6"/><path d="M4 6v6a8 3 0 0 0 16 0V6"/><path d="M4 12v6a8 3 0 0 0 16 0v-6"/></g></svg>

Before

Width:  |  Height:  |  Size: 308 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

View File

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128" viewBox="0 0 24 24"><g fill="none" stroke="#C00000" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M11 12a1 1 0 1 0 2 0a1 1 0 1 0-2 0"/><path d="M7 12a5 5 0 1 0 10 0a5 5 0 1 0-10 0"/><path d="M3 12a9 9 0 1 0 18 0a9 9 0 1 0-18 0"/></g></svg>

Before

Width:  |  Height:  |  Size: 331 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 KiB

View File

@ -0,0 +1,163 @@
# Page Transitions & Per-Element Animations
PPT Master's exported PPTX supports **page transitions** (slide-to-slide) and **per-element entrance animations** (within a slide). Both are controlled by `svg_to_pptx.py` CLI flags and ship as real OOXML — they animate inside PowerPoint and Keynote, no embedded video.
## Defaults
| Layer | Default | Why |
|---|---|---|
| Page transition | `fade`, 0.4s | Calm baseline that suits most decks |
| Per-element animation | **`none` (off)** | A page appears as a whole. Auto-firing element builds are an unsolicited "AI deck" tell, so element entrance is opt-in. Turn it on with `-a auto` (or another effect): effects map from group id (chart→wipe, card-/step-/pillar-→fly, title/takeaway→fade); image-like ids (`hero` / `figure-` / `image` / `img-` / `kpi`) cycle a richer visual pool (zoom / dissolve / circle / box / diamond / wheel) so multiple images vary across the deck; unmatched ids cycle a small fade/wipe/fly/zoom pool |
To regenerate a deck with different settings, rerun `svg_to_pptx.py` against the same `svg_output/` (or `svg_final/`) — no need to rerun the LLM. To turn per-element animation on for the whole deck, pass `-a auto`.
## Custom Object-Level Animation
Per-element animation is off by default. To enable it deck-wide, pass `-a auto` at export (no config needed). When a deck instead needs specific object timing — for example title first, chart second, annotation last — use the optional `animations.json` sidecar. The SVG remains static visual source; the sidecar only controls PPTX export behavior.
Run the standalone [`customize-animations`](../workflows/customize-animations.md) workflow when the user asks to tune animation order, effects, timing, or object-level reveals.
```bash
# Build an editable scaffold from real top-level <g id> anchors
python3 skills/ppt-master/scripts/animation_config.py scaffold <project>
# Validate references before export
python3 skills/ppt-master/scripts/animation_config.py validate <project>
# Export reads <project>/animations.json automatically when present
python3 skills/ppt-master/scripts/svg_to_pptx.py <project>
```
Minimal sidecar:
```json
{
"version": 1,
"slides": {
"03_market": {
"groups": {
"title": { "effect": "fade", "order": 1 },
"chart": { "effect": "wipe", "order": 2, "duration": 0.6 },
"insight": { "effect": "fly", "order": 3, "delay": 0.2 },
"footer": { "effect": "none" }
}
}
}
}
```
Rules:
- `slides` keys match SVG stems (`03_market.svg` → `03_market`).
- `groups` keys match top-level `<g id="...">` anchors.
- `effect: none` removes that group from the entrance sequence.
- `order` changes animation order only; it does not change slide layering.
- `delay` is seconds before that group starts in `after-previous` mode.
- `duration` overrides the per-group entrance duration.
- `--animation none` overrides the sidecar and disables all per-element animation.
## Page Transitions
```bash
# Pick a different effect
python3 skills/ppt-master/scripts/svg_to_pptx.py <project> -t push --transition-duration 0.6
# Disable
python3 skills/ppt-master/scripts/svg_to_pptx.py <project> -t none
# Auto-advance every 5 seconds (kiosk-style playback)
python3 skills/ppt-master/scripts/svg_to_pptx.py <project> --auto-advance 5
```
Available effects: `fade`, `push`, `wipe`, `split`, `strips`, `cover`, `random`.
Flags:
- `-t/--transition` — effect name, or `none` to disable. Default: `fade`.
- `--transition-duration` — seconds, default `0.4`.
- `--auto-advance` — seconds; omit for presenter-controlled advance.
## Per-Element Animations
Off by default — enable deck-wide with `-a auto` (or another effect). Once enabled, three Start modes are available — these mirror PowerPoint's animation-pane "Start" dropdown:
- **`on-click`** — entering a slide → first click reveals the first semantic group; each subsequent click reveals the next group in z-order. Suits live presentations where the speaker paces reveals. Forbidden with `--recorded-narration` because video-ready exports need click-free playback.
- **`with-previous`** — all groups start together on slide entry, playing their entrance animation in parallel. Stagger ignored.
- **`after-previous`** (default) — first group fires on slide entry, subsequent groups cascade after the previous one finishes, with `--animation-stagger` extra spacing. Suits kiosk playback, recorded walkthroughs, or anyone who wants visual flow without clicking.
```bash
# Default behavior (no flags): page transitions only, no per-element builds
python3 skills/ppt-master/scripts/svg_to_pptx.py <project>
# Enable per-element animation deck-wide (auto effect + after-previous cascade)
python3 skills/ppt-master/scripts/svg_to_pptx.py <project> -a auto
# Enable with a single effect (cascades via the after-previous trigger)
python3 skills/ppt-master/scripts/svg_to_pptx.py <project> --animation fade
# Enable and switch to on-click for live presentations (presenter controls pacing)
python3 skills/ppt-master/scripts/svg_to_pptx.py <project> -a auto --animation-trigger on-click
# Custom pacing
python3 skills/ppt-master/scripts/svg_to_pptx.py <project> --animation mixed \
--animation-stagger 0.7 --animation-duration 0.5
# All groups animate in unison on slide entry
python3 skills/ppt-master/scripts/svg_to_pptx.py <project> --animation-trigger with-previous
```
22 single effects: `appear`, `fade`, `fly`, `cut`, `zoom`, `wipe`, `split`, `blinds`, `checkerboard`, `dissolve`, `random_bars`, `peek`, `wheel`, `box`, `circle`, `diamond`, `plus`, `strips`, `wedge`, `stretch`, `expand`, `swivel`. Plus three auto-vary modes:
- `auto` (recommended when enabling) — map effect from the group's SVG id. Information-dense elements get a single stable effect: `chart` / `table` / `legend` / `timeline` / `track``wipe`; `card-*` / `pillar-*` / `item-*` / `step-*` / `stage-*` / `tier-*` / `principle-*``fly`; `title` / `chapter-*` / `section-*` / `cover-*` / `tagline` / `subtitle``fade`; `takeaway` / `callout` / `quote` / `source` / `conclusion` / `note``fade`. Image-like ids `hero` / `figure-*` / `image` / `img-*` / `kpi` instead cycle a richer visual pool (`zoom` / `dissolve` / `circle` / `box` / `diamond` / `wheel`) so multiple images vary across the deck. Unmatched ids cycle through `fade` / `wipe` / `fly` / `zoom`.
- `mixed` (legacy) — deterministic. The first animated group on each slide uses `fade`; later groups cycle through a 16-effect pool (`blinds` / `checkerboard` / `dissolve` / `fly` / `cut` / `random_bars` / `box` / `split` / `strips` / `wedge` / `wheel` / `wipe` / `expand` / `fade` / `swivel` / `zoom`) across the deck. Kept for backward compatibility.
- `random` — samples from the legacy 16-effect pool.
`appear` is excluded from every variation pool because it has no visible motion.
Flags:
- `-a/--animation` — effect name, `auto`, `mixed`, `random`, or `none`. Default: `none` (per-element animation off; pass `auto` to enable).
- `--animation-trigger` — Start mode (matches PowerPoint): `on-click`, `with-previous`, or `after-previous` (default).
- `--animation-duration` — per-element entrance seconds, default `0.4`.
- `--animation-stagger` — gap between elements in `after-previous` mode (seconds, default `0.5`). Ignored otherwise.
- `--animation-config` — sidecar path. Default: `<project>/animations.json` when present.
> Note: `--recorded-narration` rejects `on-click`; use `after-previous` or `with-previous` for video-ready narrated decks.
## Anchor Logic — Top-Level `<g id="...">`
Per-element animations are anchored on **top-level `<g id="...">` content groups** in the SVG (e.g. `<g id="cover-title">`, `<g id="card-1">`). One group = one click reveal.
Aim for **38 content groups per slide**. This is also the granularity PowerPoint uses for group-select / group-move, so it improves editing ergonomics regardless of animation.
**Chrome groups skip the cascade automatically.** Top-level groups that look like page chrome (background, header/footer, decorations, watermark, page number, nav, logo, dividing rule) are excluded from the click sequence and appear together with the slide. Detection is done on the `id`: after splitting on `-` and `_`, if any token matches `background` / `bg` / `decoration` / `decorations` / `decor` / `header` / `footer` / `chrome` / `watermark` / `pagenumber` / `pagenum` / `nav` / `logo` / `rule`, the group is treated as chrome. Examples that auto-skip: `<g id="background">`, `<g id="bg-texture">`, `<g id="cover-footer">`, `<g id="p03-header">`, `<g id="bottom-decor">`, `<g id="watermark">`, `<g id="nav">`, `<g id="logo-area">`, `<g id="column-rule">`. Examples that still animate: `<g id="card-1">`, `<g id="cover-title">`, `<g id="step-discover">`, `<g id="timeline-track">`. Don't strip the `<g>` wrapper to avoid animation — keep it (PowerPoint group-select needs it) and just name it appropriately.
**Fallback for flat SVGs** (no top-level `<g>` wrappers, only raw `<rect>` / `<text>` / `<path>` at the root):
- ≤ 8 visible top-level primitives → each becomes one anchor (capped to avoid 70+ atom cascades on dense pages).
- > 8 → animation is skipped on that slide. The slide still renders, just without entrance animation.
Executors should wrap logical sections in `<g id>` regardless of whether you plan to animate. The Executor reference (`skills/ppt-master/references/shared-standards.md`) requires it.
## Limitations
- **Native shapes mode only.** Per-element animation needs editable shape anchors. `--only legacy` produces one image per slide and has no element granularity to animate; that mode is unaffected by `-a/--animation` and only honors `-t/--transition`.
- **Office version drift on element animations.** Effects use the `<p:animEffect filter=...>` path (vs. `presetID` lookup tables) to stay stable across Office versions. Most filters render identically in PowerPoint 2016+; older Office may downgrade some filters to plain Appear.
- **PNG fallback (compat mode) is for visual rendering only.** Transitions and animations live in the slide XML, not in the PNG, so disabling compat mode does not affect either layer.
## Quick Reference
| Goal | Command |
|---|---|
| Disable transitions | `-t none` |
| Change transition effect | `-t push` (or any from the list above) |
| Slower transition | `--transition-duration 0.8` |
| Auto-play | `--auto-advance 5` |
| Disable element animation | `-a none` |
| Switch to on-click trigger | `--animation-trigger on-click` |
| Use a single effect instead of auto | `--animation fade` |
| All groups animate together | `--animation-trigger with-previous` |
| Slower per-element reveal | `--animation-duration 0.5` |
| Wider gap in after-previous | `--animation-stagger 0.7` |
See also: [`scripts/docs/svg-pipeline.md`](../scripts/docs/svg-pipeline.md) for the full `svg_to_pptx.py` reference.

View File

@ -0,0 +1,75 @@
# Canvas Format Specification
> See shared-standards.md for SVG basic rules.
## Format Quick Reference
| Format | viewBox | Ratio | Use Case |
|--------|---------|-------|----------|
| PPT 16:9 | `0 0 1280 720` | 16:9 | Business presentations, meetings |
| PPT 4:3 | `0 0 1024 768` | 4:3 | Traditional projectors, academic talks |
| Xiaohongshu (RED) | `0 0 1242 1660` | 3:4 | Image-text sharing, knowledge posts |
| WeChat Moments / IG | `0 0 1080 1080` | 1:1 | Square posters, brand showcases |
| Story / TikTok | `0 0 1080 1920` | 9:16 | Vertical stories, short video covers |
| WeChat Article Header | `0 0 900 383` | 2.35:1 | WeChat article cover images |
| Landscape Banner | `0 0 1920 1080` | 16:9 | Web banners, digital screens |
| Portrait Poster | `0 0 1080 1920` | 9:16 | Phone screens, elevator ads |
| A4 Print | `0 0 1240 1754` | 1:sqrt(2) | Print posters, flyers |
## Format Selection Decision Tree
```
Content purpose?
├── Presentation
│ ├── Modern devices → PPT 16:9 (1280x720)
│ └── Traditional devices → PPT 4:3 (1024x768)
├── Social sharing
│ ├── Xiaohongshu (RED) → 1242x1660
│ ├── WeChat Moments / IG → 1080x1080
│ └── Story / TikTok → 1080x1920
└── Marketing materials
├── WeChat Article Header → 900x383
├── Banner → 1920x1080
└── Print → 1240x1754
```
## Layout Principles
### Landscape (16:9, 4:3, 2.35:1)
- Visual flow: Z-pattern, left to right
- Margins: 40-80px
- Layouts: multi-column, left-right split, grid
- Card dimensions (16:9): single-row 530-600px, double-row 265-295px
### Portrait (3:4, 9:16)
- Visual flow: top to bottom
- Margins: 60-120px
- Layouts: single-column, top-bottom split, card stacking
- Card dimensions (3:4): height 400-600px, gap 40-60px
### Square (1:1)
- Visual flow: center-radiating
- Margins: 60-100px
- Core area: ~800x800px
## Format-specific Design
| Format | Title Area | Content Area | Special Notes |
|--------|-----------|--------------|---------------|
| PPT | 80-100px | Full width utilization | Page number bottom-right |
| Xiaohongshu (RED) | 180-240px (bold) | Generous top/bottom whitespace | Brand area at bottom 120-160px |
| WeChat Moments | 200-280px | Center 500-600px | QR code area at bottom 150-200px |
| Story | — | Middle 1500px | Top safe zone 120px, bottom 180px |
| WeChat Article Header | Center/left-aligned 48-72px | — | Image on right or as background |
> **Body font baseline scales with canvas and delivery purpose** — a PPT 16:9 baseline confirmed for read-close / business / projection cannot be carried onto tall canvases (Xiaohongshu / Story / A4). Pick the baseline from the confirmed canvas, not the recommended one; see the per-canvas px anchors in [`strategist.md`](strategist.md) §g "Font Size Ramp" (the system is px-only — all sizes are unitless px on every canvas).
## ViewBox Examples
```xml
<svg width="1280" height="720" viewBox="0 0 1280 720"> <!-- PPT 16:9 -->
<svg width="1242" height="1660" viewBox="0 0 1242 1660"> <!-- Xiaohongshu -->
<svg width="1080" height="1080" viewBox="0 0 1080 1080"> <!-- WeChat Moments -->
<svg width="1080" height="1920" viewBox="0 0 1080 1920"> <!-- Story -->
<svg width="900" height="383" viewBox="0 0 900 383"> <!-- WeChat Article Header -->
```

View File

@ -1,224 +0,0 @@
# PPT 设计硬规则
> 出稿前过一遍。**这些不是建议,是工程约束** —— 模型生成 PPT 最常见的失败模式都是违反这些规则。
## 信息设计纪律 (比视觉更重要 —— 先把这条吃透)
> "好看"七成靠**信息设计**、三成靠视觉。同样的红色卡片,标题写"行业背景"还是"渗透率破 60%,行业进入深水区",观感差一个档次。模型最容易堆视觉、忘内功 —— 这一节是把 deck 从"AI 味模板"拉到"咨询级"的关键。
### 1. 论断式标题 (Assertion title) —— 标题写结论,不写主题
每页标题是**一句可带走的结论**,不是话题名。
| 类型 | ❌ 主题式(避免) | ✅ 论断式(推荐) |
|---|---|---|
| 背景 | "行业背景" | "数字渗透率破 60%,行业进入深水区" |
| 现状 | "什么是大模型" | "大模型靠规模涌现出通用智能" |
| 历程 | "发展历程" | "六年从 GPT-1 到推理模型,能力指数跃迁" |
| 竞争 | "竞品分析" | "三家主要对手在渠道覆盖上明显薄弱" |
### 2. Takeaway 结论框 —— 每页标题下一句话结论
内容页标题下加 `P.add_takeaway(slide, "<一句话结论>")`(浅主色底 + 左主色条)。把"这页要讲什么"压成一句。**金字塔原则**:结论先行,再展开 3 条论据。
### 3. 数据语境化 —— 数字不要孤立出现
每个关键数字配三件:**数值本身(大)+ 对比基准(行业均值/上期/竞品)+ 含义("所以呢")**。
`P.add_kpi(..., baseline="行业均值 82%", delta="+11pt")`(升=绿/降=红,业界约定);含数据的页用 `P.add_source(slide, "<来源>")` 标来源。
> 例:"97.3%" 下面跟 "行业均值 82% | 领先 15 个点",而不是光一个 "97.3%"。
### 4. page_rhythm 节奏 —— 相邻页不许同版式
逐页大纲给每页标密度,**breathing 页强制打破卡片网格**(否则每页都退化成卡片网格 = AI 味):
| 标签 | 版式纪律 |
|---|---|
| `anchor` | 结构页(封面/章节/目录/尾页),走固定品牌版式 |
| `dense` | 信息密集(默认):卡片网格 / KPI / 图表 / 时间轴 / 表格都行 |
| `breathing` | 低密度冲击页:**禁止多卡网格**,用大字 + 留白 + 整图 + 金句。典型:单个大数字 + 一句语境、整图 + 浮层标题、金句 |
内容→版式映射:历程→时间轴(`add_timeline`)、循环→闭环/流程(`add_cycle`)、2-4 数字→KPI 卡(`add_kpi`)、并列概念→均衡网格(`add_card_grid`,全 deck ≤2 次)、单个震撼数字→breathing 大字页。
## 0. 画布 (默认 16:9)
| 用途 | 比例 | 宽×高 (英寸) | python-pptx |
|-----|------|------------|------------|
| **现代商务汇报** ⭐ 默认 | 16:9 | 13.33 × 7.5 | `Inches(13.33), Inches(7.5)` |
| 老投影 / 教学 | 4:3 | 10 × 7.5 | `Inches(10), Inches(7.5)` |
| 手机 / 视频号 | 9:16 | 7.5 × 13.33 | `Inches(7.5), Inches(13.33)` |
| 小红书 | 3:4 | 7.5 × 10 | `Inches(7.5), Inches(10)` |
| A4 横 / 竖 | √2:1 | 11.69 × 8.27 / 反 | 同左 |
不知道选哪个 → **16:9**。安全边距统一:左右 0.7 in,上下 0.5 in。**画布定了不要中途改**,后续坐标全按这个尺寸算。画布超 16:9 默认尺寸时所有字号 × `(实际宽 / 13.33)`
## 1. 字号 (16:9 标准)
| 元素 | 字号 (Pt) | 备注 |
|-----|----------|------|
| 主标题 (封面) | 44-54 | 单行不换行 |
| 标题 (内页) | 28-36 | 中文常用 32 |
| 副标题 / 章节小标题 | 20-24 | |
| 正文 / bullet | 18-22 | 低于 18 投影看不清 |
| 注释 / 数据来源 | 12-14 | 灰色,弱化 |
| 页脚页码 | 10-12 | 弱化处理 |
**底线**: 投影到 100 寸大屏,后排看得清最小字号是 18pt。**绝不能小于 14pt**,除非是数据来源等弱化信息。
## 2. 配色
### 三色制
- **主色 (Primary)** —— 标题、强调、关键数据。占视觉权重 60%
- **辅色 (Secondary)** —— 副标题、次要图形元素。占 30%
- **强调色 (Accent)** —— 关键数据点、CTA、警告。占 10%,不要泛滥
- 其他全部用灰阶 (#1F1F1F / #555 / #888 / #CCC / #F5F5F5)
### 推荐配色对照 (红色主题为默认)
| 风格 | 主色 | 辅色 | 强调色 | 备注 |
|-----|------|------|-------|------|
| **商务红** ⭐ 默认 | #C00000 | #E15554 | #FFC107 | 党政/年终/路演通用 |
| 中国红 | #8B0000 | #B22222 | #FFD700 | 民族/国货/红色文化主题 |
| 现代红 | #B91C1C | #DC2626 | #F59E0B | 新消费/科技产品发布 |
| 暖朱红 | #C73E1D | #E76F51 | #F4A261 | 学术汇报/行业会议 |
| 商务蓝 | #1F4E79 | #2E75B6 | #FFC000 | 金融/保险/政企 |
| 学术灰 | #2F2F2F | #595959 | #C00000 | 严肃论文/答辩 |
| 现代简约 | #2D3748 | #4A5568 | #38B2AC | 互联网/SaaS |
| 科技深色 | #0A192F | #112240 | #64FFDA | 黑客松/技术大会 |
### 派生色阶(卡片式视觉的层次来源)
`set_palette` 从主/辅/强调自动派生明暗阶,**这些不算"新色"**(quality_check 按色相归桶,同色系深浅收敛成一个):
- `PRIMARY_WASH`(主色兑 92% 白)—— 整页/大区域浅底(尾页、L13 论据卡)
- `PRIMARY_SOFT`(兑 80% 白)—— 卡片/图标底块/标签浅底
- `PRIMARY_DARK`(主色压暗)—— 封面/章节渐变深端
- `ACCENT_SOFT`(强调兑 78% 白)—— 渐变深底上的弱化文字
> 白底之上靠卡片(`add_card` 圆角+投影)+ 浅色阶分层,才有"现代咨询风"的层次;纯白底裸贴元素 = 扁平办公模板。
### 语义状态色 (例外)
趋势/状态用业界约定:**绿 `P.GOOD` = 增长/正向,红 `P.BAD` = 下降/风险,灰 = 持平**。这套语义色**不计入三色制**(quality_check 把绿色当语义色豁免)。只用在 KPI 趋势、表格升降这类语义场景,别拿来当装饰。
### 禁忌
- 红配绿、紫配黄等高对比互补色不要直接用(语义升降色除外)
- **渐变只用在大色块**(封面右块 / 章节整页,`apply_brand` 已内置);正文/标题/小图形不要渐变
- 一份 deck 主色不要换。封面是 A 色、内页变 B 色 —— 这是大忌
- 渐变深底上文字一律用**白 / `ACCENT_SOFT`**,别用深灰 `INK`(看不清)
## 视觉深度:投影是克制,不是默认
> 抄自 pptmaster shared-standards §6 —— "设计感来自'没有',不是'到处都有'"。模型最爱给每张卡都加投影,这恰恰是模板味的来源。
- **平卡是常态**:`add_card` 默认平卡(白底描发丝边)。**平铺网格里的对等卡一律平**,不投影。
- **投影只给真悬浮的**:照片/色块上的卡、被挑出的"推荐"项、浮层/标注。`add_card(..., shadow=True)` 手动开。
- **每页 ≤2-3 个投影元素**。够第 4 个了,先撤一个。
- **一个容器只用一种视觉手段**:投影 / 描边 / 渐变底 / 强主色底 —— **四选一,不叠加**(叠加 = 瞬间模板味)。
- **单一光源**:同页所有投影同方向(默认光从上方,`dy>0`)。
- 渐变深底上投影会消失,改用 1px 低透明白描边或外发光。
## 3. 留白
- 标题与上边距 ≥ 0.4 英寸
- bullet 之间行距 1.3-1.5 倍
- 一页内容占满 70% 即可,**不要塞到边缘**
- 边距统一 (左右 0.7 寸,上下 0.5 寸常用值)
## 4. 信息密度
| 页类型 | 字数上限 | 图表 |
|-------|---------|-----|
| 封面 | 30 字 | 可选装饰图 |
| 目录 | 每条 ≤ 15 字 | 不要图 |
| 分章页 | ≤ 20 字 | 大号数字 + 章节名 |
| 要点页 | bullet ≤ 5 条,每条 ≤ 25 字 | 可选小图标 |
| 数据页 | 标题 + 一句结论 | **必须有图表**;2-4 个数字优先 KPI 卡(L10)而非柱图 |
| 概念页 | 卡片标题 ≤6 字 + 说明 ≤2 行 | 图标底块 + 卡片网格(L11),别裸圆点 |
| 图片页 | ≤ 15 字标题 + 1-2 行说明 | 主体是图 |
## 4.1 字数预算 (避免溢出)
> 这是**布局超界的根因表**。bullet 写超了会顶到下一页元素;标题写超了会换行顶下来。开写前查这张表,而不是写完看 quality_check 报错。
公式: `每行字数 ≈ 框宽(in) × 72 / 字号(pt)`
| 字号 | 框宽 11.93 in (整宽) | 框宽 5.5 in (双栏单边) | 框宽 4.6 in (图片页文字区) |
|-----|--------------------|----------------------|--------------------------|
| 44 pt (主标题) | ≤ 19 字 | — | — |
| 36 pt (大标题) | ≤ 23 字 | — | — |
| 32 pt (内页标题) | ≤ 26 字 | — | — |
| 22 pt (要点) | ≤ 39 字 | ≤ 18 字 | ≤ 15 字 |
| 18 pt (正文) | ≤ 47 字 | ≤ 22 字 | ≤ 18 字 |
| 14 pt (注释) | ≤ 61 字 | ≤ 28 字 | ≤ 23 字 |
**英文字符按中文 0.5 个换算** (即英文每行约 2× 中文字数)。
### 行高估算
每行高度 ≈ `字号 × 1.4 / 72` (英寸)
| 字号 | 单行高 | 1 行框高 | 2 行框高 | 3 行框高 |
|-----|-------|---------|---------|---------|
| 32 pt | 0.62 in | 0.7 in | 1.3 in | 1.9 in |
| 22 pt | 0.43 in | 0.5 in | 0.9 in | 1.3 in |
| 18 pt | 0.35 in | 0.4 in | 0.8 in | 1.1 in |
| 14 pt | 0.27 in | 0.3 in | 0.6 in | 0.9 in |
**用法**: bullet 字数预计超表上限就拆条,不要试图靠 `auto_size` 收缩字号兜底 —— 会出现一页里字号大小不一,反而难看。
## 5. 文字层级
- 一页最多 3 级层级 (标题 / 正文 / 子项)
- 子项缩进 0.3-0.5 英寸
- 子项字号比父级小 2-4pt
- 不要四级以上嵌套
## 6. 图片规则
- **分辨率**: 投影建议 150 dpi 以上,印刷 300 dpi
- **占位**: 图片占满指定区域,不要拉伸变形 —— 用 `width=``height=` 单一参数让 python-pptx 等比缩放
- **背景**: 透明 PNG 优先;白底 JPG 在深色页上要做底色匹配
- **数量**: 一页最多 2 张图,3 张以上是网格图,按九宫格摆
## 7. 图表规则 (matplotlib)
> **先问要不要图表**:只有 2-4 个数字 → 用 KPI 卡(layouts L10),别画柱图;真有趋势/分布/多系列才上 matplotlib。图表 png 嵌进 `add_card` 白卡片里(L6)比裸图精致。
- 颜色用 spec 里定的主/辅/强调三色,**不要用 matplotlib 默认色板**
- 字号: 标题 16,坐标轴 12,刻度 10
- **去四边框**,只留极淡横向网格 (`ax.spines[*].set_visible(False)` + `ax.grid(axis='y', color='#EEEEEE', lw=0.8)`)—— 比全框 + 默认网格干净,跟卡片观感一致
- 数据标签直接标在柱子/点上,优先于看坐标
- 透明底:`fig.savefig(..., transparent=True)`,嵌白卡片上无白边
- 中文字体: `plt.rcParams['font.sans-serif'] = ['Microsoft YaHei', 'SimHei']`
- 负号: `plt.rcParams['axes.unicode_minus'] = False`
```python
# 示例:符合规则的柱状图 (默认红色主题)
import matplotlib.pyplot as plt
plt.rcParams['font.sans-serif'] = ['Microsoft YaHei', 'SimHei']
plt.rcParams['axes.unicode_minus'] = False
fig, ax = plt.subplots(figsize=(10, 5), dpi=150)
bars = ax.bar(["Q1","Q2","Q3","Q4"], [12,18,25,31],
color=["#C00000","#C00000","#C00000","#FFC107"]) # 末尾突出
for bar, v in zip(bars, [12,18,25,31]):
ax.text(bar.get_x()+bar.get_width()/2, v+0.5, str(v),
ha='center', fontsize=11)
ax.set_title("季度营收 (亿元)", fontsize=16)
ax.spines['top'].set_visible(False)
ax.spines['right'].set_visible(False)
fig.savefig("chart.png", bbox_inches="tight", dpi=150)
```
## 8. 一致性 (跨页)
- 标题位置不要跳来跳去 —— 所有内页标题都在同一像素位置
- 页脚 (页码 / logo / 标题) 在所有内页位置一致
- 字体在同 deck 内不要换 —— 中文一种字体,英文一种,够了
- 配色不变,字号梯度不变
## 9. 反模式速查
| 症状 | 原因 | 修法 |
|-----|------|-----|
| 一页字密密麻麻 | 没拆页 | 拆 2-3 页或转图表 |
| 投影看不清 | 字号 < 18 | 加大字号或拆页 |
| 颜色花 | 用了超过 5 种色 | 退回三色制 |
| bullet 是完整段落 | 把演讲稿当 bullet 写 | 提炼关键词,完整句留给口述 |
| 图表默认配色 | 没改 matplotlib 色板 | 用 spec 主色 |
| 图标/图片随意找的 | 没统一风格 | 同一来源 / 同一风格 |
| 标题在每页位置都不一样 | 没用统一版式 | 见 layouts.md,固定模板 |

View File

@ -0,0 +1,445 @@
# Executor Common Guidelines
> Narrative skeleton and visual aesthetic come from this deck's locked files under [`modes/`](./modes/_index.md) and [`visual-styles/`](./visual-styles/_index.md). Technical constraints are in shared-standards.md.
---
## 1. Template Adherence Rules
### 1.0 Pre-generation Batch Read
**Hard rule**: Before the first SVG page, batch-read every template SVG this deck will reference. Read once up front, never re-read during generation.
| Source list | Read path |
|---|---|
| Chosen template's `design_spec.md` (read frontmatter to detect `replication_mode`) | `templates/design_spec.md` |
| Every distinct `<basename>` in `spec_lock.md page_layouts` | `templates/<basename>.svg` |
| Every distinct chart name in `spec_lock.md page_charts` | `templates/charts/<chart_name>.svg` |
| Chart types in `design_spec.md §VII` not covered above | `templates/charts/<chart_name>.svg` |
**Default — read each template once; re-read only on the mid-deck exception below**:
- Layout SVG already loaded in this batch
- Chart SVG already loaded in this batch
`spec_lock.md` is the only file re-read per page (§2.1).
**Exception**: user mid-deck adds pages or swaps templates introducing a basename/chart absent from the original batch → read the new file once, continue.
> Note: batched prefix reads stay in the cached prompt prefix; per-page `spec_lock.md` re-reads append below and benefit from that cache. Scattered on-demand reads of layout/chart SVGs would invalidate downstream cache and sit in the compression-vulnerable mid-context region.
Resolve the per-page template SVG via `spec_lock.md page_layouts` (authoritative). The legacy page-type table below is a **last-resort fallback** for legacy decks where `page_layouts` is missing.
**Resolution order (per page):**
1. **Mirror-mode template** (template's `design_spec.md` frontmatter has `replication_mode: mirror`) → see §1.1 below. The page is consumed as a **visual reference**, not as a placeholder shell.
2. `spec_lock.md page_layouts` has `P<NN>: <basename>` for this page → inherit the structure of `templates/<basename>.svg` (already in context from §1.0).
3. `page_layouts` exists but **no entry** for this page → **free design**, no template inheritance.
4. `page_layouts` section absent (legacy deck) **and** `templates/` directory exists → fall back to the page-type table below, matching by SVG filename keyword (cover/chapter/content/ending/toc). Read the matched file at first use if §1.0 batch did not cover it.
5. No template at all → free design.
> Note: `page_layouts` disambiguates the multiple content variants modern templates ship (e.g., `graduation_defense` has 8); the legacy table cannot.
**Templates supply structure, not skin (non-mirror)**: a chart or layout template's gradients, drop-shadows, palette, **and font sizes** are placeholder. Inherit its geometry, label / legend placement, and series-encoding logic; re-skin every fill / stroke to the deck's `visual_style` + `spec_lock.colors` — flat styles strip the gradients and shadows, gradient / glass styles repaint their own. Forbidden — shipping a template's default `<linearGradient>` / `cardShadow` / Tailwind fills unchanged. Mirror templates are the exception: §1.1 preserves their visuals verbatim.
**Font size is skin, not geometry (non-mirror).** A chart / layout template's hardcoded `font-size` values (often 1116px, sized for the template's own dense placeholder text) are NOT inherited — classify each text into its `spec_lock.md` role and use that role's locked size, exactly as you re-skin color. **Structural roles (page title / body / subtitle / annotation / footnote) hold their one deck-wide size on every page** — the template's placeholder px never overrides it; same-role text drifting page to page is what makes a deck look unprofessional.
**Typography execution order (mandatory):**
1. Build a per-page text inventory from `design_spec.md §IX` + the current `notes/<NN>_*.md`.
2. Classify each text item before drawing. **Structural roles** (`title`, `subtitle` / `lead`, `body`, `annotation`, `footnote` / `page_number`) must map to their declared `spec_lock.typography` slot. A **one-off feature element** (a single hero number, an isolated emphasis label) may take an in-ramp intermediate value — the ramp is anchored on `body`, not a closed menu — but a feature size that **recurs** must be promoted to a declared slot. The failure mode this guards against is structural text silently inheriting the template's compact px, not legitimate feature sizing.
3. Copy the role's locked px value into `font-size` verbatim. Do this before placing the text; never start from a template `font-size` and then "adjust".
4. Layout from those locked sizes: compute line-height, wrapped line count, child `y` / `dy`, card padding, card height, column gaps, and available image/chart area from the chosen px values.
5. Only after this reflow may you inspect fit. If fit fails, move / resize containers or simplify local geometry first; do not reduce the role size merely because the inherited template slot was smaller.
**Geometry adapts to the type, never the reverse**: when the locked size is larger than the template's placeholder text, widen / heighten the card, open spacing, and recompute child `y` / `dy` to make room — do not shrink the font to fit the inherited container. A `font-size` change is a layout change: revise line-height and every downstream vertical coordinate that depends on it. For wrapped text, allocate at least the wrapped line count × line-height plus top / bottom padding; fixed `y` stacks copied from a smaller template are invalid once the locked role size is applied. The Executor renders the page it was given; page count and per-page density are the Strategist's call, fixed at confirmation — do **not** re-paginate, split the page, or drop authored content to cope with size here. Only when a single block still cannot fit after the geometry is fully reflowed may you shrink **that block** as a bounded last resort — and **only body text** is ever shrunk this way. Title, subtitle, annotation / caption, footnote and page number are **locked once set and never adjusted to fit** — their values hold across the whole deck. Step the overflowing body block's `font-size` down by `2`px at a time, and only if it still overflows step it down again, up to a cumulative floor of **`4`px below the locked body size** (e.g. `24` → no smaller than `20`). This is a **local, single-block** reduction — the deck-wide locked body size is unchanged on every other block and page. (The Executor works in **unitless px** throughout — spec_lock and SVG carry no `pt`.) If the block still overflows at the floor, surface a `warning:` rather than silently restructure the page. (Mirror templates are the exception: §1.1 preserves their sizes verbatim — there the source deck's typography *is* the spec.)
### 1.1 Mirror-mode templates — reference-style consumption
When the project's chosen template is a `mirror` template (`design_spec.md` frontmatter declares `replication_mode: mirror`), Executor switches to a **reference-style** consumption path that bypasses placeholder substitution:
1. **Per-page reference selection** — Strategist selects one mirror page per project page via `spec_lock.md page_layouts` (e.g., `P04: 015_content`). The basename is the mirror filename without extension; Strategist made this choice by reading `design_spec.md §V Page Roster` descriptions, not by guessing.
2. **Copy, don't fill** — open the referenced mirror SVG (already in context from §1.0). **Copy it as the starting point for the project page**, then edit text elements in place to express the project's content for `P<NN>`. Preserve every non-text element verbatim: backgrounds, decorative shapes, sprite-cropped images, charts, icon usage, color values, font families, geometry, sprite `<svg viewBox>` wrappers, and **which image** each `<image>` points at.
3. **What you may edit** — the visible text content of `<text>` / `<tspan>` elements that express slide-specific content (title, body, captions, KPI labels, dates, page numbers). Replace the source deck's example text with the project's text for this page from `design_spec.md §IX` and `notes/<NN>_*.md`.
4. **What you must not touch** — element positions, sizes, fonts, colors, fills, strokes, gradients, **which image each `<image>` points at**, `<g>` grouping, sprite-sheet `<svg viewBox>` wrappers, decorative `<rect>` / `<path>` / `<circle>` / `<polygon>` shapes, `<use data-icon="...">` markers, embedded chart data structures. Mirror's value is preserving the source deck's visual identity — any geometric / decorative drift defeats the purpose. **The `href` path is not the image**: normalizing a bare `href="cover_bg.png"` to `href="../images/<name>"` (when Step 3 relocated the asset to `images/`) points at the *same* image and changes nothing visual — that is an allowed path fix, not a fidelity edit. Leaving the bare href as-is is also fine; the exporter and live preview resolve bare hrefs against `images/` either way.
5. **Content fit** — the mirror page was chosen by Strategist because its layout matches the content slot. If the project's content for `P<NN>` legitimately needs more / fewer items than the mirror page provides (e.g. mirror shows 3 KPI cards, project has 4 metrics), keep the mirror page's visual rhythm and either drop one metric to fit or split across two pages — do **not** restructure the mirror page's grid. If neither works, surface a `warning: P<NN> content does not fit mirror reference <basename>; suggest different reference page` and proceed with the closest-fit edit.
6. **No `{{}}` substitution** — mirror SVGs do not contain placeholder markers. Do not search for `{{TITLE}}` / `{{CONTENT_AREA}}` etc.; do not invent placeholders. The whole mirror contract is "verbatim source + in-place text edit".
7. **Output filename** — follow the standard project SVG naming convention (`<NN>_<page_name>.svg` where `<NN>` matches the project page index, not the mirror source index). The mirror filename is the *reference*, not the *output*.
**Detecting mirror mode**: read the chosen template's `design_spec.md` frontmatter once during §1.0 batch read. If `replication_mode: mirror`, every page that hits `page_layouts` follows §1.1 above; pages without a `page_layouts` entry still fall through to free design (resolution rule 3 above).
**Mirror + chart pages**: chart structures inside a mirror SVG are already drawn (axis, series, labels). Treat them as visual references — replace the data labels and series text content to match the project's chart spec, but do not redraw the chart from a `templates/charts/<name>.svg` baseline. A mirror template's `page_charts` entries are normally absent for this reason.
**Legacy fallback table** (used only when `page_layouts` is absent):
| Page Type | Corresponding Template | Adherence Rules |
|-----------|----------------------|-----------------|
| Cover | `01_cover.svg` | Inherit background, decorative elements, layout structure; replace placeholder content |
| Chapter | `02_chapter.svg` | Inherit numbering style, title position, decorative elements |
| Content | `03_content.svg` | Inherit header/footer styles; **content area may be freely laid out** |
| Ending | `04_ending.svg` | Inherit background, thank-you message position, contact info layout |
| TOC | `02_toc.svg` | **Optional**: Inherit TOC title, list styles |
### Page-Template Mapping Declaration (Required Output)
Before generating each page, output which template is used:
```
📝 **Template mapping**: `templates/03a_content_image_text.svg` (or "None (free design)")
🎯 **Adherence rules / layout strategy**: [specific description]
```
- **Content pages**: template defines only header/footer; content area is free
- **No template**: generate entirely per the Design Spec
---
## 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.
### 2.1 Per-page spec_lock re-read (Mandatory)
> Long decks drift off the declared palette/icons mid-deck due to context compression. `spec_lock.md` is the canonical execution reference — re-read it per page to bypass model memory.
**Hard rule**: Before generating **each** SVG page, `read_file <project_path>/spec_lock.md`. Use only values from this file, not from memory. If context was auto-compacted, also `read_file <project_path>/design_spec.md` for the current page's §IX brief.
**Per-block expression**: render each `design_spec.md §IX Content` block in its written texture — a full-sentence block as wrapped prose, a fragment/label block as bullets/keywords. **Never split a full-sentence block into a bullet list** — splitting loses the information that the block was continuous reasoning, not a set of parallel points; not because a bullet lays out easier, and not because an inherited template slot is shaped as a list. If a block carries no clear texture, infer the mode from its wording and the page layout.
- **Prose render recipe**: one `<text>` per paragraph; wrap lines with sibling `<tspan>` that reset `x` to the block's left edge and advance `dy` by the font size × a line-height factor. **Default — line-height by density (may override per content fit)**: ~1.41.5× for dense / small-body blocks (CLReq comfortable minimum), 1.62.0× for large-type, sparse, or `breathing` blocks. Fit about width ÷ font-size CJK glyphs per line (Latin fits roughly twice that); the last line runs short. Use the body ramp size, not a new one.
- **Template precedence**: when an inherited template slot is a bullet list but the §IX block is prose, the prose wins — widen or reflow the container to hold the paragraph, or drop that card; do not pour the sentence back into the list slot.
- **Mode precedence**: the locked mode shapes voice / register, not §IX's authored titles or page order. When a `§IX` title is a user-authored topic label, keep it — do not upgrade it to an assertion just because the mode (e.g. `pyramid`) favors them; mode title-tendencies apply only to AI-drafted titles.
> Note: block-level phrasing, applied *within* the page's `page_rhythm` density (below), not against it.
**If `spec_lock.md` is missing**: emit `warning: spec_lock.md missing — generating without execution lock` once, then proceed using `design_spec.md` values. Expected only for legacy projects; new projects MUST have it (see [strategist.md](strategist.md) §6 step 4).
**Forbidden — values outside the lock**:
- Colors (fill / stroke / stop-color) MUST come from `colors`
- Icons MUST come from `icons.inventory`; library MUST equal `icons.library`
- Font family from `typography`: use role override (`title_family` / `body_family` / `emphasis_family` / `code_family`) if declared, else fall back to `font_family`
- Font sizes follow a **ramp anchored on `typography.body`**, not a closed menu. **Structural roles — page title, body, subtitle, annotation / caption, footnote / page number — render at one consistent size deck-wide, taken from their `spec_lock` slot; never re-pick a structural role's size page by page or carry a template's placeholder px.** This locks the **role**, not every glyph: a page may still carry deliberate typographic hierarchy — a lead-in sentence, an inline emphasis figure, a pull-quote, a kicker, a hero number — but each of those is its **own role / feature element** with its own size, **applied consistently deck-wide** (declare a recurring one as its own `spec_lock` slot). In-band intermediate sizes are for exactly these feature elements. What is banned is the *same* role drifting size to fit a container or by page whim — that scatter is what reads as unprofessional. Sizes outside every band require extending the lock first.
- **The page's core message is primary — render it ≥ `body`.** The one-idea / key-claim / key-takeaway line a page is built around is its most important text; map it to the locked `lead` or `subtitle` slot (≥ `body`), never to a sub-`body` size. Demoting it below body while data callouts or labels sit larger inverts the hierarchy — the failure this prevents. If no `lead` / `subtitle` slot is locked for a recurring core-message line, surface it (per below) instead of improvising a smaller one. A footnote / page number / source credit uses the locked `footnote` (or `annotation`) slot — never an invented sub-`annotation` size; and the body-shrink last resort (§1.0) bottoms out at `body 4`px, a hard floor never crossed.
- **Write the locked px verbatim; at most 2 decimals.** `font-size` MUST be the exact px from `spec_lock.typography` — if `body` is `24`, write `24`; never substitute a "rounder" or PowerPoint-familiar number (`20` / `18` / `36`). The system is px-only — there is no pt to convert, and a remembered pt-style value written as px renders the whole deck the wrong size. Prefer whole numbers (sizes are clean even px); keep a decimal only for a slot that genuinely carries one in `spec_lock`. Never emit long tails like `20.8026`: the exporter rounds the final size to 1 decimal pt, so extra px precision is wasted noise.
- Images MUST reference files listed under `images`; no invented filenames
- Formula PNGs are images with `Acquire Via: formula` / `Status: Rendered`; place them only from the listed file path and never recreate the formula as text.
If a page needs a value not in `spec_lock.md`, surface it — do not silently invent one.
**Per-page layout rhythm — `page_rhythm` section**:
Before drawing each page, look up its entry in `page_rhythm` (key format `P<NN>` matching the page index in §IX of `design_spec.md`) and apply the corresponding layout discipline:
| Tag | Layout discipline |
|-----|-------------------|
| `anchor` | Structural page (cover / chapter / TOC / ending). With a template, follow the matching template verbatim. In free design (no template), realize the page's §IX intent — for the cover deliver its `Cover impact` and for a closing page its `Closing impact` (the committed hook / takeaway + composition), never a default centered title + subtitle or a generic "Thank you" sign-off. |
| `dense` | Information-heavy. Card grids, multi-column layouts, KPI dashboards, tables, and charts are all permitted. This is the baseline behavior. |
| `breathing` | Low-density impact page. Avoid **multi-card grid layouts** — do not organize content as multiple parallel rounded containers (3-card row, 4-card KPI grid, 2×2 matrix rendered as cards). Use naked text blocks, dividers, whitespace, or full-bleed imagery as the content structure. Single rounded visual elements (hero image corners, callouts, tags, one emphasis block) are fine — the rule is about grid structure, not about the `rx` attribute. Proportions follow information weight (not a preset ratio). Typical forms: hero quote, single large number with one-line interpretation, full-bleed image with floating caption, section transition. |
> Without rhythm variation, every page defaults to card grids (the "AI-generated" look). `page_rhythm` is the only narrative lever that survives context compression.
**Missing `page_rhythm` section** → emit `warning: spec_lock.md missing page_rhythm — defaulting all pages to dense` once, fall back to `dense` for all pages.
**Tag not found for current page** → emit `warning: spec_lock.md page_rhythm tag not found for P<NN> — falling back to dense` once per deck (aggregate; do not repeat per page), fall back to `dense`. Do not invent a tag.
**Per-page template lookup — `page_layouts` section**:
Before drawing each page, look up its entry in `page_layouts` to decide which basename to inherit (the SVG itself was loaded in §1.0):
- Entry present (e.g., `P04: 03a_content_image_text`) → inherit the corresponding SVG already in context. The basename **must match** an actual file in the chosen template directory; if it doesn't, emit `warning: page_layouts P<NN> references missing file <basename>.svg — falling back to free design` and proceed.
- No entry for this page → free design, no inheritance. **Not an error** — Strategist intentionally left this page free.
- Whole section absent → see §1 fallback (legacy page-type matching).
Do **not** invent a layout entry, and do **not** assume a template just because `templates/` exists — if `page_layouts` is present but silent for this page, that silence is the instruction.
**Per-page chart reference — `page_charts` section**:
Before drawing each page, look up its entry in `page_charts` to decide which chart structure applies (the SVG itself was loaded in §1.0):
- Entry present (e.g., `P09: timeline_horizontal`) → adapt the corresponding chart SVG already in context. Apply project colors/typography/density; do not copy verbatim. Cross-reference `templates/charts/charts_index.json` for the chart's purpose summary if needed.
- No entry for this page → either no chart on this page, or a chart that didn't match any catalog template (Strategist's `no-template-match` fallback). Design the visualization from scratch using `design_spec.md §VII` for guidance.
- Whole section absent → no chart pages in this deck.
---
## 3. Execution Guidelines
- **Proximity**: group related elements with tight spacing; separate unrelated groups
- **Spec adherence**: follow color, layout, canvas format, and typography in the spec
- **Template structure**: if templates exist, inherit the visual framework
- **Main-agent ownership**: SVG generation must run in the main agent (not sub-agents) — pages share upstream context for cross-page visual continuity
- **Generation rhythm**: lock global design context first, then generate pages sequentially in one continuous context. No batched groups (e.g., 5 at a time).
- **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.
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.
### 3.1 Chart Plot-Area Marker (MANDATORY on every chart page)
> The [`verify-charts`](../workflows/verify-charts.md) workflow enumerates chart pages from `design_spec.md §VII`, then reads each page's plot-area marker to feed `svg_position_calculator.py`. Missing marker → verify-charts has to re-derive the plot area from axis lines, paying the cost on every run.
**Hard rule**: every SVG page that contains a data visualization chart includes a plot-area marker inside `<g id="chartArea">`, placed **after axis lines** and **before the first data element** (bar, line, area, point).
**Rectangular plot area** (bar / horizontal_bar / grouped_bar / stacked_bar / line / area / stacked_area / scatter / waterfall / pareto / butterfly):
```xml
<!-- chart-plot-area: x_min,y_min,x_max,y_max -->
```
**Radial charts** (pie / donut / radar):
```xml
<!-- chart-plot-area: pie | center: cx,cy | radius: r -->
<!-- chart-plot-area: donut | center: cx,cy | outer-radius: r1 | inner-radius: r2 -->
<!-- chart-plot-area: radar | center: cx,cy | radius: r -->
```
**How to determine coordinate values**:
| Value | Derivation |
|-------|------------|
| `x_min` | X coordinate of the Y-axis line (leftmost data boundary) |
| `y_min` | Y coordinate of the topmost grid line (highest data boundary) |
| `x_max` | X coordinate of the rightmost axis endpoint or grid line |
| `y_max` | Y coordinate of the X-axis baseline |
| `cx, cy` | Center point of pie/donut/radar (accounting for `transform="translate()"`) |
| `r` | Outer radius of the chart |
**Per-page verification** — after writing each chart SVG, confirm the marker exists:
```bash
grep "chart-plot-area" <project_path>/svg_output/<current_page>.svg
```
> All chart templates in `templates/charts/` include this marker as a reference. If you are drawing a chart and the marker is absent, you have a bug.
- **Technical specs**: see [shared-standards.md](shared-standards.md) for SVG/PPT constraints
- **Card containers — use the documented patterns**: when a content page needs section cards (4 quadrants, parallel aspects, capability blocks, info cards), use the patterns codified in [`templates/charts/CHART_STYLE_GUIDE.md`](../templates/charts/CHART_STYLE_GUIDE.md) §11 — half-rounded section tab (§11.1), nested card border without stroke (§11.2), card-grid skeletons (§11.3), diagonal dashed connector for cross-quadrant relationships (§11.5), ground-anchor ellipse as a non-filter depth marker (§11.6), bidirectional interaction arrows for paired protocols (§11.7). Do not reinvent the "tinted full-rounded rect + white cover-rect to hide the bottom corners" hack; it survives in older templates but breaks SVG→PPTX color editing. Reference templates: [`labeled_card.svg`](../templates/charts/labeled_card.svg), [`quadrant_text_bullets.svg`](../templates/charts/quadrant_text_bullets.svg), [`kpi_cards.svg`](../templates/charts/kpi_cards.svg), [`matrix_2x2.svg`](../templates/charts/matrix_2x2.svg), [`team_roster.svg`](../templates/charts/team_roster.svg), [`client_server_flow.svg`](../templates/charts/client_server_flow.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.
### 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).
Examples: `01_封面.svg` / `02_目录.svg` / `03_核心优势.svg`; `01_cover.svg` / `02_agenda.svg` / `03_key_benefits.svg`.
---
## 4. Icon Usage
Strategist chooses the library and inventory; Executor only implements. Library details and one-library rule: [`../templates/icons/README.md`](../templates/icons/README.md). This section defines placeholder syntax.
> **Resolution is project-first.** Strategist copied the chosen icons into `<project_path>/icons/<lib>/` (via `icon_sync.py`); `finalize_svg.py embed-icons` embeds from there, falling back to the global library per-icon. **Custom icons**: drop an `.svg` into `<project_path>/icons/<lib>/` (any `<lib>`, e.g. `custom/`) and reference it as `data-icon="<lib>/<name>"` — it embeds like any other. Reference only icons in the `spec_lock.md` inventory.
**Built-in icons — Placeholder method (recommended)**:
```xml
<!-- chunk-filled (straight-line geometry, sharp corners, structured) -->
<use data-icon="chunk-filled/home" x="100" y="200" width="48" height="48" fill="#005587"/>
<!-- tabler-filled (bezier-curve forms, smooth & rounded contours) -->
<use data-icon="tabler-filled/home" x="100" y="200" width="48" height="48" fill="#005587"/>
<!-- tabler-outline (light, line-art style — screen-only decks) -->
<use data-icon="tabler-outline/home" x="100" y="200" width="48" height="48" fill="#005587"/>
<!-- phosphor-duotone (single color + 20% backplate — soft depth without solid weight) -->
<use data-icon="phosphor-duotone/house" x="100" y="200" width="48" height="48" fill="#005587"/>
<!-- simple-icons (brand logos — used alongside the deck's primary library, only for real company/product marks) -->
<use data-icon="simple-icons/github" x="100" y="200" width="48" height="48" fill="#181717"/>
<!-- tabler-outline with thin / bold stroke (stroke-style libraries only) -->
<use data-icon="tabler-outline/home" x="100" y="200" width="48" height="48" fill="#005587" stroke-width="1.5"/>
<use data-icon="tabler-outline/home" x="100" y="200" width="48" height="48" fill="#005587" stroke-width="3"/>
```
> ⚠️ **Color**: ALWAYS use `fill="#HEX"` on `<use data-icon="...">`. NEVER use `stroke` or `fill="none"`, even for stroke-style libraries.
>
> **stroke-width** (stroke-style libraries only, currently `tabler-outline`): allowed values `{1.5, 2, 3}`. If `spec_lock.md icons.stroke_width` is declared, all placeholders MUST use that value deck-wide. Default `2` if absent (legacy). Ignored on non-stroke libraries.
>
> Icons are auto-embedded by `finalize_svg.py` — no need to run `embed_icons.py` manually.
**Searching for icons** — use terminal, zero token cost:
```bash
ls skills/ppt-master/templates/icons/chunk-filled/ | grep home
ls skills/ppt-master/templates/icons/tabler-filled/ | grep home
ls skills/ppt-master/templates/icons/tabler-outline/ | grep chart
ls skills/ppt-master/templates/icons/phosphor-duotone/ | grep house
ls skills/ppt-master/templates/icons/simple-icons/ | grep github
```
**Abstract concept → icon name** (names for `chunk-filled`; tabler libraries use their own equivalents — verify with `ls | grep`):
| Concept | chunk-filled | tabler-filled / tabler-outline |
|---------|-------|-------------------------------|
| Growth / Increase | `arrow-trend-up` | same |
| Decline / Decrease | `arrow-trend-down` | same |
| Success / Complete | `circle-checkmark` | `circle-check` |
| Warning / Risk | `triangle-exclamation` | `alert-triangle` |
| Innovation / Idea | `lightbulb` | `bulb` |
| Strategy / Goal | `target` | same |
| Efficiency / Speed | `bolt` | same |
| Collaboration / Team | `users` | same |
| Settings / Config | `cog` | `settings` |
| Security / Trust | `shield` | same |
| Money / Finance | `dollar` | `currency-dollar` |
| Time / Deadline | `clock` | same |
| Location / Region | `map-pin` | same |
| Communication | `comment` | `message` |
| Analysis / Data | `chart-bar` | same |
| Process / Flow | `arrows-rotate-clockwise` | `refresh` |
| Global / World | `globe` | `world` |
| Excellence / Award | `star` | same |
| Expand / Scale | `maximize` | same |
| Problem / Issue | `bug` | same |
> For self-evident names (home, user, file, search, arrow, etc.) — just `grep chunk-filled/` directly without consulting the table.
> ⚠️ **Icon validation**: only use icons from the Design Spec's approved inventory. Verify each via `ls | grep` before use. Mixing libraries within one deck is FORBIDDEN.
---
## 5. Visualization Reference
Chart SVGs referenced in **VII. Visualization Reference List** are loaded once via the §1.0 batch read. This section governs adaptation only.
**Hard rule**: adapt the loaded chart SVG; do not improvise from memory and do not replicate verbatim. Apply project colors, typography, content; preserve visualization type.
**Adaptation rules**:
- **Preserve**: visualization type (bar/line/pie/timeline/process/framework…) as specified
- **Adapt**: data, labels, colors (project scheme), dimensions
- **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`
### 5.1 Chart Coordinate Calibration
Coordinate calibration runs as a **standalone post-generation workflow**, not inside the executor pipeline. After SVG generation completes, if the deck contains data charts, run [`workflows/verify-charts.md`](../workflows/verify-charts.md) before post-processing.
The executor's only obligation here is upstream: embed the `<!-- chart-plot-area ... -->` marker on every chart page during initial draft (§3.1). Verify-charts enumerates chart pages from `design_spec.md §VII` (authoritative deck plan) and uses the marker to feed `svg_position_calculator.py`.
> Do NOT run `svg_position_calculator.py` during the initial draft. The calculator calibrates already-generated SVGs against their declared plot areas; running it before the SVG exists has nothing to compare against.
---
## 6. Image Handling
Handle images by their status in the Design Spec's Image Resource List. Status enum and lifecycle: [`svg-image-embedding.md`](svg-image-embedding.md).
| Status | Source | Handling |
|--------|--------|----------|
| **Existing** | User-provided | Reference images directly from `../images/` directory |
| **Generated** | Generated by Image_Generator | Reference images directly from `../images/` directory |
| **Sourced** | Web-acquired by Image_Searcher | Reference from `../images/`. **Read [`image_sources.json`](image-searcher.md) to decide attribution** — see §6.1 below. |
| **Rendered** | Deterministic formula PNG | Reference from `../images/`; use `preserveAspectRatio="xMidYMid meet"` |
| **Needs-Manual** | Acquisition failed and file is absent | Use dashed border placeholder unless the expected file exists |
| **Placeholder** | Not yet prepared | Use dashed border placeholder |
**Reference syntax**: see [`svg-image-embedding.md`](svg-image-embedding.md).
**Template-bundled images**: when a template (deck / layout / brand) is applied, its bitmaps are copied into the project's `images/` alongside every other runtime image (SKILL.md Step 3). Reference them the same way — `../images/<name>` — and do **not** reproduce a template SVG's bare sibling href (e.g. `href="cover_bg.png"`): the template SVG is reference material, the rendered page lives in `svg_output/` and must point at `../images/`. Mirror templates (§1.1) are the one exception — they copy hrefs verbatim, and the exporter resolves those bare hrefs against `images/`.
**Placeholder**: Dashed border `<rect stroke-dasharray="8,4" .../>` + description text
**`no-crop` images**: when a `spec_lock.md images` entry ends with ` | no-crop`, size the container to the image's native ratio (from `analyze_images.py` or file dims) and use `preserveAspectRatio="xMidYMid meet"`. Untagged entries are croppable — default to `slice`.
**Formula images**: rows with `Acquire Via: formula` or `Type: Latex Formula` MUST be treated as no-crop even if a legacy `spec_lock.md` forgot the flag. Use the dimensions from `design_spec.md §VIII`, `analysis/image_analysis.csv`, or `images/formula_manifest.json`; do not normalize all formulas to one height unless the spec explicitly states that layout choice.
### 6.1 Inline Attribution for Sourced Images (web path)
Whenever the slide uses an image with `Status: Sourced`, look up the corresponding entry in `project/images/image_sources.json` and act on `license_tier`:
| `license_tier` | Action on this slide |
|---|---|
| `no-attribution` | Embed the `<image>` element only. **No credit element needed.** |
| `attribution-required` | Embed the `<image>` element **plus** a small inline `<text>` credit element per the visual spec in [image-searcher.md §7](./image-searcher.md). |
The credit text is **not** rendered by post-processing or export — it must be present in the SVG you produce. The shape of the credit element (size, position, color, multi-image source line, hero gradient overlay) is specified in [image-searcher.md §7](./image-searcher.md). Do not invent a different style.
Use `attribution_text` from the manifest entry as the **starting point**, then compress for the small-text constraint (drop URL, drop filename, keep "via Provider / License"). For CC0/PD images that landed in the `attribution-required` tier only because of upstream metadata quirks (rare), credits are still safe to render.
`svg_quality_checker.py` treats missing CC BY / CC BY-SA inline attribution as an **error**. Fix the offending SVG before post-processing.
**The manifest is the single source of truth for credits.** Do not duplicate license info into speaker notes or any other artifact.
---
## 7. Font Usage
Source of truth: `spec_lock.md typography`. Use `font_family` as default; override per role with `title_family` / `body_family` / `emphasis_family` / `code_family` if declared. LaTeX formulas that Strategist rendered are PNG images, not a `code_family` text role.
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.
---
## 8. Speaker Notes Generation Framework
### Task 1. Generate Complete Speaker Notes Document
After all SVG pages are finalized, enter Logic Construction Phase and write the full notes to `notes/total.md`. Batch-writing (not per-page) lets transitions plan coherently.
**Pure spoken narration**: notes are read aloud verbatim by `notes_to_audio.py` (TTS). Write only what should be spoken. No visible markers, no labeled meta-lines, no enumerated key-point lists, no duration annotations — anything you write outside the heading will be vocalized.
**Per-page structure**: `# <number>_<page_title>` heading (the `#` heading line is the only thing stripped before TTS), pages separated by `---`. Body is 25 natural sentences carrying the page's core message. Page-to-page transitions live inside the opening sentence as natural prose ("接下来……" / "Having framed X, let's turn to Y") — no bracketed `[过渡]` / `[Transition]` tags.
**Concrete examples** — same shape applies to any language; just write naturally in that language.
中文 deck
```
# 02_市场格局
在明确了行业背景之后,我们来看具体的市场格局。当前线上零售集中度持续上升,前三大平台合计份额已经达到百分之六十八,腰部玩家正在被快速挤压,留给新进入者的窗口期不超过十八个月。这意味着我们的策略必须聚焦,而不是铺开。
```
英文 deck
```
# 02_market_landscape
Having framed the industry backdrop, let's look at the actual market landscape. Online retail concentration keeps rising — the top three platforms now hold sixty-eight percent of combined share, mid-tier players are being squeezed fast, and the window for new entrants is under eighteen months. This means our strategy has to focus, not spread.
```
> 日本語 / 한국어 / 其他语言:照搬同样的结构,用对应语言自然书写即可。
**Number readability**: TTS reads digits and symbols literally. Prefer fully-spelled forms in the language being spoken when literal pronunciation would be awkward (e.g. Chinese "百分之六十八" reads better than "68%"; "1-2分钟" reads as "一减二分钟"). Plain integers and percentages in English are fine as-is.
**Common mistakes to avoid**:
- Leaving any bracketed stage marker (`[过渡]` / `[Transition]` / `[Pause]` / `[Data]` / `[Scan Room]` / `[Interactive]` / `[Benchmark]` etc.) in the text — they will be read aloud literally.
- Adding `要点:① …` / `Key points: (1) …` / `时长2分钟` / `Duration: 2 minutes` / `Flex: …` lines — TTS will speak "要点 一 …".
- Mixing languages within one deck's notes.
### Task 2. Split Into Per-Page Note Files
Auto-split `notes/total.md` into per-page files in `notes/`.
**Naming**: match SVG names (`01_cover.svg` → `notes/01_cover.md`); `slide01.md` also supported (legacy).
---
## 9. Next Steps After Completion
> **Auto-continuation**: After Visual Construction Phase (all SVG pages) and Logic Construction Phase (all notes) are complete, the Executor proceeds directly to the post-processing pipeline.
**Post-processing & Export** (same canonical pipeline as [shared-standards.md §5](shared-standards.md)):
```bash
# 1. Split speaker notes
python3 scripts/total_md_split.py <project_path>
# 2. SVG post-processing (auto-embed icons, images, etc.)
python3 scripts/finalize_svg.py <project_path>
# 3. Export PPTX
python3 scripts/svg_to_pptx.py <project_path>
# Output (default-flow mode):
# exports/<project_name>_<timestamp>.pptx ← native pptx (canonical output)
# backup/<timestamp>/svg_output/ ← Executor SVG source backup (always written)
#
# Add --svg-snapshot to additionally emit:
# exports/<project_name>_<timestamp>_svg.pptx ← SVG snapshot pptx (sibling of native pptx)
```

View File

@ -1,102 +0,0 @@
# 图标系统 (两层)
> 几何装饰 (圆点、徽章、品牌条、装饰线) 已在 `layouts.md` 起手块以 helper 封装 (`add_dot` / `add_badge` / `add_accent_line` / `add_rect`),直接调用,**不要重写**,**也不要把它们当"图标"用**。本文档处理的是真正的**业务概念图标** (火箭 / 目标 / 雷达 / 齿轮 / 盾牌 ...)
## 选图标两层降级
```
1) Iconify 个性化图标 ── 业务概念 (火箭、目标、雷达、齿轮) → 见 §A
2) Unicode 字形兜底 ── Iconify 没有合适的 (✓ ✗ ★ → ↑) → 见 §B
```
整 deck 选**一个图标集**用到底,不要 tabler 跟 lucide 混用。
## §A. Iconify 个性化图标 (本地缓存 + 网络拉取)
### A1. 本地库 (两处:只读种子库 + 本 task 已拉)
- **种子库(只读)**: `<skill_dir>/assets/icons/` —— skill 自带的商务红 tabler 种子集,详见 [INDEX.md](../assets/icons/INDEX.md)。docker 沙盒里 `skills/` 是只读挂载,**只能读、不能往这儿写**。
- **本 task 已拉**: `<task_dir>/assets/icons/` —— A2 fetch 新图标的落点(可写)。
命名规约: `<set>_<name>_<colorhex>_<sizepx>.png`(如 `tabler_rocket_C00000_128.png`)
**用之前先 `glob` 两处都查一遍**(种子库 `<skill_dir>/assets/icons/` + 本 task `<task_dir>/assets/icons/`),有就直接 `add_picture`,免去网络往返。
### A2. fetch_icon.py 拉新图标
脚本在 `<skill_dir>/scripts/`(只读可执行);拉下来的图标 `-o` **必须落 `<task_dir>/assets/icons/`**(种子库只读,新图标进 task 目录):
```bash
# 主红色 128px PNG (推荐)
python <skill_dir>/scripts/fetch_icon.py rocket --set tabler --color C00000 \
--size 128 -o <task_dir>/assets/icons/tabler_rocket_C00000_128.png
# 强调色金黄
python <skill_dir>/scripts/fetch_icon.py target --set tabler --color FFC107 \
--size 128 -o <task_dir>/assets/icons/tabler_target_FFC107_128.png
```
`--set` 默认 `tabler`(4500+ 商务图标,MIT)。其它选 `lucide / heroicons / material-symbols / carbon / fluent / mdi`。**整 deck 只用一个 set**。
PNG 转换需 `pip install cairosvg`(推荐)或 `pip install svglib`。没装也能拿 SVG。
### A3. 嵌入幻灯片
```python
slide.shapes.add_picture(
"<task_dir>/assets/icons/tabler_rocket_C00000_128.png", # 路径 = glob 命中的那处(种子库或 task)
Inches(1.0), Inches(2.5),
width=Inches(0.8), # 装饰图标 0.5-1.5 in;别超 2 in
)
```
### A4. 浏览找名字
打开 https://icon-sets.iconify.design/ 搜关键词,如 "rocket" / "数据" / "shield",拿到名字 (如 `tabler:rocket`) 直接给 fetch_icon.py。
### A5. 流程节点 (替代 PENTAGON)
需要"调研→设计→开发→测试→上线"这种横向流程时,**不要用 PowerPoint 内置 PENTAGON**(视觉陈旧),改用 Iconify 的 `chevron-right` + 文本组合:
```python
from pptx.util import Inches
from pptx.enum.text import PP_ALIGN
# 假设页面顶部已 import pptx_helpers as P,且 slide 已建(见 layouts.md §通用起手)
stages = ["调研","设计","开发","测试","上线"]
icon_path = "<task_dir>/assets/icons/tabler_chevron-right_C00000_64.png" # 先 fetch_icon.py 拉到 task,种子库没有 chevron-right_64
for i, label in enumerate(stages):
x = 0.7 + i * 2.4
P.add_textbox(slide, x, 3.7, 1.8, 0.5, label, 16, bold=True,
color=P.PRIMARY, align=PP_ALIGN.CENTER, name=f"stage_{i}")
if i < len(stages) - 1: # 节点间放 chevron
slide.shapes.add_picture(icon_path, Inches(x + 1.85), Inches(3.7),
width=Inches(0.4))
```
## §B. Unicode 字形 (兜底)
Iconify 都没合适的时候用。避 emoji,用单色符号:
```
✓ ✔ ✗ ✘ 对号 / 错号
★ ✦ ✧ ✪ 星
→ ← ↑ ↓ ↔ 箭头
↗ ↘ ↙ ↖ 斜箭头
● ○ ◉ ◎ 圆
⬛ ⬜ ◆ ◇ 方块菱形
∴ ∵ ⇒ ⇔ 数学
№ ¶ § † 文档
```
```bash
# 强调色对号 96px → PNG
python <skill_dir>/scripts/render_icon.py "✓" --color "#C00000" --size 96 -o <task_dir>/slides/check.png
```
## §C. 硬规则
1. **风格统一** —— 整 deck 只用一个 Iconify set;不要 tabler 跟 lucide 混
2. **颜色限定** —— 只用 PRIMARY / SECONDARY / ACCENT / GREY,不要每图标独立配色
3. **大小克制** —— 装饰图标 0.5-1.5 in;不超过 2 in
4. **不替表意** —— 一个 ★ 不能代替"重点"两字
5. **避免 emoji** —— 跨系统渲染差异大,且自带颜色冲突主题
6. **不要每页都堆** —— 装饰是配角,文字是主角
7. **缓存复用** —— Iconify 拉的图标进 `<task_dir>/assets/icons/`,本 task 内再用直接读,不要重复请求(种子库 `<skill_dir>/assets/icons/` 只读,新图标不往那写)
## §D. 不要把 layouts.md helper 当"图标"
`add_dot` / `add_badge` / `add_accent_line` / `add_rect` 是几何**装饰**(品牌条、圆点 bullet、编号徽章、装饰短线),不是业务图标。它们底层是 MSO_SHAPE.OVAL/RECTANGLE,但模型不要直接调 MSO_SHAPE —— 全部走 layouts.md 的 helper 接口。

View File

@ -0,0 +1,222 @@
# Image-Text Layout Patterns
A vocabulary registry of ways images can be placed on a slide. The point of this file is to **expand the mental list of options** so that when you reach for an image layout, you do not default to the same three patterns (left/right, top/bottom, full-bleed cover).
Every entry has a name plus a short technical hint. Common techniques get a single line. Less obvious or easily forgotten techniques get a short paragraph — not a full tutorial, but enough that a model unfamiliar with the project can implement it without guessing. This is a registry, not a teaching document; no use-case prescriptions, no decision tables.
> **Numbers are stable identifiers, not sequence.** The file is split into **Part 1 — Primary Structures** (#1#19, #38#56) and **Part 2 — Modifier Layers** (#20#37, #57#72). Numbers jump within each Part because Primary structures were grouped first; existing references to `#38`, `#48`, etc. anywhere in the project still resolve correctly.
---
## Core Principle — Two Layers
Almost every pattern below is an instance of one underlying split:
> **The image carries atmosphere, world-building, emotional weight. Native SVG shapes carry information, data, editable text.**
This is the single most underused move in image-heavy decks. The default reflex is to place image and text in adjacent rectangles. The far more powerful move — especially for content-rich pages — is to let the image **be the canvas** (often full-bleed) and draw native vector elements (annotation cards, flow nodes, KPI tiles, leader lines, network diagrams, dashboards) directly on top.
Anything that must be editable, numerically accurate, contain Chinese, or be styled to the deck's exact palette belongs in the SVG layer regardless of what the image looks like underneath.
---
# Part 1 — Primary Structures
Pick one or more of these as the page's bones. Cross-primary combinations are encouraged (see Composition Guidance).
## Container Layouts (where the image sits)
1. **Full-bleed background with floating title**`<image x=0 y=0 width=1280 height=720 preserveAspectRatio="xMidYMid slice"/>` + scrim `<rect>` for legibility + overlay `<text>`.
2. **Left-third image + right text body**`<image x=0 y=0 width=~427 height=720>` on the left; text area in the remaining width; optional right-edge gradient fade for smooth transition.
3. **Right-third image + left text body** — mirror of #2.
4. **Right image bleeding off the canvas edge**`<image>` width extended past viewBox; text on left with a rightward gradient fade so the image emerges from the text area without a visible boundary.
5. **Top-band image + bottom multi-column text**`<image x=0 y=0 width=1280 height=~340>` at the top + bottom-fade gradient + 23 evenly spaced text columns below.
6. **Bottom-band image + top title + middle text** — mirror of #5 with the image at the bottom and a top-fade gradient.
7. **Top-and-bottom symmetric split** — image occupies 50% (top or bottom) with a divider line or thin gradient band separating the halves.
8. **Z-pattern serpentine** — three rows, image on the left in rows 1 and 3, on the right in row 2 (or alternating). Each row roughly 1/3 canvas height; visual flow zigzags down the page.
9. **3×3 grid with central image** — nine cells; center cell holds the image, the other 8 hold text blocks, color swatches, or small data widgets.
10. **Centered image with radial callouts pointing outward** — image (often circular via `clipPath`) at canvas center; multiple `<line>` leader lines + small `<circle>` endpoints + offset text labels in surrounding space.
11. **Diagonal split with directional gradient (not hard polygon cut)** — full-bleed `<image>` (do NOT hard-clip) + overlay `<rect fill="url(#grad)">` whose `<linearGradient>` axis runs along the desired diagonal + a `<line>` on the diagonal to make the divider visible. The gradient does the "splitting" softly; hard polygon clipping produces ugly stair-step edges on text panels.
12. **Faded image as backdrop with oversized overlay text**`<image>` + heavy semi-transparent `<rect fill="bg-color" fill-opacity="0.50.7">` over it + huge `<text>` (80120px) on top. Image becomes texture; text is the subject.
13. **Narrow vertical image strip + giant horizontal title**`<image x=0 y=0 width=200280 height=720>` + thick divider `<rect>` + large `<text>` (6090px) in the remaining width.
14. **Horizontal banner strip cutting through mid-section**`<image y=middle width=1280 height=200280>` with edge fades; text blocks above and below the band.
15. **Multi-image montage with bold text spanning across** — multiple `<image>` tiled with 24px gaps + large `<text>` (60100px) in a darkened band spanning the full montage. The band uses `<rect fill-opacity="0.50.7">` to keep text legible across all underlying images.
16. **Negative-space dominant — small image, mostly whitespace** — image and text together occupy less than 40% of the canvas; rest is empty.
17. **Picture-in-picture inset** — large `<image>` background + small `<image>` overlaid inside it with a `<rect>` frame.
18. **Image as full-height sidebar column** — narrow `<image x=0 y=0 width=~200280 height=720>`; rest of canvas is content area.
19. **Image floating in whitespace with thin frame and caption**`<image>` + thin `<rect fill="none" stroke="…">` frame around it + `<text>` caption below.
## Image-as-Canvas + Native Overlay (the most underused family)
This is the family that opens up the largest design space and the one AI is most likely to skip. The shared pattern: image fills the slide (or a large region), native SVG elements are layered on top to carry the actual information. None of the overlay elements need to be generated by the image model — they are vector primitives you draw yourself.
38. **Background image + annotation cards with bezier leader lines** — full-bleed `<image>` + 24 small info cards (`<rect rx>` + icon + title + one-line text) placed in the image's calm regions. From each card, draw a bezier `<path>` ending in a `marker-end` arrow that points to the specific object in the image being annotated. Card text and leader lines are editable; image is the scene.
39. **Background image + flow nodes drawn over the scene** — the image is a real or rendered scene (workshop, control room, landscape). On top, draw a dashed `<path>` route that traces a workflow through the scene, with numbered `<circle>` nodes at each stop. Each node = number + icon + label. The flow is fully editable; the image is atmosphere.
40. **Background image + floating KPI metric cards** — full-bleed image (often an operations photo) + dark scrim + multiple `<rect>` cards in negative-space regions. Each card = icon + small label + large metric number. Image gives context; cards give the data.
41. **Background image + measurement lines and module tags (engineering overlay)** — used on technical / blueprint / cross-section images. Draw measurement lines with end-caps (`<line>` + perpendicular ticks) spanning a feature, with a centered label box reading dimensions or part names. Add tagged callouts with `<rect>` + monospace text. Reads as engineering drawing markup.
42. **Background image + glassmorphism UI panels** — image is the visual world; on top, draw UI elements (semi-transparent panels, progress arcs, status badges, indicators). Panels use `fill-opacity="0.60.8"` + thin light-color strokes; arcs via `<path d="…A…">`. Looks like a live dashboard floating above the scene.
43. **Background image + native data chart on top** — AI image generation cannot produce accurate data charts. Solution: use an AI-generated dashboard image as **visual reference only** (clearly labeled as such in a caption), and draw the actual chart with native SVG primitives (`<line>` axes, `<path>` series, `<circle>` data points) directly on or next to it. Required marker if exporting: `<!-- chart-plot-area: x_min,y_min,x_max,y_max -->` inside the chart group.
44. **Background image + native network/architecture diagram** — same logic as #43 but for structural diagrams. Image provides atmosphere or visual anchor; the actual nodes, connections, and labels are SVG circles, lines, icons, and text — all editable.
45. **Background image + numbered hotspots with sidebar legend** — small numbered `<circle>` markers placed on the image at points of interest. A sidebar (left or right) lists "1. … 2. … 3. …" with corresponding descriptions.
46. **Background image + bordered "lens" rectangle highlighting a sub-region** — full-bleed image + a bordered `<rect fill="none" stroke="accent" stroke-width="3"/>` framing a sub-region + caption nearby. Frame draws the eye to one detail without occluding the surrounding context.
## Multi-Image Compositions
47. **Small multiples — 36 same-kind images in an evenly spaced row** — each in identical container, each with identical caption block underneath (title + one-line description). This is **not** a generic grid: the identical framing is itself the message — readers compare across panels because the structure is the same. Useful for style comparisons, time-series snapshots, product variations.
48. **Side-by-side comparison (before/after, A/B, then/now)** — two `<image>` of equal size in 50/50 split with thin divider `<line>` and "before" / "after" labels.
49. **Asymmetric collage** — one large `<image>` + 23 smaller `<image>` arranged around it; sizes vary, gaps consistent.
50. **Tiled grid (2×2, 2×3, 3×3) with equal cells**`cell_size = (canvas - total_gap) / cols`; consistent `gap=220px`.
51. **Mosaic** — irregular tile sizes packed together with or without thin gaps; each image clipped to its tile's rect.
52. **Image strip / filmstrip** — horizontal sequence of `<image>` elements with thin gaps; same height, varying widths allowed.
53. **Vertical image stack** — column of `<image>` aligned by width, shared annotations on one side.
54. **Overlapping image stack**`<image>` elements with overlapping `x/y` positions; each subsequent one in front (z-order by document order); often combined with slight rotation for layered photo-print look.
55. **Diptych split — two images abutting at 50/50** — vertical or horizontal split with optional thin divider `<line>`.
56. **Image triptych** — three independent `<image>` side-by-side, equal widths or 2:1:2 etc. (distinct from #26 baked-in triptych, where the three scenes are inside one image file).
---
# Part 2 — Modifier Layers
Stack any of these freely on top of a Primary structure. Multiple Modifiers per page is the expected case, not the exception.
## Non-rectangular Image Shapes
20. **Circular crop**`<clipPath><circle cx cy r/></clipPath>` referenced by `<image clip-path="url(#id)"/>`.
21. **Rounded rectangle crop**`<clipPath><rect rx ry/></clipPath>`; the `rx` value controls roundness.
22. **Ellipse / oval crop**`<clipPath><ellipse cx cy rx ry/></clipPath>`.
23. **Hexagonal / polygonal crop**`<clipPath><polygon points="x1,y1 x2,y2 …"/></clipPath>`; remember to keep all vertices inside the image's display rectangle.
24. **Custom path crop (blob, arrow, leaf, silhouette)**`<clipPath><path d="…"/></clipPath>`; allows any curved or organic shape. PowerPoint export translates this to `custGeom` and survives roundtrip.
25. **Layered paper-cut stack** — multiple image or shape layers each with `clipPath` + a small `<feDropShadow>` offset to fake physical layering depth. Each layer casts a shadow onto the next, producing real-looking craft depth.
26. **Triptych baked into a single wide image** — one wide `<image width=1160 height=334>` whose internal composition already contains 23 scenes. Generate the triptych as one image (not three separate calls) when scene-to-scene consistency matters — the model preserves character identity, lighting continuity, and color grading far more reliably when panels are produced together.
## Overlay & Masking Treatments
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.
29. **Two-stop scrim — opaque on text side, transparent on focal side**`<linearGradient>` with one stop at `stop-opacity="0.9"` and another at `stop-opacity="0"`. Use when text sits on one side and the image's subject on the other.
30. **Flat semi-transparent rectangle overlay**`<rect fill="#000" fill-opacity="0.4"/>` over the image. Uniform darkening/lightening; simplest scrim.
31. **Color-tinted overlay**`<rect fill="#brandColor" fill-opacity="0.150.25"/>`. Pushes a foreign-looking image toward the deck's palette without regenerating it.
32. **Multi-stop scrim with hue shift** — three-or-more-stop `<linearGradient>` where stops are different colors (e.g. dark navy → transparent → warm orange). This re-grades the image's color world without regenerating — particularly useful when an AI image came back with the right composition but wrong color temperature.
33. **Spotlight mask — clear region surrounded by darkness** — cover the canvas with `<rect>` filled by a `<radialGradient>` whose inner stop is fully transparent and outer stop is opaque dark. Reads as a flashlight beam on the focal area. Use sparingly — it kills everything outside the spotlight.
34. **Gaussian-blur backdrop**`<filter><feGaussianBlur stdDeviation="815"/></filter>` applied to the background image, with sharp content layered on top unblurred. Reads as depth-of-field. Be aware that filters have inconsistent PPT export support — if fidelity matters, bake the blur into the source image instead.
35. **Duotone treatment** — two-color mapping of a photograph (e.g. deep navy shadows + warm cream highlights). Most reliable when baked into the source image at generation time. Runtime SVG duotone via `<feColorMatrix>` + `<feComponentTransfer>` is possible but the filter chain is fragile through PPT export — only attempt if you control the renderer.
36. **Drop shadow under image panel**`<filter><feDropShadow dx dy stdDeviation flood-color flood-opacity/></filter>` applied to the image's container `<rect>` (or to the `<image>` itself). Standard depth lift.
37. **Inner / outer glow on overlay shape**`<filter><feGaussianBlur/><feMerge/></filter>` on a shape, or simply a slightly larger blurred `<rect>` underneath the target.
## Image as Texture / Atmosphere
57. **Full-bleed image with extreme low opacity as texture wash** — full-bleed `<image>` + overlay `<rect fill="bg-color" fill-opacity="0.70.85"/>` so the image only barely shows through.
58. **Image fragment as decorative corner element** — small `<image>` (often with `clipPath`) placed in one corner; not the focus, just visual seasoning.
59. **Image as horizontal divider band** — narrow `<image height=80150>` placed between two text sections instead of a `<line>` divider.
60. **Image as ambient noise** — visible but low contrast; mood-setting only, not informational.
61. **Image as watermark behind body content** — large `<image>` at very low opacity behind body text. Use either a pre-baked low-alpha image or a high-opacity overlay `<rect>` to suppress visibility.
## Special Techniques
62. **Same image, two references — full view + zoom-callout** — reference the same image file twice in two `<image>` elements: one shows the full scene at normal size; the second uses `clipPath` (circle or rectangle) plus a larger display size to "zoom into" a sub-region. Connect them with a bezier `<path>` ending in `marker-end`; ring the zoom with a `<circle stroke>` so it reads as a magnifying lens. No special asset needed — the zoom effect comes from same-source-different-display.
63. **Transparent PNG sticker / cutout** — an RGBA PNG (with alpha channel) placed via standard `<image>` — no `clipPath` required, the transparency lives in the file itself. Useful for subjects that should not appear inside a rectangular frame (people cutouts, product shots, decorative motifs floating over backgrounds). Producing transparent PNGs is **not** a standard ppt-master pipeline step — three paths: (a) AI backend that supports transparent output natively, (b) generate a chroma-key (solid green background) image then strip the green with a separate tool, (c) user-supplied transparent asset. SVG-side usage is trivial; asset preparation is the work.
64. **Image with embedded text rendered by the AI** — text becomes part of the artwork: decorative lettering, designed title, hand-lettered keyword. Prompt with explicit text content — name the exact characters literally. Use for text that is part of the artwork and will not change. Anything that must be correct or editable goes in the SVG `<text>` layer (#65).
65. **Image with NO text — labels added as native SVG** — generate the image with explicit "no text, no letters, no numbers, no signs" instruction (`text_policy: none`), then place all labels as `<text>` overlays. The right call when labels will be reworded, must stay exact, or carry data that must stay editable — pair with `#64` when stable visual identifiers (axis labels, subplot letters, unit symbols) belong inside the image instead.
66. **Image fading into the solid background** — soften the image's edge into the deck's background color via a `<linearGradient>` overlay whose end-stop matches the background hex exactly. The image's rectangular boundary disappears, producing seamless integration.
67. **Image with knock-out / cut-out shape** — overlay a shape filled with the background color or another image, creating the impression of a hole punched through the underlying image.
68. **Text-as-mask over image** — letterforms revealing image through them. SVG-level `<mask>` is forbidden in this project (PPT export breaks). The only reliable way: bake this effect into the image at generation time by prompting for "large lettering revealing the underlying scene through letterforms." Treat as a pre-rendered artistic choice, not a runtime effect.
69. **Image rotated at a slight angle for editorial feel**`transform="rotate(angle cx cy)"` on the `<image>` or its container `<g>`; 26 degrees typical. Adds dynamism without breaking layout.
70. **Image with thin colored matte frame**`<rect fill="none" stroke="#color" stroke-width="26"/>` over or around the image edge. Single rule, single color.
71. **Image with multiple stacked frames for "photo print" aesthetic** — nested `<rect>` outlines or `<rect>` containers of slightly different sizes giving a "framed photograph" look.
72. **Image-to-image transition / merge** — two `<image>` elements with overlapping regions, one or both with gradient masks (from group C) creating a soft blend between them.
---
## Composition Guidance
A page is built by layering. Pick one or more **Primary Structures** (Part 1) as the page's bones, then add any number of **Modifier Layers** (Part 2) for finish. Both stack — the question on each page is "is the next layer still earning its place", not "have I exceeded a quota".
**Cross-primary combinations are encouraged.** A side-by-side comparison (#48) where each side is annotated with bezier-leader cards (#38) is one page, not a violation. A 3×3 grid (#9) whose center cell is upgraded to an image-as-canvas with KPI overlay (#40) reads as one composition. The old reflex "one primary per page" tends to under-use the catalog — combine when the page asks for it.
**Modifier stacking pattern that works in practice** — observed on real content pages combining one Primary with four Modifiers:
- one Primary from Part 1 (e.g. #48 side-by-side comparison)
- `#21` rounded-rectangle clipPath on the image (rx=6 or circle)
- `#27` top-edge linearGradient in the deck's accent color, opacity 0.55 → 0
- `#66` bottom-edge linearGradient fading to background color, opacity 0 → 0.95
- small color-block badge + reversed-out label replacing any opaque color bar that would otherwise sit over the image
Combine freely. The "AI-default" failure mode is the opposite: defaulting to bare #2 / #3 (left/right split) with no Modifier at all.
**Skip-detection signal** — if every page's `Layout pattern` column resolves to bare #2 / #3 / #5 / #6 with no Modifier ids, the catalog was not consulted. Re-read and reconsider.
## Hard Constraints
- Long body copy, data points, numeric labels, and Chinese text always go in the SVG layer — never baked into the image.
- `<clipPath>` on `<image>` and transparency encoding (`fill-opacity` / `stop-opacity`, never `rgba()`) — authoritative form in [`shared-standards.md`](shared-standards.md) §1.2 and §2; do not restate or relax here.
- No `<mask>`, no `<feComposite>` for alpha compositing. Alpha-effect routing (gradient overlays, clipPath crops, filter shadows, baked-in source image) is the table in [`shared-standards.md`](shared-standards.md) §1.0.
- `<feDropShadow>` / `<feGaussianBlur>` are accepted but PPT export is inconsistent — bake into the source image when fidelity is critical.
---
For sizing math (calculating container dimensions from image aspect ratio when using side-by-side intent), see [`image-layout-spec.md`](image-layout-spec.md). This file is the design vocabulary; that file is the dimension calculator.

View File

@ -0,0 +1,235 @@
> See shared-standards.md for common technical constraints.
# Image Layout Specification
Layout rules for pages where the image is placed **side-by-side with body text** as a container block. Strategist and Executor both follow these rules when the image's narrative intent is *side-by-side*.
**Core principle (side-by-side)**: compute container layout from the image's original aspect ratio so the image displays completely — no excess whitespace, no cropping.
> **Scope**: this spec applies to *side-by-side* intent only. Other intents (hero / full-bleed, atmosphere / background, accent / inline) use full-bleed placement where ratio alignment is not a constraint and cropping is expected — the ratio→split table below does NOT apply. See `references/strategist.md` §h for intent selection.
---
## Layout Decision Flow
```
1. Decide narrative intent (hero / atmosphere / side-by-side / accent) — see strategist.md §h
2. If intent = side-by-side: continue below. Otherwise: compose per narrative; this spec does not apply.
3. Get image original dimensions → Calculate ratio (width/height)
4. Select layout type based on ratio
5. Calculate maximum display size for the image
6. Allocate remaining space for text area
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.
---
## Layout Type Selection (side-by-side intent)
| Image Ratio | Layout Type | Image Position | Description |
|-------------|-------------|----------------|-------------|
| > 2.0 (ultra-wide) | Top-bottom split | Top full-width | Image spans canvas width, height proportional |
| 1.5-2.0 (wide) | Top-bottom split | Top | Image width = content area width, height proportional |
| 1.2-1.5 (standard) | Left-right split | Left | Image height-first fit, width proportional |
| 0.8-1.2 (square) | Left-right split | Left | Image takes content area height, width proportional |
| < 0.8 (portrait) | Left-right split | Left | Image height = content area height, width proportional |
> Boundary ratio (e.g., 1.5): decide by text volume — more text → left-right; less text → top-bottom.
---
## Dimension Calculation Formulas
### Canvas Parameters (All Formats)
| Format | Canvas | Margins (L/R, T/B) | Content Area (W x H) | Title Height | Content Start Y |
|--------|--------|--------------------|-----------------------|-------------|----------------|
| PPT 16:9 | 1280x720 | 60, 60 | 1160 x 600 | 60px | 80px |
| PPT 4:3 | 1024x768 | 50, 50 | 924 x 608 | 60px | 70px |
| Xiaohongshu | 1242x1660 | 60, 80 | 1122 x 1500 | 80px | 100px |
| WeChat Moments | 1080x1080 | 60, 60 | 960 x 960 | 60px | 80px |
| Story | 1080x1920 | 60, 120/180 | 960 x 1620 | 80px | 140px |
| WeChat Article | 900x383 | 40, 40 | 820 x 303 | 40px | 50px |
> Below, **W** = content area width, **H** = content area height (excludes title). PPT 16:9 example: W=1160, H=600.
### Top-Bottom Layout Calculation
```
Image width = W = 1160 px
Image height = W / R = 1160 / R px
Text area height = H - image height - gap(20px)
Validation: Text area height >= 150px (at least 3-4 lines of text)
If not satisfied → Switch to left-right layout
```
### Left-Right Layout Calculation
**Method 1 (height-first, suitable for portrait images)**:
```
Image height = H = 600 px
Image width = H x R = 600 x R px
Text area width = W - image width - gap(20px)
```
**Method 2 (width-constrained, for wide images converted to left-right)**:
```
Image width = W x 0.7 = 812 px
Image height = image width / R
Text area width = W - image width - gap(20px)
```
**Validation**: Text area width >= 280px; otherwise reduce image area width.
---
## Layout Examples
### Ultra-wide Image (ratio 2.45)
```
Original: 1960x800, R=2.45 → Top-bottom split
Image: 1160x473, Text area: 1160x147 → 7:3 top-bottom
```
### Standard Landscape (ratio 1.38)
```
Original: 1614x1171, R=1.38 → Left-right split
Image: 773x560 (left), Text area: 367x560 (right) → 7:3 left-right
```
### Wide Image Edge Case (ratio 1.75)
```
Original: 1820x1040, R=1.75
Try top-bottom: image height=663, text area=-43 ❌
Switch to left-right: image 780x446 (left), text area 360x600 (right) → 7:3 left-right
```
---
## Portrait Canvas Override
Default selection table assumes **landscape or square canvas**. For portrait canvases (height > width), left-right splits leave both columns too narrow — use the override below.
| Canvas Orientation | Image Ratio | Recommended Layout | Reason |
|-------------------|-------------|-------------------|--------|
| Portrait (Xiaohongshu, Story) | > 1.5 (wide) | Top-bottom | Same as landscape canvas |
| Portrait (Xiaohongshu, Story) | 1.2-1.5 (standard) | Top-bottom | Left-right too narrow on tall canvas |
| Portrait (Xiaohongshu, Story) | 0.8-1.2 (square) | Top-bottom | Image fits well in top half |
| Portrait (Xiaohongshu, Story) | 0.5-0.8 (portrait) | Left-right | Portrait image on tall canvas works |
| Portrait (Xiaohongshu, Story) | < 0.5 (extreme portrait) | Left-right | Image takes one side, text the other |
> Square canvases (WeChat Moments 1:1): use the standard landscape rules.
---
## Multi-Image Layout
For slides with multiple images, divide the content area evenly using the formulas below.
### Grid Formulas
```
columns = number of columns
rows = number of rows
gap = 20px (PPT formats) or 30px (social formats)
cell_width = (W - (columns - 1) * gap) / columns
cell_height = (H - (rows - 1) * gap) / rows
```
### Common Patterns
| Image Count | Layout | Grid | Description |
|-------------|--------|------|-------------|
| 2 (both landscape) | Side-by-side | 2x1 | Two equal columns |
| 2 (both portrait) | Stacked | 1x2 | Two equal rows |
| 2 (mixed) | 1 large + 1 small | Custom | Landscape top (full-width), portrait right-bottom |
| 3 | 1 large + 2 small | 1+2 | Left large (50% width), right column with 2 stacked |
| 4 | Grid | 2x2 | Equal-sized cells |
### Example: 2x2 Grid on PPT 16:9
```
W=1160, H=600, gap=20
cell_width = (1160 - 20) / 2 = 570
cell_height = (600 - 20) / 2 = 290
Image positions:
(60, 80) 570x290 (650, 80) 570x290
(60, 390) 570x290 (650, 390) 570x290
```
> Multi-image slides: use `preserveAspectRatio="xMidYMid meet"` on all images for consistent in-cell display.
---
## Prohibited Practices
| Prohibited | Correct Approach |
|-----------|-----------------|
| Fixed 50:50 or arbitrary ratios | Dynamic calculation based on image ratio |
| Forcing wide image into square container | Use top-bottom layout or increase image area width |
| Placing portrait image in narrow horizontal strip | Use left-right layout, image on left |
| Image whitespace exceeding 10% | Recalculate layout or choose alternative approach |
| Cropping key image content | Use `preserveAspectRatio="xMidYMid meet"` |
| Text area too small to read | Ensure text area >= 150px (top-bottom) or >= 280px (left-right) |
---
## Handoff Fields
This spec only defines layout calculation. Write computed fields into the Image Resource List defined in [`svg-image-embedding.md`](svg-image-embedding.md):
| Field | Meaning |
|-------|---------|
| `Ratio` | Original image width / height |
| `Layout plan` | Top-bottom / left-right / grid, including split ratio when relevant |
| `Image area` | Computed display rectangle size |
| `Text area` | Computed remaining text area size |
For SVG `<image>` syntax, path rules, `preserveAspectRatio`, external refs, and Base64 embedding: see [`svg-image-embedding.md`](svg-image-embedding.md).
### SVG Image Embedding Examples
Complete display (data charts, side-by-side — must not crop):
```xml
<image href="../images/xxx.png"
x="60" y="80" width="780" height="446"
preserveAspectRatio="xMidYMid meet"/>
```
Crop-to-fill (backgrounds and hero images only):
```xml
<image href="../images/bg.png"
x="0" y="0" width="1280" height="720"
preserveAspectRatio="xMidYMid slice"/>
```
---
## Automation Tool
```bash
python3 scripts/analyze_images.py <project_path>/images # Default: PPT 16:9
python3 scripts/analyze_images.py <project_path>/images --canvas ppt43 # PPT 4:3
python3 scripts/analyze_images.py <project_path>/images --canvas xiaohongshu # Xiaohongshu
```
`--canvas` selects target format (default `ppt169`). The tool computes layout type (top-bottom / left-right), image display area, and text area per the formulas above. Output is a Markdown table — paste directly into the image resource list.
---
## Role Responsibilities
| Role | Responsibility |
|------|---------------|
| **Strategist** | Run analyze_images.py, calculate layout per this spec, populate image resource list |
| **Executor** | Strictly follow the layout plan and dimensions in the image resource list when generating SVGs |

View File

@ -1,532 +0,0 @@
# 版式库 (16:9, 13.33×7.5 in) — 卡片式视觉系统
> **要点**:版式 helper 全在 `scripts/pptx_helpers.py`,**不要把 helper 源码默写进 build_deck.py** —— 只 `import pptx_helpers as P` 然后调用。配色用 current spec(命名见 SKILL.md §阶段一)里的实际 hex,通过 `P.set_palette(spec_path=...)` 注入,默认商务红 + 自动派生明暗色阶。
>
> **观感升级要点(相对老版"左色条 + 圆点 bullet")**:内容尽量装进**圆角卡片**(`add_card`,自带柔和投影),业务概念配**图标底块**(`add_icon_tile`),数据页优先**KPI 数字卡**(`add_kpi`)而非小柱图,封面/章节用**渐变大色块**(`apply_brand` 已内置)。白底之上靠卡片浮起 + 浅色阶分层,才不是"扁平办公模板"。
## 通用起手(整 deck 单脚本 — 默认路径)
阶段二写一个 `build_deck.py`,一个进程内建完整份 deck、末尾 `save` 一次(**不逐页 run_python**)。每页一个小函数,主流程按逐页大纲依次调用:
```python
import sys
sys.path.insert(0, "<skill_dir>/scripts") # <skill_dir> 用 system prompt 注入的绝对路径替换
import pptx_helpers as P
from pptx.enum.text import MSO_ANCHOR, PP_ALIGN
from pptx.enum.shapes import MSO_SHAPE
SPEC = "<task_dir>/<today>-<task_short_id>-<task_name>.spec.md"
OUT = "<task_dir>/<topic>.pptx"
ICONS = "<task_dir>/assets/icons" # fetch_icon.py 拉到这;种子库在 <skill_dir>/assets/icons
def page_1_cover(prs):
s = P.add_slide(prs)
P.apply_brand(s, "cover")
# ... 见 L1 封面 ...
def page_2(prs):
s = P.add_slide(prs)
# ... 见对应 Lx 版式 ...
def main():
prs = P.new_presentation("16:9") # 默认 16:9;可传 "4:3" / "9:16" / "3:4"
P.set_palette(spec_path=SPEC) # 整 deck 设一次配色 + 派生色阶(同进程常驻)
for build in (page_1_cover, page_2, ...): # 按逐页大纲顺序
build(prs)
prs.save(OUT)
main()
```
跑法:先 `write` 脚本到 `<task_dir>/build_deck.py`,再 `run_python(script_path=...)`。要改(quality_check 报错 / 用户要调)→ 改对应 `page_x` 函数重跑整脚本(可复现,不 edit 成品 .pptx)。
> **风格探针 / 增量补页**:要先看封面 + 1 页观感,把 `main()` 循环临时缩到前 2 个函数跑一遍;或对已存在 deck 追加单页时 `prs = P.load(OUT)``add_slide`。**常规整建不用 `load`**。
⚠️ 一律用 `P.xxx`(不要 `from pptx_helpers import *`)—— `set_palette` 靠改模块属性覆盖配色,`import *` 会把旧绑定拷进命名空间导致覆盖不生效。
---
## Helper API 速查 (都在 `P.` 命名空间下)
**画布 / 配色入口**
- `P.new_presentation(canvas="16:9")` → 建空 deck,设画布,回填 `P.SLIDE_W/H` 与安全区
- `P.load(path)` → 载入已有 deck,按文件实际尺寸回填画布常量
- `P.add_slide(prs)` → 追加空白 slide
- `P.set_palette(primary=, secondary=, accent=, cn_font=, en_font=, spec_path=)` → 覆盖主题色/字体并**重算派生色阶**;传 `spec_path` 自动取 spec 前 3 个 #hex;默认商务红
**颜色常量**:`P.PRIMARY` `P.SECONDARY` `P.ACCENT` `P.INK` `P.GREY` `P.GREY_LIGHT` `P.HAIRLINE` `P.BG` `P.WHITE`
**派生色阶**(从主/辅/强调自动算):`P.PRIMARY_WASH`(整页/大区域浅底) `P.PRIMARY_SOFT`(卡片/标签浅底) `P.PRIMARY_DARK`(渐变深端) `P.ACCENT_SOFT`(高亮浅底) `P.SURFACE`(卡片白面)
**字体常量**:`P.CN_FONT`(微软雅黑) `P.EN_FONT`(Arial)
**画布常量**:`P.SLIDE_W` `P.SLIDE_H` `P.SAFE_LEFT/TOP/RIGHT/BOTTOM` `P.SAFE_W` `P.SAFE_H`
**色阶工具**:`P.tint(color, pct)` 提亮 / `P.shade(color, pct)` 压暗(自定义中间色用)
**🔥 组合版式件**(一个函数摆一整块 —— 优先用这些,别手摆参差网格/拿卡片硬凑时间线)
- `P.add_card_grid(slide, items, top, height, cols=None, icon_dir=None, accent=None)`**均衡概念网格**;items=每项 `{icon,title,body}`;自动均衡行列(2×2/2×3,不参差),单行图标顶置、多行图标左置;`icon_dir` 给图标目录(图标名去 `tabler_` 前缀)
- `P.add_timeline(slide, nodes, y=3.2)`**横向时间轴**;nodes=`{year,title,body}`;发展历程/路线图用,别塞卡片网格
- `P.add_cycle(slide, steps, cy=4.5, radius=1.55, center_label=)`**流程闭环**(节点沿环+中心词);循环类用。⚠️文字多时改用横向流程(L12)更稳
- `P.add_toc(slide, items, top=2.2)`**目录**(序号+标题+右副标+发丝线,贯通整宽);items=`(title, caption)`
- `P.add_kpi(slide, l, t, w, h, value, label, baseline=, delta=, delta_dir=)`**KPI 数字卡**;`baseline`=对比基准、`delta`=趋势(升绿降红);**数字别孤立**
- `P.add_takeaway(slide, "<一句话结论>", top=None)`**结论框**(浅主色底+左条);内容页论断标题下标配
- `P.add_source(slide, "<来源>")` → 数据来源(右下角弱化);含数据的页必标
- `P.add_picture_bg(slide, png)` → 整页铺渲染好的高清背景图(混合方案:背景图+原生可编辑文字)
**容器 / 质感**(卡片式核心)
- `P.add_card(slide, l, t, w, h, fill=SURFACE, radius=0.12, shadow=False, border=None, accent=None)` → 圆角卡片。**默认平卡**(白底描发丝边);**投影是克制**:平铺对等卡一律平,`shadow=True` 只给真悬浮/被挑出的卡,每页 ≤2-3 个;**一容器一手段**(投影/描边/底色/accent 四选一不叠)。见 design_principles §视觉深度
- `P.add_round_rect(slide, l, t, w, h, fill, radius=0.10)` → 无投影圆角矩形
- `P.add_gradient_rect(slide, l, t, w, h, c1, c2, angle=90, rounded=False)` → 渐变块(封面/章节大色块;原生可编辑非图片)
- `P.set_shadow(shape, ...)` / `P.set_line(shape, color, weight)` → 手动投影 / 描边
- `P.add_bg(slide, color=BG)` → 整页背景(`apply_brand` 已内置)
- 语义色:`P.GOOD`(增长绿)/ `P.BAD`(下降红)—— KPI 趋势用,不计三色制
**组件**
- `P.add_icon_tile(slide, x, y, size=0.9, png_path=None, fill=PRIMARY_SOFT)` → 图标圆角底块 + 居中图标
- `P.add_icon(slide, png_path, x, y, size=0.6)` → 裸图标 PNG(方形源等比)
- `P.add_pill(slide, x, y, w, h, text, fill=PRIMARY, fg=WHITE, size=12)` → 胶囊标签 / chip
- `P.add_eyebrow(slide, x, y, text, color=PRIMARY, size=13)` → 标题上方小标签 / kicker
- `P.add_badge(slide, x, y, num, diameter=0.7)` → 编号徽章(圆+数字)
- `P.add_chevron(slide, x, y, w=0.55, h=0.5, color=GREY_LIGHT)` → 流程箭头
- `P.add_dot(slide, x, y, size=0.18, color=ACCENT)` → 圆点(bullet 前缀)
- `P.add_accent_line(slide, x, y, length=1.0, thickness=0.05)` → 强调短线
- `P.add_divider(slide, x, y, length, vertical=False)` → 细分隔线
**文本 / 标题 / 品牌 / 备注**
- `P.add_textbox(slide, l, t, w, h, text, size, bold=False, color=INK, align=, anchor=, font=None, shrink=True, name=)` → 文本框;`font=None` 自动 latin=Arial + 东亚=微软雅黑(**中文真落雅黑靠这个**),传 `font` 则两槽都用它(纯英文大字/数字)
- `P.page_title(slide, text, page_num=None, total=None, footer=, eyebrow=None)` → 内页标题+强调线(+可选 eyebrow / 页脚页码)
- `P.apply_brand(slide, kind)` → 品牌锚点,`kind` ∈ `"cover"/"inner"/"section"/"end"`;**每页第一行必调**(已含整页背景)
- `P.add_notes(slide, text)` → 演讲者备注(正式产物每页给 2-4 句口述要点)
- `P.assert_inside(l, t, w, h, name="")` → 手动越界校验(放置 helper 已内置)
---
## 🔥 组合件示例 (优先用 —— 一个函数一整块)
### 内容页范式:论断标题 + Takeaway + 均衡网格
> 内容页的"黄金结构"(咨询级):**论断式标题**(写结论)→ **Takeaway 一句话**(浅底框)→ 内容。把它做成本地小函数 `content_header`
```python
from pptx.enum.text import MSO_ANCHOR
def content_header(s, title, takeaway, eyebrow=None):
ty = P.SAFE_TOP
if eyebrow:
P.add_eyebrow(s, P.SAFE_LEFT, ty, eyebrow); ty += 0.4
P.add_textbox(s, P.SAFE_LEFT, ty, P.SAFE_W, 0.7, title, 28, bold=True,
color=P.PRIMARY, name="title") # 论断标题
if takeaway:
P.add_takeaway(s, takeaway, top=ty + 0.82) # 结论框
s = P.add_slide(prs); P.apply_brand(s, "inner")
content_header(s, "大模型靠规模涌现出通用智能",
"参数突破千亿临界点后,模型从'专用工具'跃升为'通用大脑'",
eyebrow="DEFINITION")
items = [ # 每项 icon 名 + 标题 + 精炼正文(≤18 字)
{"icon": "brain", "title": "超大参数", "body": "千亿参数突破临界点,涌现推理力"},
{"icon": "cpu", "title": "对话生成", "body": "多轮对话、写代码、摘要改写"},
{"icon": "cloud-network", "title": "多模态", "body": "文本+图像+音频+视频统一理解"},
{"icon": "target", "title": "任务规划", "body": "高级推理与链式拆解"},
{"icon": "bolt", "title": "持续成长", "body": "RLHF、RAG、微调持续打磨"},
]
P.add_card_grid(s, items, top=2.35, height=4.5, icon_dir=ICONS) # 平卡,自动均衡
```
### 时间轴(发展历程 / 路线图)
```python
content_header(s, "六年从 GPT-1 到推理模型,能力指数跃迁",
"每一代都在重定义能力边界", eyebrow="TIMELINE")
P.add_timeline(s, [
{"year": "2018", "title": "GPT-1", "body": "预训练范式确立"},
{"year": "2020", "title": "GPT-3", "body": "1750 亿参数,few-shot 涌现"},
{"year": "2022", "title": "ChatGPT", "body": "对话式 AI 引爆全民应用"},
{"year": "2023", "title": "GPT-4", "body": "多模态 + 强推理"},
], y=3.9)
P.add_source(s, "OpenAI / 各厂商公开发布")
```
### KPI 数字卡(数据语境化:对比基准 + 升降)
```python
data = [("158%", "实验吞吐同比", "行业均值 90%", "+68pt", "up"),
("27天", "配方迭代周期", "去年 45 天", "-40%", "up"),
("92.3%", "中试一次通过率", "行业 81%", "+11pt", "up")]
n, gap = len(data), 0.3; cw = (P.SAFE_W - gap*(n-1))/n
for i,(v,lab,base,delta,d) in enumerate(data):
P.add_kpi(s, P.SAFE_LEFT+i*(cw+gap), 2.6, cw, 2.7, v, lab,
baseline=base, delta=delta, delta_dir=d)
```
### breathing 大字页(打破卡片单调 —— 每隔 2-3 页插一个)
```python
s = P.add_slide(prs); P.apply_brand(s, "inner")
P.add_eyebrow(s, P.SAFE_LEFT, 1.5, "THE INFLECTION POINT")
P.add_textbox(s, P.SAFE_LEFT, 2.15, 9.0, 2.5, "2 个月", 150, bold=True,
color=P.PRIMARY, font=P.EN_FONT, shrink=False, name="big_stat")
P.add_textbox(s, P.SAFE_LEFT, 4.7, 11, 0.7, "ChatGPT 月活突破 1 亿", 30,
bold=True, color=P.INK, name="big_label")
P.add_textbox(s, P.SAFE_LEFT, 5.6, 11, 0.6,
"史上最快 —— 此前纪录是 TikTok 的 9 个月", 18, color=P.GREY,
name="big_ctx") # 数据语境化:大数字必带对比
```
### 目录(贯通整宽)
```python
P.page_title(s, "目录", eyebrow="AGENDA")
P.add_toc(s, [("什么是大模型", "规模、能力与边界"),
("发展历程", "六年能力跃迁"),
("AI 智能体", "从对话到自主行动")], top=2.25)
```
### 混合背景封面(杂志级,opt-in)
```python
# 先 run_python: python render_bg.py --out <task_dir>/figures/cover_bg.png --kind cover --primary C00000
s = P.add_slide(prs)
P.add_picture_bg(s, "<task_dir>/figures/cover_bg.png") # 背景图(不可编辑)
P.add_eyebrow(s, 0.95, 1.95, "TECHNOLOGY INSIGHT · 2026", color=P.ACCENT)
P.add_textbox(s, 0.95, 2.45, 8.0, 1.7, "主标题\n副标题行", 44, bold=True,
color=P.WHITE, name="cover_title") # 白字叠背景(可编辑)
```
> 下面 L1-L13 是更细的手摆版式参考;**业务概念/数据/历程/循环优先用上面的组合件**,手摆只在组合件不覆盖时用。
> ⚠️ **给每个元素起语义 `name`**(`"bullet_1"`/`"kpi_val"`/`"eyebrow"`/`"pill"` 等)。quality_check 靠 name 判定"哪些是标签(小字号豁免)、哪些是真 bullet(计 ≤5)、谁压了谁",名字乱起会误报。helper 默认名已合理,自己加文本时照着命名。
> `MSO_SHAPE` / `PP_ALIGN` / `MSO_ANCHOR` 页面里要直接用就自行 import(`pptx_helpers` 内部已 import 但不重导出)。
---
## L1 · 封面 (Cover) —— 渐变大色块 + 左侧标题区
```python
s = P.add_slide(prs)
P.apply_brand(s, "cover") # 右侧 40% 主色→深主色渐变块 + 左上强调短线 + 底细线
# 左侧标题区(避开右侧渐变块,文字区约 7.4 寸宽)
P.add_eyebrow(s, 0.9, 2.0, "2026 年度技术汇报") # kicker 小标签
P.add_textbox(s, 0.9, 2.5, 7.2, 1.6, "项目名称 / 演示主题",
42, bold=True, color=P.INK, name="cover_title")
P.add_textbox(s, 0.9, 4.4, 7.0, 0.6, "一句话副标题或定位",
20, color=P.GREY, name="cover_sub")
P.add_textbox(s, 0.9, 6.4, 7.0, 0.4, "汇报人 · 部门 · 2026-06-08",
14, color=P.GREY_LIGHT, name="cover_meta")
P.add_notes(s, "开场白:点出主题与本次汇报要解决的核心问题。")
```
> 有合适主图时(见 SKILL.md §配图),可把右侧渐变块换成**真实图片**:`s.shapes.add_picture(hero, Inches(P.SLIDE_W*0.6), Inches(0), height=Inches(7.5))`,再在图上叠半透明主色块保证文字区干净。
---
## L2 · 目录 (Agenda) —— 编号徽章 + 文字
```python
from pptx.enum.text import MSO_ANCHOR
s = P.add_slide(prs)
P.apply_brand(s, "inner")
P.page_title(s, "目录")
items = ["背景与现状", "核心问题", "解决方案", "实施计划", "预期成果"]
for i, item in enumerate(items):
y = 1.9 + i * 0.95
P.add_badge(s, P.SAFE_LEFT, y, i + 1, diameter=0.65)
P.add_textbox(s, P.SAFE_LEFT + 1.0, y, P.SAFE_W - 1.0, 0.65, item, 22,
color=P.INK, anchor=MSO_ANCHOR.MIDDLE, name=f"agenda_{i}")
```
---
## L3 · 章节分隔 (Section Divider) —— 渐变整页 + 大字编号(白字)
```python
from pptx.enum.text import MSO_ANCHOR
s = P.add_slide(prs)
P.apply_brand(s, "section") # 主色→深主色整页渐变 + 强调装饰条
# 大编号(白色;font=EN_FONT 让数字走 Arial)
P.add_textbox(s, 1.1, 2.0, 4, 2.5, "01", 150, bold=True, color=P.WHITE,
font=P.EN_FONT, name="sec_num")
# 章节名(白色)
P.add_textbox(s, 5.3, 2.8, 7, 1.0, "背景与现状", 44, bold=True,
color=P.WHITE, anchor=MSO_ANCHOR.MIDDLE, name="sec_title")
# 引言(强调浅色,渐变深底上可读)
P.add_textbox(s, 5.3, 4.0, 7, 0.6, "本章讨论行业现状与机会窗口", 18,
color=P.ACCENT_SOFT, name="sec_lead")
```
> 渐变深底上文字一律用 **白 / `ACCENT_SOFT`** 等浅色,不要用 `INK` 深灰(看不清)。
---
## L4 · 要点 (Bullets) —— 圆点 + 文字;≥3 条建议升级成卡片(见 L11)
```python
from pptx.enum.text import MSO_ANCHOR
s = P.add_slide(prs)
P.apply_brand(s, "inner")
P.page_title(s, "核心结论")
bullets = [
"结论一:用一句话讲清楚",
"结论二:具体数据支撑,如增长 27%",
"结论三:对未来的判断,简洁有力",
"结论四:可选第四条,不要超过 5 条",
]
for i, b in enumerate(bullets):
y = 2.0 + i * 0.95
P.add_dot(s, P.SAFE_LEFT + 0.05, y + 0.22, size=0.18)
P.add_textbox(s, P.SAFE_LEFT + 0.45, y, P.SAFE_W - 0.45, 0.6, b, 22,
color=P.INK, anchor=MSO_ANCHOR.MIDDLE, name=f"bullet_{i}")
```
> 纯圆点 bullet 偏单薄。**业务概念类要点(能力/模块/策略)优先用 L11 卡片网格 + 图标底块**,视觉重量足。
---
## L5 · 双栏对比 (Two-Column) —— 两张卡片,左中右灰
```python
from pptx.enum.text import PP_ALIGN, MSO_ANCHOR
s = P.add_slide(prs)
P.apply_brand(s, "inner")
P.page_title(s, "现状 vs 改进后")
cw = (P.SAFE_W - 0.5) / 2 # 两卡 + 中间 0.5 间隙
ly, lh = 2.0, 4.5
# 左卡:现状(中性灰底,弱化)
P.add_card(s, P.SAFE_LEFT, ly, cw, lh, fill=P.BG, border=True, shadow=False)
P.add_pill(s, P.SAFE_LEFT + 0.35, ly + 0.35, 1.1, 0.36, "现状", fill=P.GREY)
left_pts = ["问题 A:描述", "问题 B:描述", "问题 C:描述"]
for i, p in enumerate(left_pts):
yy = ly + 1.1 + i * 0.7
P.add_dot(s, P.SAFE_LEFT + 0.4, yy + 0.16, color=P.GREY)
P.add_textbox(s, P.SAFE_LEFT + 0.8, yy, cw - 1.1, 0.55, p, 17,
color=P.INK, anchor=MSO_ANCHOR.MIDDLE, name=f"l_pt_{i}")
# 右卡:改进后(主色强调条 + 浅底,突出)
rx = P.SAFE_LEFT + cw + 0.5
P.add_card(s, rx, ly, cw, lh, fill=P.SURFACE, accent=P.PRIMARY)
P.add_pill(s, rx + 0.5, ly + 0.35, 1.3, 0.36, "改进后", fill=P.PRIMARY)
right_pts = ["改善 A:描述", "改善 B:描述", "改善 C:描述"]
for i, p in enumerate(right_pts):
yy = ly + 1.1 + i * 0.7
P.add_dot(s, rx + 0.55, yy + 0.16, color=P.ACCENT)
P.add_textbox(s, rx + 0.95, yy, cw - 1.3, 0.55, p, 17, color=P.INK,
anchor=MSO_ANCHOR.MIDDLE, name=f"r_pt_{i}")
```
---
## L6 · 图表为主 (Chart-focus) —— 标题 + 一句结论 + 大图嵌卡片
```python
from pptx.util import Inches
from pptx.enum.text import PP_ALIGN
# chart.png 已用 matplotlib 生成(见 design_principles.md §7)
s = P.add_slide(prs)
P.apply_brand(s, "inner")
P.page_title(s, "季度营收持续增长")
P.add_textbox(s, P.SAFE_LEFT, P.SAFE_TOP + 1.1, P.SAFE_W, 0.5,
"Q4 同比增长 158%,创历史新高", 18, color=P.GREY, name="lead")
# 图表衬一张白卡片(浮起,比裸图精致)
P.add_card(s, 2.0, 2.4, 9.3, 4.3, fill=P.SURFACE)
s.shapes.add_picture("<task_dir>/slides/chart.png", Inches(2.4),
Inches(2.7), width=Inches(8.5))
P.add_textbox(s, P.SAFE_LEFT, 6.95, P.SAFE_W, 0.4, "数据来源:公司年报 2025",
11, color=P.GREY_LIGHT, align=PP_ALIGN.RIGHT, shrink=False,
name="source")
```
---
## L7 · 图片为主 (Image-focus) —— 图占 58%,文字独立区
```python
from pptx.util import Inches
from pptx.enum.shapes import MSO_SHAPE
s = P.add_slide(prs)
P.add_bg(s, P.WHITE)
# 左侧图(只给 height 等比铺满,避免变形)
s.shapes.add_picture("<task_dir>/slides/hero.jpg", Inches(0), Inches(0),
height=Inches(7.5))
# 右侧浅底文字区
P.add_rect(s, 7.7, 0, 5.63, 7.5, P.PRIMARY_WASH, "text_panel")
P.add_eyebrow(s, 8.1, 1.4, "PRODUCT")
P.add_textbox(s, 8.1, 1.9, 4.9, 1.0, "走进未来", 36, bold=True, color=P.INK,
name="img_title")
P.add_accent_line(s, 8.1, 3.0, length=0.6)
P.add_textbox(s, 8.1, 3.4, 4.9, 1.6, "用一两句话点出主旨,不要把演讲稿搬上来。",
18, color=P.GREY, name="img_caption")
P.add_shape(s, MSO_SHAPE.RIGHT_ARROW, 8.1, 6.4, 0.7, 0.35, P.ACCENT, "img_cta")
```
---
## L8 · 金句 / 大字 (Quote) —— 留白主导
```python
from pptx.enum.text import MSO_ANCHOR
s = P.add_slide(prs)
P.apply_brand(s, "inner")
P.add_textbox(s, 0.8, 0.6, 1.5, 1.5, '"', 200, bold=True, color=P.ACCENT,
font=P.EN_FONT, shrink=False, name="quote_mark")
P.add_textbox(s, 1.5, 2.7, 10.5, 2.0, "把复杂留给我们,把简单留给用户。", 36,
bold=True, color=P.INK, anchor=MSO_ANCHOR.MIDDLE, name="quote_text")
P.add_accent_line(s, 1.5, 5.0, length=0.5)
P.add_textbox(s, 1.5, 5.2, 10.5, 0.5, "—— 公司价值观 2025", 16, color=P.GREY,
name="quote_attr")
```
---
## L9 · 结尾 / Q&A —— 浅底 + 大字,**强制必有**
> **不是可选** —— 任何 deck 都必须以这页收尾。
```python
from pptx.enum.text import PP_ALIGN
s = P.add_slide(prs)
P.apply_brand(s, "end") # PRIMARY_WASH 浅底 + 顶/底强调短线
P.add_textbox(s, 0, 2.5, P.SLIDE_W, 1.6, "Thank You", 80, bold=True,
color=P.PRIMARY, align=PP_ALIGN.CENTER, font=P.EN_FONT,
name="thanks")
P.add_textbox(s, 0, 4.3, P.SLIDE_W, 0.6, "欢迎提问与讨论", 22, color=P.INK,
align=PP_ALIGN.CENTER, name="qa")
P.add_textbox(s, 0, 6.2, P.SLIDE_W, 0.5, "联系方式 / 邮箱 / 公众号", 14,
color=P.GREY_LIGHT, align=PP_ALIGN.CENTER, name="contact")
```
---
## L10 · KPI 数字卡 (Metrics) —— 2-4 张并排,数据页主力
> 数据页**优先用这个**,不要为 2-4 个数字硬画柱状图。大数字 + 标签 + 同比小注,信息密度与质感俱佳。
```python
s = P.add_slide(prs)
P.apply_brand(s, "inner")
P.page_title(s, "平台运行关键指标", eyebrow="运行数据 / 2025")
data = [("158%", "实验吞吐同比", "↑ 较去年"),
("27天", "配方迭代周期", "↓ 缩短 40%"),
("92.3%", "中试一次通过率", "↑ +11pt"),
("4.2万", "累计实验记录", "条")]
n = len(data)
gap = 0.3
cw = (P.SAFE_W - gap * (n - 1)) / n
for i, (v, lab, sub) in enumerate(data):
P.add_kpi(s, P.SAFE_LEFT + i * (cw + gap), 2.6, cw, 2.7, v, lab, sub=sub)
```
> 想突出某张卡:传 `value_color=P.ACCENT` 或给那张卡 `add_card(..., accent=P.ACCENT)``add_kpi(..., card=False)` 叠上。
---
## L11 · 卡片网格 (Card Grid) —— 图标底块 + 标题 + 说明,业务概念主力
> 能力 / 模块 / 策略 / 价值点这类**业务概念**用它,替代单薄的圆点 bullet。2-4 列均可;图标走 `add_icon_tile`(图标先按 SKILL.md §阶段二第 2 步批量 `fetch_icon.py` 拉到 `<task_dir>/assets/icons`)。
```python
import os
s = P.add_slide(prs)
P.apply_brand(s, "inner")
P.page_title(s, "三大核心能力")
items = [("target", "数据底座", "统一实验/表征/工艺数据湖,一处录入处处可用"),
("cpu", "智能配方", "贝叶斯优化叠加机理约束,迭代更快更稳"),
("chart-bar", "中试放大", "小试到中试参数迁移模型,放大不失真")]
n = len(items)
gap = 0.35
cw = (P.SAFE_W - gap * (n - 1)) / n
for i, (icon, h, body) in enumerate(items):
x = P.SAFE_LEFT + i * (cw + gap)
P.add_card(s, x, 2.3, cw, 3.6, accent=P.PRIMARY)
png = os.path.join(ICONS, f"tabler_{icon}_C00000_128.png") # 主色染色后的图标
P.add_icon_tile(s, x + 0.4, 2.7, 0.95, png_path=png)
P.add_textbox(s, x + 0.4, 3.85, cw - 0.8, 0.5, h, 20, bold=True,
color=P.INK, name=f"card_h_{i}")
P.add_textbox(s, x + 0.4, 4.45, cw - 0.8, 1.1, body, 15, color=P.GREY,
name=f"card_b_{i}")
```
---
## L12 · 流程 / 步骤 (Process) —— 卡片 + chevron 箭头串联
```python
s = P.add_slide(prs)
P.apply_brand(s, "inner")
P.page_title(s, "实施四步走", eyebrow="路线图")
steps = [("01", "调研", "梳理现状与痛点"),
("02", "建模", "搭数据底座与模型"),
("03", "试点", "单产线小批验证"),
("04", "推广", "全厂复制与运维")]
n = len(steps)
arrow_w = 0.5
cw = (P.SAFE_W - arrow_w * (n - 1) - 0.2 * (n - 1)) / n
y, h = 2.8, 2.6
for i, (num, title, body) in enumerate(steps):
x = P.SAFE_LEFT + i * (cw + arrow_w + 0.2)
P.add_card(s, x, y, cw, h, fill=P.SURFACE)
P.add_textbox(s, x + 0.3, y + 0.3, cw - 0.6, 0.7, num, 34, bold=True,
color=P.PRIMARY, font=P.EN_FONT, name=f"step_num_{i}")
P.add_textbox(s, x + 0.3, y + 1.1, cw - 0.6, 0.5, title, 19, bold=True,
color=P.INK, name=f"step_t_{i}")
P.add_textbox(s, x + 0.3, y + 1.65, cw - 0.6, 0.8, body, 14, color=P.GREY,
name=f"step_b_{i}")
if i < n - 1:
P.add_chevron(s, x + cw + 0.1, y + h / 2 - 0.25, arrow_w, 0.5)
```
---
## L13 · 大数字 + 论据 (Stat Highlight) —— 单个震撼数字撑半屏
> 一个核心数字要砸出冲击力时用。左侧超大数字,右侧三两条支撑论据卡。
```python
from pptx.enum.text import MSO_ANCHOR
s = P.add_slide(prs)
P.apply_brand(s, "inner")
P.page_title(s, "一年走完三年的路", eyebrow="成效")
# 左:超大数字(主色)
P.add_textbox(s, P.SAFE_LEFT, 2.4, 5.2, 2.4, "3.6×", 140, bold=True,
color=P.PRIMARY, font=P.EN_FONT, anchor=MSO_ANCHOR.MIDDLE,
name="big_stat")
P.add_textbox(s, P.SAFE_LEFT, 4.9, 5.2, 0.5, "研发效率提升", 20, color=P.INK,
name="big_stat_label")
# 右:支撑论据(浅底小卡堆叠)
facts = ["实验自动排程,人力释放 60%", "失败配方提前预警,返工 ↓45%", "知识沉淀复用,新人上手周期减半"]
for i, f in enumerate(facts):
yy = 2.5 + i * 1.25
P.add_card(s, 6.6, yy, 6.0, 1.05, fill=P.PRIMARY_WASH, shadow=False)
P.add_dot(s, 6.95, yy + 0.45, color=P.PRIMARY)
P.add_textbox(s, 7.35, yy, 5.0, 1.05, f, 16, color=P.INK,
anchor=MSO_ANCHOR.MIDDLE, name=f"fact_{i}")
```
---
## 选版式速查
```
封面 → L1 (Cover)
目录 → L2 (Agenda)
转场 / 换章 → L3 (Section Divider)
要点 ≤ 5 条(纯文字) → L4 (Bullets)
对比类 (前/后, A/B) → L5 (Two-Column)
有数据图表 → L6 (Chart-focus)
有大图 / 视觉优先 → L7 (Image-focus)
观点强调 / 名言 → L8 (Quote)
末页 → L9 (Q&A) [强制]
2-4 个关键数字 → L10 (KPI 数字卡) ← 优先于硬画柱图
业务概念(能力/模块) → L11 (卡片网格 + 图标) ← 优先于圆点 bullet
流程 / 步骤 → L12 (Process)
单个震撼数字 → L13 (Stat Highlight)
```
## 三个常犯的越界场景
1. **bullet 字数超额** —— 22pt 在 11.5 寸宽下每行约 50 个中文字,超 1 行就溢出 0.6 in 框。根本解法是**字数压缩**(见 design_principles.md §字数预算),不要靠 `auto_size` 收字号兜底。
2. **卡片内容超出卡片** —— 卡片内文字按 `卡宽 - 2×0.4` 内边距算框宽;标题/正文字数超了会顶出卡片下边缘。卡片高度留够(KPI 卡 ≥2.5,概念卡 ≥3.4)。
3. **图片不等比拉伸** —— `add_picture(width=, height=)` 同时给会变形;**只给 width 或 height 一项**。
4. **渐变深底上用深色字** —— L3 章节页 / cover 渐变块上的文字必须 `WHITE` / `ACCENT_SOFT`,用 `INK` 看不清。
```

View File

@ -0,0 +1,73 @@
# Modes — Index
A **mode** is the deck's **narrative + persuasion skeleton** — how the argument is organized and advanced across pages. Lock **one mode per deck**; it shapes page sequencing, title voice, page-structure tendencies, and speaker-notes register.
> A mode is *not* a visual style. **Mode = how you argue; visual style = how it looks** (see [`visual-styles/_index.md`](../visual-styles/_index.md)). The two are locked independently — any mode pairs with any visual style (a `pyramid` deck can look `swiss-minimal` or `dark-tech`).
---
## 1. Catalog (5 modes)
Each mode has its own file with: narrative skeleton, page-structure tendencies, speaker-notes register, and a page skeleton example. **Read only the file for the mode you lock** — never glob the directory.
| Mode | Narrative skeleton | Best for |
|---|---|---|
| [`pyramid`](./pyramid.md) | Conclusion first; MECE arguments; every datum carries a comparison | Decision support, analysis, strategy, board / exec reports |
| [`narrative`](./narrative.md) | Story arc — situation → tension → resolution; suspense and turns | Pitches, case studies, brand journeys, fundraising |
| [`instructional`](./instructional.md) | Concept decomposition; step-by-step; parallel exposition | Training, tutorials, explainers, knowledge sharing |
| [`showcase`](./showcase.md) | Visual-led impact; big imagery / numbers; emotional rhythm | Launches, brand reveals, event / promo decks |
| [`briefing`](./briefing.md) | Neutral, complete, scannable; topic titles, even weight, no thesis | Status updates, reference decks, catalogs, meeting packs, FAQs |
> The five partition presentation *intent*, not aesthetics: persuade (`pyramid`) · tell a story (`narrative`) · teach (`instructional`) · impress (`showcase`) · simply inform (`briefing`).
>
> **A mode is a lens, not a mandate over the user's own structure.** When the user brings their own outline, it is authoritative: transcribe it into `design_spec.md §IX` as given — page order and titles preserved — and let the mode govern only voice / register and page-internal treatment. A mode never reorders a user's pages or rewrites their given titles (mode is Reference-strength; a user-authored outline is exactly the override). When the user gives no structure, the mode does the structural lifting. To lay an outline out with the least reshaping, `briefing` imposes the lightest skeleton.
---
## 2. Auto-selection — content / audience signal → mode
| Signal | Recommended mode | Alternates |
|---|---|---|
| Strategic decision / analysis / board / investor | `pyramid` | `narrative` |
| Pitch / case study / origin story / campaign arc | `narrative` | `showcase` |
| Course / onboarding / how-to / science explainer | `instructional` | `pyramid` |
| Product launch / brand reveal / event opener / keynote / 发布会 / TED | `showcase` | `narrative` |
| Status update / reference / catalog / FAQ / meeting pack / 周报 / 参考 | `briefing` | `pyramid` |
> No single signal dominates — read the deck's actual purpose from `c. Key Information`. When two modes fit, follow the **primary** intent of the body pages, not the cover. A data review legitimately runs almost entirely `pyramid`; do not force variety.
**Close calls** — the genuinely adjacent pairs; every other pair is far enough apart that the auto-selection signal decides.
| Torn between | …the first when | …the second when |
|---|---|---|
| `pyramid` / `briefing` | it must land a recommendation — conclusion-first, every number compared | it must inform completely without arguing — topic titles, even weight |
| `narrative` / `pyramid` | the point lands through a story arc, tension → resolution | the point lands as a conclusion stated up front, then supported |
| `narrative` / `showcase` | an argument travels through the story | presence leads — minimal copy, one big visual per page |
| `instructional` / `briefing` | the goal is to build understanding step by step | the goal is to lay out a complete reference to scan |
> "Keynote-style" is a *mode* request, not a visual style — it means showcase pacing (one big idea per page, full-bleed hero, reveal rhythm), skinned by whatever visual style fits the brand (`swiss-minimal` clean, `dark-tech` dramatic, `glassmorphism` premium). Don't reach for a "keynote" visual style — there isn't one, by design.
---
## 3. How to use
1. Strategist reads this index at confirmation `d. Layer 1`.
2. Pick one mode from the auto-selection table + the deck's stated purpose.
3. Lock it: write `- mode: <name>` into `spec_lock.md`, record the rationale in `design_spec.md`.
4. Executor reads **only** `modes/<locked-mode>.md` at generation entry — never globs this directory.
**Lock scope**: deck-wide (one mode per deck). The five are the catalog you select from; if the structure is genuinely mixed, pick the mode of the body pages and let pages vary within it, or recommend a `custom` blend (§4). Recommend the best fit; the user confirms.
---
## 4. Escape hatch — `custom`
`custom` holds **any bespoke narrative direction the five don't give as-is** — and what *kind* of thing it is doesn't matter. It might be a nameable cadence (dialectic 正反合, myth-vs-reality, countdown / Top-N, Socratic), a deliberate multi-act fusion of several modes, or the user's own feel for how the deck should carry (confrontational here, detached there). Don't try to taxonomize it.
**Either side may originate it.** The user can ask for it directly; or the Strategist — as the deck's strategist — may **recommend** `custom` when a bespoke direction (often a fusion of two modes) genuinely serves the deck better than any single preset. Like every confirmation, it's a recommendation the user confirms or overrides — and the recommendation must **spell the custom out in plain language** (what the cadence / fusion / posture actually is), never present the bare token `custom`, so the user confirms something legible. Either way, the Strategist **crystallizes the intent into a `- mode_behavior:` paragraph** — concrete enough that the Executor can follow it per page (the act sequence or posture shifts, the title voice, the page rhythm, the notes register). Set `- mode: custom` in `spec_lock.md` with that sibling line; the Executor follows the prose in place of a preset file. (This records the intent so it survives 20 pages of generation — the Executor only ever reads `spec_lock.md`, never the chat.)
> **One value per deck — fusion is *one* `custom`, not several modes.** A deck always locks a single `mode`. A multi-mode blend is expressed as **one** `mode: custom` whose `mode_behavior` paragraph describes the acts — never by locking several modes.
>
> **First ask whether it's really fusion.** A locked mode is a *tendency*, not a cage: a `narrative` deck can still carry one analytical (pyramid-style) page, an `instructional` deck one showcase reveal — that is leaning within a dominant mode, and needs **no** `custom`. Reach for `custom` only when there is genuinely no single dominant spine.
**The one thing to avoid**: reaching for `custom` as a *dodge* — defaulting to it because picking among the five takes judgment. When a preset genuinely fits, lock the preset; propose `custom` when a bespoke direction earns its place, not to avoid choosing. (And a user-stated direction is authoritative the same way a user-supplied outline is — see the lens-not-mandate note in §1.)

View File

@ -0,0 +1,41 @@
# Mode: briefing
Neutral information delivery. Lay the facts out plainly and completely, organized for scanning and lookup — no thesis to argue, no story to tell, no lesson to build, no spectacle. For status updates, reference decks, catalogs, meeting packs, FAQs, data references.
---
## 1. Narrative skeleton
**No thesis, by design**: the deck informs rather than argues. Don't manufacture a conclusion-first claim (that's `pyramid`) or a turn (that's `narrative`) where the material is simply "here is what's true".
**Topic titles, not assertions**: the page title names its subject plainly ("Q3 headcount by team", "Supported file formats") — clarity for lookup beats a persuasive finding. This is the deliberate inverse of `pyramid`'s assertion titles.
**`core_message` states coverage, not a claim**: when filling `design_spec.md §IX`, write each page's `core_message` as what the page lays out ("Q3 headcount across teams"), not what it proves ("headcount is concentrating in engineering"). The §IX field reads as an assertion under the other modes; under `briefing` it names scope.
**Complete over selective**: include the full reference set the audience needs to scan, not only the points that support a case. Coverage is the value here.
**Parallel, even treatment**: sibling items get the same shape and weight so they can be compared and located quickly; nothing is dramatized over its peers unless it genuinely differs.
**Sectioned for navigation**: group related facts, label the groups, keep order predictable (chronological / categorical / alphabetical) so the reader can jump to what they need.
---
## 2. Page-structure tendencies
- Tables, definition lists, status cards, reference grids, dashboards — scannable structures over hero compositions.
- Even hierarchy within a section; consistent layout across sibling pages so the eye always knows where to look.
- Where one figure genuinely matters (a total, a status flag, an exception), surface it — but don't invent a punchline the content doesn't have.
> Table / list / dashboard / status-card geometry lives in [`templates/charts/`](../../templates/charts/); this mode decides *that the page informs completely and neutrally*, not pixel positions.
## 3. Speaker-notes register
Even, factual, plain. State what the page shows without building tension or pressing a "so what". No rhetorical questions, no suspense — a clear read-out the listener can follow or skim. Numbers stated plainly. (Common framework: [`executor-base.md §8`](../executor-base.md).)
## 4. Page skeleton example
```
Title: "Q3 deliverables by workstream" ← a topic label, not a claim
Body: status table — workstream | owner | status | due — rows at equal weight
Notes: "Three workstreams are on track; payments is at risk on the integration." (plain read-out)
```

View File

@ -0,0 +1,45 @@
# Mode: instructional
Teaching-led exposition. Decompose a concept into ordered, digestible parts and build understanding step by step. For training, tutorials, explainers, onboarding, science / knowledge sharing.
---
## 1. Narrative skeleton
**Decompose, then sequence**: break the subject into parts and present them in a deliberate order (simple → complex, prerequisite → dependent, overview → detail).
**One concept per page**: each page teaches a single idea well; do not stack unrelated concepts.
**Parallel exposition**: sibling concepts get parallel structure — same shape, same depth — so the audience can compare and map them.
**Show, then tell**: lead with a concrete example or analogy, then state the principle. A worked example beats an abstract definition.
**Signpost**: orient the learner — what we covered, what comes next.
Titles state what the page teaches ("How attention weights are computed") — clear over clever.
---
## 2. Page-structure tendencies
- Numbered steps / ordered flows for processes; parallel cards for sibling concepts.
- Diagrams that build incrementally; annotate the part currently being explained.
- A concrete example anchors each abstract point.
> Step / flow / diagram geometry lives in [`templates/charts/`](../../templates/charts/); this mode decides *the learning order and granularity*.
---
## 3. Speaker-notes register
Patient, explanatory. Define before using; analogy then principle. Anticipate the learner's question and answer it. Steady pace; signpost transitions ("now that we have X, we can ask Y"). Conversational data. (Common framework: [`executor-base.md §8`](../executor-base.md).)
---
## 4. Page skeleton example
```
Title: "Step 2 — Scoring each token against the query"
Body: concrete example (3 tokens) → the rule it illustrates → one diagram
Notes: "Remember the query from the last page? Here's what it does next…"
```

View File

@ -0,0 +1,43 @@
# Mode: narrative
Story-arc persuasion. Carry the audience through situation → tension → resolution, using suspense, turns, and human framing so the point lands emotionally before it lands logically. For pitches, case studies, brand journeys, fundraising.
---
## 1. Narrative skeleton
**Arc, per deck and per page**: scenario → conflict → resolution. Set a stake, raise a tension, resolve it — then bridge to the next beat.
**Suspense and payoff**: pose a question at the right moment, answer it on the next page. Let curiosity pull the audience forward.
**Human framing**: anchor abstract points in a protagonist, a moment, a concrete stake ("a team that shipped in two weeks instead of three months").
**At least one turn**: a reframe, a reveal, a "but here's what changed". Flat exposition is not narrative.
Titles read as beats that advance the arc ("Then the numbers stopped adding up"), not as labels.
---
## 2. Page-structure tendencies
- Pages alternate rhythm: a dense beat followed by a breathing page (single image / quote / turn) to prevent fatigue.
- Visual weight guides the eye through each beat (hero image, one focal number, a pull quote).
- Continuity within a chapter, variation between chapters.
> Structure serves the arc, not a grid. Layout / chart geometry lives in [`templates/charts/`](../../templates/charts/) and [`executor-base.md`](../executor-base.md); this mode decides *the emotional beat of each page*.
---
## 3. Speaker-notes register
Conversational narration — like talking with the audience, not reading a report. Scenario-conflict-resolution per page. Metaphors make the abstract tangible ("like adding a turbocharger"). Plain rhetorical questions create suspense; bridge each page from the prior one. Conversational data ("nearly a third", "more than doubled"). (Common framework: [`executor-base.md §8`](../executor-base.md).)
---
## 4. Page skeleton example
```
Page 3 (turn): full-bleed image + one line — "Then deployment broke."
Page 4 (payoff): the reframe — what changed, one focal number
Notes: "You might be wondering where the opportunity is…" (bridges, builds)
```

View File

@ -0,0 +1,58 @@
# Mode: pyramid
Conclusion-first argumentation. State the answer, then support it with mutually-exclusive, collectively-exhaustive evidence — every claim earns its place, every number carries a comparison. For audiences who want the result before the process: executives, boards, investors, decision-makers.
---
## 1. Narrative skeleton
**Conclusion first**: the page title *is* the conclusion, not a label. The body develops the supporting arguments beneath it.
SCQA opening, pyramid body:
| Stage | Role | Where |
|---|---|---|
| Situation | establish shared context | cover / first 1-2 pages |
| Complication | the tension / problem | early pages |
| Question | the implicit question to resolve | transition |
| Answer | the recommendation, developed MECE | all body pages |
**Assertion titles** — write the finding, not the topic:
| Weak (topic) | Strong (assertion) |
|---|---|
| "Market Overview" | "Domestic market grows 23% YoY, outpacing the global average" |
| "Challenges" | "Three structural contradictions block scaled deployment" |
| "Our Solution" | "Three-phase path: Focus, Expand, Scale" |
**Data never stands alone** — every figure pairs with a comparison (prior period / benchmark / competitor / target / rank) and a "so what". A bare number is an incomplete thought in this mode.
**MECE** — when decomposing (drivers, segments, options), branches are mutually exclusive and collectively exhaustive; parts sum to the whole (or label "Other").
---
## 2. Page-structure tendencies
- Title (the conclusion) → one-line takeaway → supporting evidence beneath.
- Each body page answers one question and states its own one-sentence conclusion.
- Decomposition pages (driver tree / MECE breakdown / 2×2 matrix) carry the analytical load.
- Source attribution on every data page.
> Page structure is a tendency, not a coordinate template. Card / tree / chart / KPI geometry lives in [`templates/charts/`](../../templates/charts/) — adapt those skeletons, do not reinvent. This mode decides *what argument each page makes*, not pixel positions.
---
## 3. Speaker-notes register
Conclusion-driven: the first sentence of each page's notes is the takeaway, then 2-3 supporting facts in flowing prose. Composed, authoritative. Every number paired with its comparison in the same sentence ("23% — nearly double the industry's 12%"). Spell percentages as words where the spoken form reads more naturally. (Common framework: [`executor-base.md §8`](../executor-base.md).)
---
## 4. Page skeleton example
```
Title: "Retention, not acquisition, now drives growth" ← the conclusion
Takeaway: one line — "CAC up 40% YoY, yet repurchase lifted 60% of revenue growth"
Body: 3 MECE arguments, each with one contextualized datum
Footer: Source: … | page #
```

View File

@ -0,0 +1,43 @@
# Mode: showcase
Visual-led impact. Let imagery, scale, and rhythm carry the message; minimize copy, maximize presence. For product launches, brand reveals, event openers, promotional decks.
---
## 1. Narrative skeleton
**Image / number leads, words support**: each page has one dominant visual element — a hero image, a single huge number, a short phrase — not a paragraph.
**Emotional rhythm**: build and release — a run of bold pages punctuated by a quiet one. Pace for feeling, not density.
**One idea per page, stated big**: reduce each page to a single takeaway expressed at scale.
**Reveal structure**: hold back, then reveal (the product, the result, the tagline) for maximum effect.
Titles are short and evocative — a phrase, not a sentence.
---
## 2. Page-structure tendencies
- Full-bleed imagery with overlay text; a single focal hero number / phrase.
- Generous negative space; the page breathes around one element.
- Bold use of the deck's theme color for atmosphere (cover / chapter pages).
> Hero / full-bleed / breathing-page geometry lives in [`executor-base.md`](../executor-base.md) and [`image-layout-patterns.md`](../image-layout-patterns.md); this mode decides *what single thing each page presents*.
---
## 3. Speaker-notes register
Energetic, evocative — sets mood and builds anticipation. Short, punchy sentences. Lets the visual do the work and narrates the feeling around it. (Common framework: [`executor-base.md §8`](../executor-base.md).)
---
## 4. Page skeleton example
```
Page (reveal): full-bleed product image + one line — "Meet the new standard."
Page (proof): single huge number "10×" + one phrase, vast whitespace
Notes: "Imagine cutting that to seconds. That's what this does."
```

View File

@ -0,0 +1,777 @@
# Shared Technical Standards
Common technical constraints for PPT Master, eliminating cross-role file duplication.
---
## 1. SVG Banned Features Blacklist
The following are **forbidden** in generated SVGs — PPT export breaks otherwise:
### 1.0 Text characters: must be well-formed XML
SVG is strict XML. Two rules for all text and attribute values:
| Character category | Required form | Forbidden form |
|---|---|---|
| Typography & symbols (em dash, en dash, ©, ®, →, ·, NBSP, full-width punctuation, emoji…) | **Raw Unicode characters** — write `—` `` `©` `®` `→` directly | HTML named entities — `&mdash;` `&ndash;` `&copy;` `&reg;` `&rarr;` `&middot;` `&nbsp;` `&hellip;` `&bull;` etc. |
| XML reserved characters (`&`, `<`, `>`, `"`, `'`) | **XML entities only**`&amp;` `&lt;` `&gt;` `&quot;` `&apos;` (e.g. `R&amp;D`, `error &lt; 5%`) | Bare `&` `<` `>` (e.g. `R&D`, `error < 5%`) |
One offending character invalidates the file and aborts export. Numeric refs (`&#160;` / `&#xa0;`) are XML-legal but discouraged.
**Structural blacklist** (in addition to the character rules above):
| Banned Feature | Description |
|----------------|-------------|
| `mask` | Masks |
| `<style>` | Embedded stylesheets |
| `class` | CSS selector attributes (`id` inside `<defs>` is a legitimate reference and is NOT banned) |
| External CSS | External stylesheet links |
| `<foreignObject>` | Embedded external content |
| `<symbol>` + `<use>` | Symbol reference reuse |
| `textPath` | Text along a path |
| `@font-face` | Custom font declarations |
| `<animate*>` / `<set>` | SVG animations |
| `<script>` / event attributes | Scripts and interactivity |
| `<iframe>` | Embedded frames |
> **`marker-start` / `marker-end` is conditionally allowed** — see §1.1 for constraints. The converter maps qualifying markers to native DrawingML `<a:headEnd>` / `<a:tailEnd>`.
>
> **`clipPath` on `<image>` is conditionally allowed** — see §1.2 for constraints. The converter maps qualifying clip shapes to native DrawingML picture geometry (`<a:prstGeom>` or `<a:custGeom>`).
>
> **`<pattern>` fills are conditionally allowed** — see §7 *Pattern Fill* for the required `data-pptx-pattern` annotation and the closed OOXML preset enum. Hand-drawn pattern geometry is NOT honored; the converter emits the named PPTX preset only. Missing or invalid preset values produce diagonal stripes (warning) or schema-failed PPTX (error).
>
> **Replacing `<mask>` effects** — DrawingML has no per-pixel alpha. Route by effect:
> - Image gradient overlay (vignette/fade/tint) → stacked `<rect>` with `<linearGradient>`/`<radialGradient>` (§6 Image Overlay)
> - Non-rectangular image crop (circle/rounded/hexagon) → `clipPath` on `<image>` (§1.2)
> - Inner glow / soft-edge → `<filter>` with `<feGaussianBlur>` (§6 Glow)
> - Drop shadow → filter shadow or layered rect (§6 Shadow)
>
> Pixel-level alpha effects (text-knockout image fills, arbitrary alpha composites) have no PPT path — bake into the source image at Image_Generator stage.
---
### 1.1 Line-end Markers (Conditionally Allowed)
`marker-start` and `marker-end` on `<line>` and `<path>` elements are allowed **only** when the referenced `<marker>` satisfies all of the following:
| Requirement | Reason |
|-------------|--------|
| Marker `<marker>` element defined inside `<defs>` | Converter looks up marker defs via id index |
| `orient="auto"` | DrawingML arrow auto-rotates along the line tangent; other orient values will not round-trip |
| Marker shape is **one of**: closed 3-vertex path/polygon (triangle), closed 4-vertex path/polygon (diamond), `<circle>` / `<ellipse>` (oval) | These three map cleanly to DrawingML `type="triangle" / "diamond" / "oval"`. Any other shape is silently dropped with a warning. |
| Marker child's `fill` **matches** the parent line's `stroke` color | In DrawingML the arrow head inherits the line color — a mismatched marker fill will look wrong on export. |
| `markerWidth` / `markerHeight` roughly in `315` range | Mapped to `sm` (<6) / `med` (612) / `lg` (>12) size buckets. |
**Use boundary**:
- `marker-start` / `marker-end`: only for connector arrows where the line is primary
- For block / chunky / solid arrows (arrow body is the visual object), use standalone closed `<path>` / `<polygon>`; see `templates/charts/chevron_process.svg` or `templates/charts/process_flow.svg`
**Supported DrawingML mapping**:
| SVG Marker Shape | DrawingML Output |
|------------------|------------------|
| `<path d="M0,0 L10,5 L0,10 Z"/>` (triangle) | `<a:tailEnd type="triangle" w="med" len="med"/>` |
| `<polygon points="0,0 10,5 0,10"/>` | `<a:tailEnd type="triangle" w="med" len="med"/>` |
| 4-vertex closed path/polygon | `<a:tailEnd type="diamond" .../>` |
| `<circle cx="5" cy="5" r="4"/>` | `<a:tailEnd type="oval" .../>` |
**Recommended template** — a standard arrow-head definition ready to reuse:
```xml
<defs>
<marker id="arrowHead" markerWidth="10" markerHeight="10" refX="9" refY="5"
orient="auto" markerUnits="strokeWidth">
<path d="M0,0 L10,5 L0,10 Z" fill="#1976D2"/>
</marker>
</defs>
<line x1="100" y1="200" x2="400" y2="200" stroke="#1976D2" stroke-width="3"
marker-end="url(#arrowHead)"/>
```
> ⚠️ Unclassifiable marker shapes (curved paths, multi-segment, >4 vertices) are silently dropped — line renders without arrow. Use a manual `<polygon>` for exotic shapes.
---
### 1.2 Image Clipping (Conditionally Allowed)
`clip-path` on `<image>` elements is allowed when the referenced `<clipPath>` satisfies the following:
| Requirement | Reason |
|-------------|--------|
| `<clipPath>` element defined inside `<defs>` | Converter looks up clip defs via id index |
| Contains a **single** shape child | First child is used; multiple children are not composited |
| Shape is one of: `<circle>`, `<ellipse>`, `<rect>` (with rx/ry), `<path>`, `<polygon>` | These map to DrawingML geometry (preset or custom) |
| Used **only on `<image>` elements** | Non-image elements with clip-path are **forbidden** |
**Use boundary**:
- Only on `<image>` for non-rectangular crops (circular avatars, rounded frames, hexagons)
- NOT on shapes (`<rect>`/`<circle>`/`<path>`/`<g>`/`<text>`) — draw the target shape directly. A rect clipped to a circle is just a circle.
- PowerPoint's SVG renderer doesn't handle `clipPath`; only the Native PPTX converter does.
**Supported DrawingML mapping**:
| SVG Clip Shape | DrawingML Output | Use Case |
|----------------|------------------|----------|
| `<circle>` / `<ellipse>` | `<a:prstGeom prst="ellipse"/>` | Circular avatar, oval frame |
| `<rect rx="..."/>` | `<a:prstGeom prst="roundRect"/>` with adj value | Rounded rectangle photo frame |
| `<path>` / `<polygon>` | `<a:custGeom>` with path commands | Hexagon, diamond, custom shape |
**Recommended template** — circular image clip:
```xml
<defs>
<clipPath id="avatarClip">
<circle cx="200" cy="200" r="100"/>
</clipPath>
</defs>
<image href="../images/photo.jpg" x="100" y="100" width="200" height="200"
clip-path="url(#avatarClip)" preserveAspectRatio="xMidYMid slice"/>
```
**Rounded rectangle clip** — for card-style image frames:
```xml
<defs>
<clipPath id="cardClip">
<rect x="60" y="120" width="400" height="250" rx="16"/>
</clipPath>
</defs>
<image href="../images/banner.jpg" x="60" y="120" width="400" height="250"
clip-path="url(#cardClip)" preserveAspectRatio="xMidYMid slice"/>
```
> ⚠️ `clip-path` on non-image elements is FORBIDDEN — quality checker errors out. Draw target geometry directly.
---
## 2. PPT Compatibility Alternatives
| Banned Syntax | Correct Alternative |
|---------------|---------------------|
| `fill="rgba(255,255,255,0.1)"` | `fill="#FFFFFF" fill-opacity="0.1"` |
| `<g opacity="0.2">...</g>` | Set `fill-opacity` / `stroke-opacity` on each child element individually |
| `<image opacity="0.3"/>` | Overlay a `<rect fill="background-color" opacity="0.7"/>` mask layer after the image |
**Mnemonic**: PPT does not recognize rgba, group opacity, or image opacity.
> Arrows: prefer `marker-end` for connector lines (§1.1) — converter produces native auto-rotating arrow heads. For block/chunky arrows, use standalone closed shapes; see `templates/charts/chevron_process.svg` and `templates/charts/process_flow.svg`.
---
## 3. Canvas Format Quick Reference
> See [`canvas-formats.md`](canvas-formats.md) for the full format table (presentations / social / marketing) and the format-selection decision tree.
---
## 4. Basic SVG Rules
- **viewBox** must match the canvas dimensions (`width`/`height` must match `viewBox`)
- **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).
- **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"/>`
- **Icons**: `<use data-icon="<library>/<name>" x="" y="" width="48" height="48" fill="#HEX"/>` (auto-embedded post-processing). Always include library prefix. One stylistic library per deck (`chunk-filled`/`tabler-filled`/`tabler-outline`/`phosphor-duotone`); `simple-icons` only for real brand marks. See [`../templates/icons/README.md`](../templates/icons/README.md).
### Inline Text Runs (Single Logical Line = Single `<text>`)
One logical line — even with mixed colors/weights/sizes — MUST be one `<text>` with inline `<tspan>` children. Never use multiple adjacent `<text>` elements. The converter maps each `<tspan>` to a `<a:r>` run within the same PPT text frame, keeping the line as one editable shape.
**DO** — one `<text>` → one text frame with three runs:
```xml
<text x="100" y="200" font-size="24" fill="#333333">
实现<tspan fill="#1A73E8" font-weight="bold">10倍</tspan>效率提升
</text>
```
**DON'T** — three side-by-side `<text>` elements become three separate text frames in PPT (breaks edit-as-one-line, risks alignment drift, makes spacing fragile):
```xml
<text x="100" y="200" font-size="24" fill="#333333">实现</text>
<text x="160" y="200" font-size="24" fill="#1A73E8" font-weight="bold">10倍</text>
<text x="240" y="200" font-size="24" fill="#333333">效率提升</text>
```
**⚠️ Inline tspans must NOT carry `x`/`y`/`dy`** — those mark a new line, and `flatten_tspan` will split into a separate text frame. `dx` is safe (kerning, stays inline). Only set `x`/`y`/`dy` on tspans that genuinely start a new line.
**Multi-line `<text>` with per-line emphasis works**: an outer line-break tspan (with `x` + `dy` or `y`) MAY contain nested inline tspans for color/weight/size — converter walks nested tspans and emits one run per styled segment:
```xml
<text x="80" y="190" font-size="18" fill="#333333">
<tspan x="80" dy="0">完成率<tspan fill="#4CAF50" font-weight="bold">98%</tspan>超预期</tspan>
<tspan x="80" dy="35">成本降低<tspan fill="#F44336" font-weight="bold">¥120万</tspan></tspan>
</text>
```
**DON'T** — same-line column jump via `<tspan x="...">`:
```xml
<text x="100" y="200" font-size="18" fill="#333333">
<tspan x="100">左列</tspan><tspan x="600" font-weight="bold">右列</tspan>
</text>
```
`x` on a tspan starts a new line, splitting into two independent text frames. For two-column layouts, write two `<text>` elements.
**Default — lift key information.** Uniform-styled paragraphs read as walls of text. Wrap these in `<tspan fill="..." font-weight="bold">`:
- **Numerical results** — percentages, multipliers (`10x`), absolute amounts (`¥120万`)
- **Contrasts** — gain/loss, before/after, target/actual
- **One or two load-bearing nouns per sentence** — the term that carries the insight
Do NOT highlight: connectives, common verbs, every noun, decorative adjectives, structural text (footer/axis/legend/page number/labels).
Color: use the deck's primary brand color for emphasis. Reserve green/red for actual positive/negative semantics.
**DON'T** — uniform-styled paragraph buries the insight:
```xml
<text x="80" y="200" font-size="20" fill="#333333">
2024年公司营收同比增长35%达到12亿元创历史新高
</text>
```
**DO** — same line, key data lifted:
```xml
<text x="80" y="200" font-size="20" fill="#333333">
2024年公司营收同比<tspan fill="#1A73E8" font-weight="bold">增长35%</tspan>达到<tspan fill="#1A73E8" font-weight="bold">12亿元</tspan>创历史新高
</text>
```
### Element Grouping (Mandatory)
Wrap logically related elements in top-level `<g id="...">` groups. Produces PowerPoint groups in PPTX, making slides easier to select/move/edit and providing stable anchors for optional per-element entrance animation.
> ⚠️ Only `<g opacity="...">` is banned (§2). Plain `<g>` for grouping is required.
**Animation-ready rule**: direct children of `<svg>` should be semantic groups, not raw drawing atoms. Aim for **38 top-level content `<g id>` groups per slide** (the 38 budget excludes page chrome — see below); each content group becomes one entrance step under the chosen `--animation-trigger` mode (one click in `on-click`, one cascade slot in `after-previous`, parallel in `with-previous`).
**Chrome groups are excluded automatically.** The exporter treats top-level groups whose id contains chrome tokens as page chrome and skips them in the animation sequence — they appear together with the slide. Tokens (matched against id after splitting on `-` / `_`): `background`, `bg`, `decoration` / `decorations` / `decor`, `header`, `footer`, `chrome`, `watermark`, `pagenumber` / `pagenum` / `page-number`, `nav`, `logo`, `rule`. So `<g id="bg-texture">`, `<g id="cover-footer">`, `<g id="p03-header">`, `<g id="bottom-decor">`, `<g id="nav">`, `<g id="logo-area">`, `<g id="column-rule">` all skip animation while keeping their `<g>` wrapper for editing/grouping. Use these naming conventions for chrome — do **not** strip the `<g>` wrapper.
**What to group**:
| Grouping Unit | Contains |
|---------------|----------|
| Card / panel | Background rect + (optional shadow only if the card floats over a photo/colored panel — see §6) + icon + title + body text |
| Process step | Number circle + icon + label + description |
| List item | Bullet / number + icon + title + description |
| Icon-text combo | Icon element + adjacent label |
| Page header | Title + subtitle + accent decoration |
| Page footer | Page number + branding |
| Decorative cluster | Related decorative shapes (rings, orbs, dots) |
**Do not**:
- Put the whole slide into one giant `<g>`; that leaves only one animation step.
- Leave many top-level `<rect>` / `<text>` / `<path>` elements ungrouped; fallback animation is capped at 8 primitives and dense flat pages may skip animation.
- Split every icon, text line, or decorative mark into separate top-level groups; that creates too many click steps.
- Use anonymous top-level groups. Every top-level semantic group needs a descriptive `id`.
**Example**:
```xml
<g id="card-benefits-1">
<!-- This card floats over a colored panel — shadow is appropriate. On a flat white canvas, omit the filter. -->
<rect x="60" y="115" width="565" height="260" rx="20" fill="#FFFFFF" filter="url(#shadow)"/>
<use data-icon="chunk-filled/bolt" x="108" y="163" width="44" height="44" fill="#0071E3"/>
<text x="105" y="270" font-size="56" font-weight="bold" fill="#0071E3">10×</text>
<text x="250" y="270" font-size="30" font-weight="bold" fill="#1D1D1F">Faster</text>
<text x="105" y="310" font-size="18" fill="#6E6E73">Reduce production time from days to hours.</text>
</g>
```
**Naming**: descriptive `id` on top-level `<g>` is **required** (e.g., `card-1`, `step-discover`, `header`, `footer`). Each top-level `<g id>` becomes one anchor for per-element entrance animation in PPTX export; without it, the exporter falls back to at most 8 top-level primitives or skips animation on dense pages.
---
## 5. Post-processing Pipeline (3 Steps)
Must be executed in order — skipping or adding extra flags is FORBIDDEN:
```bash
# 1. Split speaker notes into per-page note files
python3 scripts/total_md_split.py <project_path>
# 2. SVG post-processing (icon embedding, image crop/embed, text flattening, rounded rect to path)
python3 scripts/finalize_svg.py <project_path>
# 3. Export PPTX (embeds speaker notes by default)
python3 scripts/svg_to_pptx.py <project_path>
# Output (default-flow mode):
# exports/<project_name>_<timestamp>.pptx ← native pptx (canonical output)
# backup/<timestamp>/svg_output/ ← Executor SVG source backup (always written)
#
# Add --svg-snapshot to additionally emit:
# exports/<project_name>_<timestamp>_svg.pptx ← SVG snapshot pptx (sibling of native pptx)
```
**Optional animation flags** (only when the user asks):
- `-t <effect>` — page transition (`fade` / `push` / `wipe` / `split` / `strips` / `cover` / `random` / `none`; default `fade`)
- `-a <effect>` — per-element entrance animation (`fade` / `auto` / `mixed` / `random` / one of 22 named effects / `none`; **default `none`** — pages appear as a whole, no auto element builds; opt in with `auto`, which maps effect from group id — image-like ids cycle zoom/dissolve/circle/box/diamond/wheel, other matches map to a single effect, unmatched ids cycle fade/wipe/fly/zoom). Anchors on top-level `<g id="...">` groups.
- `--animation-trigger {on-click,with-previous,after-previous}` — Start mode matching PowerPoint's animation-pane Start dropdown. Default `after-previous` (cascade on slide entry; pace via `--animation-stagger <seconds>`); `on-click` advances per click; `with-previous` plays all groups together.
- `--animation-config <path>` — optional object-level animation sidecar. Default: `<project>/animations.json` when present.
- `--auto-advance <seconds>` — kiosk-style auto-play
**Optional recorded narration** (only when the user asks for narrated/video export):
```bash
python3 scripts/notes_to_audio.py <project_path> --voice zh-CN-XiaoxiaoNeural
python3 scripts/svg_to_pptx.py <project_path> --recorded-narration audio
```
- `notes_to_audio.py` reads split `notes/*.md` files and writes one audio file per slide to `audio/`. Default `edge` output is MP3; configured cloud providers may output MP3 or WAV depending on provider settings.
- `--recorded-narration audio` prepares PowerPoint's recorded timings and narrations: every slide needs matching `m4a` / `mp3` / `wav` audio, every duration must be readable by `ffprobe`, and `on-click` object animation is rejected.
- `--recorded-narration audio` embeds matching audio, keeps speaker notes, and sets slide timings from audio duration.
- `--narration-audio-dir audio` is the lower-level embedding path for partial audio coverage; it does not prepare a complete recorded-timings export.
- Long-audio import and automatic long-audio splitting are not supported.
Full reference: [`animations.md`](animations.md).
**Prohibited**:
- NEVER use `cp` as a substitute for `finalize_svg.py`
- NEVER force `-s output` for the legacy/preview pptx (PowerPoint's internal SVG parser drops icons and rounded corners). Default auto-split already gives native the high-fidelity source it needs without affecting legacy.
- NEVER use `--only` (it suppresses one of the two output files)
> Source-directory split: by default `svg_to_pptx.py` reads `svg_output/` for the native pptx (preserves icon `<use>`, image `preserveAspectRatio``srcRect`, rounded rect `rx/ry``prstGeom roundRect`) and `svg_final/` for the legacy/preview pptx (PowerPoint's internal SVG parser needs the flattened form). Pass `-s output` or `-s final` only when you specifically want both products to read from a single source.
**Re-run rule**: Any change to `svg_output/` after post-processing requires re-running Steps 2-3. Step 1 only re-runs if `notes/total.md` changed.
---
## 6. Shadow & Overlay Techniques
> `<mask>` elements and `<image opacity="...">` are banned. Always use stacked `<rect>` or gradient overlays instead (see §2).
### Shadow
> **Shadow is restraint, not default.** The "designed" feel comes from absence, not abundance.
#### When to use
Only when the element genuinely floats above another layer:
- Card / quote bubble / annotation on a photo or colored panel
- Single primary CTA or "recommended" item picked out from peers
- Overlay layer (callout, tooltip, modal emphasis)
- Floating image card on a textured background
#### When NOT to use
- Background panels / dividers / decorative bars — they are the floor
- Equal peer cards in a 2/3/4-up grid — keep all flat
- Containers with visible border, gradient fill, or strong tint — redundant
- Body-text paragraph containers — disrupts scan rhythm
- Decorative lines / dividers / icons — they are symbols, not objects
- Pages with only one content container — no second layer to lift above
- Dark backgrounds — black shadows vanish; use 1px low-opacity white stroke or outer glow
**Reference — not a constraint**: 2-3 shadowed elements per page usually reads cleanest; before adding a 4th, check the extra layering earns its weight — a genuinely complex dashboard may justify more.
#### Single light source per page
All `feOffset` on a page must share the same `dx`/`dy` direction. Default: `dx="0"`, `dy="4"`-`dy="8"` (light from upper front).
#### Restraint over visibility
Standard: "the shadow is felt, not seen." If noticed, it's too strong.
- Resting cards: `flood-opacity` 0.06-0.10
- Raised elements (CTA, overlay): max `flood-opacity` 0.20
- Above 0.20 = Office 2007 hard-shadow look
- Color: near-black at low opacity, or a darker tint of background. Brand-color shadow only on accent elements sharing that hue.
#### Two-tier elevation maximum
A page may have at most two non-floor tiers.
| Tier | When | dy | stdDeviation | flood-opacity |
|------|------|----|--------------|---------------|
| Floor (no shadow) | Backgrounds, peer-grid cards, dividers, body-text containers | — | — | — |
| Resting | Cards on photos/panels, secondary callouts | 2-4 | 4-8 | 0.06-0.10 |
| Raised | Primary CTA, focused/recommended card, overlay | 6-10 | 10-16 | 0.12-0.20 |
#### Don't stack visual-weight tools
Pick **one** per container: shadow, border, gradient fill, or strong tint. Stacking = instant template look.
---
#### Filter Soft Shadow — Recommended
Best for: cards, floating panels, elevated elements. The `svg_to_pptx` converter automatically converts `feGaussianBlur` + `feOffset` into native PPTX `<a:outerShdw>`.
```xml
<defs>
<filter id="softShadow" x="-15%" y="-15%" width="140%" height="140%">
<feGaussianBlur in="SourceAlpha" stdDeviation="12"/>
<feOffset dx="0" dy="6" result="offsetBlur"/>
<feFlood flood-color="#000000" flood-opacity="0.10" result="shadowColor"/>
<feComposite in="shadowColor" in2="offsetBlur" operator="in" result="shadow"/>
<feMerge>
<feMergeNode in="shadow"/>
<feMergeNode in="SourceGraphic"/>
</feMerge>
</filter>
</defs>
<rect x="60" y="60" width="400" height="240" rx="12" fill="#FFFFFF" filter="url(#softShadow)"/>
```
Recommended parameters (see "Two-tier elevation maximum" above for tier guidance):
```
stdDeviation: 416 (resting cards: 48; raised elements: 1016)
flood-opacity: 0.060.10 (resting cards — default)
0.120.20 (raised elements only — primary CTA, overlay)
NEVER > 0.20 (Office 2007 hard-shadow look)
dy: 210 (resting: 24; raised: 610)
dx: 02 (must match every other shadow on the page — single light source)
```
#### Colored Shadow
Best for: accent buttons, brand-colored cards. Use the element's own color family instead of black.
```xml
<filter id="colorShadow" x="-15%" y="-15%" width="140%" height="140%">
<feGaussianBlur in="SourceAlpha" stdDeviation="10"/>
<feOffset dx="0" dy="6" result="offsetBlur"/>
<feFlood flood-color="#1A73E8" flood-opacity="0.20" result="shadowColor"/>
<feComposite in="shadowColor" in2="offsetBlur" operator="in" result="shadow"/>
<feMerge>
<feMergeNode in="shadow"/>
<feMergeNode in="SourceGraphic"/>
</feMerge>
</filter>
```
Replace `flood-color` with the element's brand color. Keep `flood-opacity` 0.12-0.20. Reserve for the single primary CTA per page — using on every button defeats the cue.
#### Glow Effect
Best for: title highlights, key metrics, hero text. The converter automatically converts `feGaussianBlur` without `feOffset` into native PPTX `<a:glow>`.
```xml
<defs>
<filter id="titleGlow" x="-30%" y="-30%" width="160%" height="160%">
<feGaussianBlur in="SourceAlpha" stdDeviation="6" result="blur"/>
<feFlood flood-color="#1A73E8" flood-opacity="0.45" result="glowColor"/>
<feComposite in="glowColor" in2="blur" operator="in" result="glow"/>
<feMerge>
<feMergeNode in="glow"/>
<feMergeNode in="SourceGraphic"/>
</feMerge>
</filter>
</defs>
<text x="640" y="360" text-anchor="middle" font-size="48" fill="#1A73E8" filter="url(#titleGlow)">Key Insight</text>
```
Recommended parameters:
```
stdDeviation: 48 (smaller = subtle, larger = prominent)
flood-color: brand color or accent color (NOT black)
flood-opacity: 0.350.55 (stronger than shadow for visibility)
```
**vs shadow**: no `<feOffset>` (or dx=0/dy=0). The converter uses this to distinguish glow from shadow.
#### Layered Rect Shadow — High-Compatibility Fallback
Best for: maximum compatibility with older PowerPoint versions. Stack 23 semi-transparent rectangles behind the main card:
```xml
<!-- Shadow layers (back to front, largest offset first) -->
<rect x="68" y="72" width="400" height="240" rx="16" fill="#000000" fill-opacity="0.03"/>
<rect x="65" y="69" width="400" height="240" rx="14" fill="#000000" fill-opacity="0.05"/>
<rect x="62" y="66" width="400" height="240" rx="12" fill="#1A73E8" fill-opacity="0.04"/>
<!-- Main card -->
<rect x="60" y="60" width="400" height="240" rx="12" fill="#FFFFFF"/>
```
### Image Overlay
#### Linear Gradient Overlay — Most Common
Best for: image+text pages. Gradient direction should match text position (text on left → gradient darkens toward left).
```xml
<image href="..." x="0" y="0" width="1280" height="720" preserveAspectRatio="xMidYMid slice"/>
<defs>
<linearGradient id="imgOverlay" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" stop-color="#1A1A2E" stop-opacity="0.85"/>
<stop offset="55%" stop-color="#1A1A2E" stop-opacity="0.30"/>
<stop offset="100%" stop-color="#1A1A2E" stop-opacity="0"/>
</linearGradient>
</defs>
<rect x="0" y="0" width="1280" height="720" fill="url(#imgOverlay)"/>
```
#### Bottom Gradient Bar
Best for: cover slides and full-image pages with bottom title.
```xml
<defs>
<linearGradient id="bottomBar" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#000000" stop-opacity="0"/>
<stop offset="100%" stop-color="#000000" stop-opacity="0.72"/>
</linearGradient>
</defs>
<rect x="0" y="380" width="1280" height="340" fill="url(#bottomBar)"/>
```
#### Radial Gradient Overlay — Vignette Effect
Best for: full-screen atmosphere slides; draws attention to the center.
```xml
<defs>
<radialGradient id="vignette" cx="50%" cy="50%" r="70%">
<stop offset="0%" stop-color="#000000" stop-opacity="0"/>
<stop offset="100%" stop-color="#000000" stop-opacity="0.58"/>
</radialGradient>
</defs>
<rect x="0" y="0" width="1280" height="720" fill="url(#vignette)"/>
```
#### Brand Color Overlay
Best for: slides needing strong visual brand identity.
```xml
<defs>
<linearGradient id="brandOverlay" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" stop-color="#005587" stop-opacity="0.80"/>
<stop offset="100%" stop-color="#005587" stop-opacity="0.10"/>
</linearGradient>
</defs>
<rect x="0" y="0" width="1280" height="720" fill="url(#brandOverlay)"/>
```
### Quick-Reference Table
| Scenario | Recommended Technique | Avoid |
|----------|-----------------------|-------|
| Card / panel shadow (only when floating over photo/colored panel) | Filter soft shadow (`flood-opacity` 0.060.10, single light source) | Hard black shadow, full-page abundance |
| Equal peer cards in a grid | All flat (no shadow) | Lifting every card uniformly |
| Page-section background panel | Flat fill, no shadow | Treating panels as floating cards |
| Accent / CTA button (one per page) | Colored shadow (same hue family, `flood-opacity` 0.120.20) | Generic gray shadow, applying to every button |
| Title / metric highlight | Glow filter (brand color, no offset) | Overuse on body text |
| Text over image | Linear gradient overlay (direction matches text side) | Uniform flat opacity over whole image |
| Cover / full-image slide | Bottom gradient bar + brand color | Solid black overlay |
| Atmosphere / hero slide | Radial vignette | Unprocessed raw image |
| Max PPT compatibility needed | Layered rect shadow | Filter-based shadow |
---
## 7. Stroke, Text & Shape Effects
### stroke-dasharray — Dashed / Dotted Lines
Converts to native PPTX `<a:prstDash>`. Use preset patterns for best results:
| SVG Value | PPTX Preset | Best For |
|-----------|-------------|----------|
| `4,4` | Dash | General dashed lines, separators |
| `2,2` | Dot (sysDot) | Subtle dotted borders, placeholder outlines |
| `8,4` | Long dash | Timeline connectors, flow arrows |
| `8,4,2,4` | Long dash-dot | Technical drawings, dimension lines |
```xml
<rect x="60" y="60" width="400" height="240" rx="12"
fill="none" stroke="#999999" stroke-width="2" stroke-dasharray="4,4"/>
<line x1="100" y1="360" x2="1180" y2="360"
stroke="#CCCCCC" stroke-width="1" stroke-dasharray="2,2"/>
```
### stroke-linejoin
Controls how line segments join at corners. Supported values convert to native PPTX line join types:
| SVG Value | PPTX Equivalent | Best For |
|-----------|-----------------|----------|
| `round` | Round join | Smooth polyline charts, organic shapes |
| `bevel` | Bevel join | Technical diagrams |
| `miter` | Miter join (default) | Sharp-cornered rectangles, arrows |
```xml
<polyline points="100,200 200,100 300,200" fill="none"
stroke="#1A73E8" stroke-width="3" stroke-linejoin="round"/>
```
### text-decoration
Supported text decorations convert to native PPTX text formatting:
| SVG Value | PPTX Equivalent | Best For |
|-----------|-----------------|----------|
| `underline` | Single underline | Emphasis, links, key terms |
| `line-through` | Strikethrough | Removed items, before/after comparisons |
```xml
<text x="100" y="200" font-size="20" fill="#333333" text-decoration="underline">Important Term</text>
<!-- Per-tspan decoration -->
<text x="100" y="240" font-size="18" fill="#333333">
Regular text <tspan text-decoration="line-through" fill="#999999">old value</tspan> new value
</text>
```
### Gradient Fill — linearGradient & radialGradient
Gradients defined in `<defs>` and referenced via `fill="url(#id)"` convert to native PPTX `<a:gradFill>`. Use them as shape fills (not just overlays) for polished surfaces.
**Linear gradient** — best for buttons, header bars, background panels:
```xml
<defs>
<linearGradient id="btnGrad" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" stop-color="#1A73E8"/>
<stop offset="100%" stop-color="#0D47A1"/>
</linearGradient>
</defs>
<rect x="540" y="600" width="200" height="48" rx="24" fill="url(#btnGrad)"/>
```
**Radial gradient** — best for spotlight backgrounds, circular accents:
```xml
<defs>
<radialGradient id="spotBg" cx="50%" cy="50%" r="70%">
<stop offset="0%" stop-color="#1A73E8" stop-opacity="0.15"/>
<stop offset="100%" stop-color="#1A73E8" stop-opacity="0"/>
</radialGradient>
</defs>
<circle cx="640" cy="360" r="300" fill="url(#spotBg)"/>
```
### Pattern Fill — `<pattern>` with PPTX preset annotation
`<pattern>` fills convert to native PPTX `<a:pattFill prst="...">` — but only PPTX's built-in preset patterns are reachable. The converter does **not** render hand-drawn `<path>` geometry inside the pattern; instead it reads two annotations off the `<pattern>` element and emits the matching DrawingML preset.
**Prefer explicit geometry when spacing matters.** A `<pattern>` renders at PowerPoint's **fixed preset density** — you cannot reproduce a specific tile size (e.g. a 40px grid). For grids / textures whose spacing or line weight is part of the design, draw the lines as **one `<path>` with all lines as subpaths** (`M40 0V720 M80 0V720 … M0 40H1280 …`, `fill="none" stroke=…`) — the converter supports `M/L/H/V` and multi-subpath, so it becomes **one editable vector shape that reproduces the exact spacing** across all four renderers. Reserve `<pattern>` + `data-pptx-pattern` for **round-tripping an existing PPTX** (decks imported via `pptx_to_svg`), where the source genuinely used a native preset fill. For pure display where no PPT-side editing is needed, `--svg-snapshot` is the other faithful option.
**Required annotations** (only when you intentionally use a `<pattern>` preset):
| Attribute | Purpose | Without it |
|---|---|---|
| `data-pptx-pattern="<preset>"` | Names the PPTX preset (one of the enum below) | Falls back to `ltUpDiag` — diagonal stripes, not your geometry |
| Child `<rect fill="<bg-hex>"/>` | Background color of the pattern tile | `bg` falls back to `#FFFFFF`, painting over the page background |
The child `<path>`'s `stroke` becomes the foreground color (the pattern's line color).
```xml
<defs>
<pattern id="bpGrid" x="0" y="0" width="40" height="40"
patternUnits="userSpaceOnUse" data-pptx-pattern="lgGrid">
<rect width="40" height="40" fill="#0E2A47"/>
<path d="M 40 0 L 0 0 0 40" fill="none" stroke="#2D4A6B" stroke-width="0.6"/>
</pattern>
</defs>
<rect width="1280" height="720" fill="url(#bpGrid)"/>
```
**Valid `data-pptx-pattern` values** (OOXML `ST_PresetPatternVal` — closed enum, anything outside makes PowerPoint open with "needs to be repaired"):
| Category | Values |
|---|---|
| Grids | `smGrid` · `lgGrid` · `dotGrid` *(no `ltGrid` — common typo)* |
| Diagonal lines | `ltUpDiag` · `ltDnDiag` · `dkUpDiag` · `dkDnDiag` · `wdUpDiag` · `wdDnDiag` · `dashUpDiag` · `dashDnDiag` · `diagCross` |
| Horizontal / vertical lines | `horz` · `vert` · `ltHorz` · `ltVert` · `dkHorz` · `dkVert` · `narHorz` · `narVert` · `dashHorz` · `dashVert` · `cross` |
| Percent fills | `pct5` · `pct10` · `pct20` · `pct25` · `pct30` · `pct40` · `pct50` · `pct60` · `pct70` · `pct75` · `pct80` · `pct90` |
| Checks & confetti | `smCheck` · `lgCheck` · `smConfetti` · `lgConfetti` |
| Decorative | `horzBrick` · `diagBrick` · `weave` · `plaid` · `trellis` · `zigZag` · `wave` · `sphere` · `divot` · `shingle` · `solidDmnd` · `openDmnd` · `dotDmnd` |
> `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.
### 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`.
```xml
<!-- Rotated decorative element -->
<rect x="100" y="100" width="60" height="60" fill="#1A73E8" fill-opacity="0.1"
transform="rotate(45, 130, 130)"/>
<!-- Rotated text label -->
<text x="50" y="400" font-size="14" fill="#999999"
transform="rotate(-90, 50, 400)">Y-Axis Label</text>
```
**Syntax**: `rotate(angle)` or `rotate(angle, cx, cy)` where `cx,cy` is the rotation center. Positive angles rotate clockwise.
### Arc Paths — Donut / Pie Charts
Calculate arc endpoint coordinates precisely with trigonometry. Never estimate — small errors produce wildly wrong shapes.
**Calculation formula** (center `cx,cy`, radius `r`, angle `θ` in degrees):
```
x = cx + r × cos(θ × π / 180)
y = cy + r × sin(θ × π / 180)
```
**Key rules**:
1. Start at **-90°** (12 o'clock position) and go clockwise
2. Each sector spans `percentage × 360°`
3. Use **large-arc flag = 1** when the sector is > 180°, **0** otherwise
4. sweep-direction = 1 (clockwise) for outer arc, 0 (counter-clockwise) for inner arc returning
5. **Always verify** that the sum of all sector angles equals 360° and that the last sector's end point matches the first sector's start point
**Example — 75% donut sector** (center 400,400, outer r=180, inner r=100):
```
Start angle: -90° → outer(400, 220), inner(400, 300)
End angle: -90+270=180° → outer(220, 400), inner(300, 400)
Large-arc flag: 1 (270° > 180°)
<path d="M 400,220 A 180,180 0 1,1 220,400 L 300,400 A 100,100 0 1,0 400,300 Z"/>
```
### Polygon Arrows on Diagonal Lines
> For connector lines prefer `marker-end`/`marker-start` (§1.1). For chunky/wide solid/non-connector arrows, use standalone polygon or path.
Horizontal/vertical lines can use simple point offsets for `<polygon>` arrowheads. Diagonal lines need triangle vertices rotated to match line direction.
**Method** — calculate triangle points using the line's direction vector:
```
Given line from (x1,y1) to (x2,y2):
1. Direction vector: dx = x2-x1, dy = y2-y1
2. Normalize: len = √(dx²+dy²), ux = dx/len, uy = dy/len
3. Perpendicular: px = -uy, py = ux
4. Arrow tip = (x2, y2)
5. Back point 1 = (x2 - ux×12 + px×5, y2 - uy×12 + py×5)
6. Back point 2 = (x2 - ux×12 - px×5, y2 - uy×12 - py×5)
```
**Example — diagonal line** from (260,310) to (370,430):
```
dx=110, dy=120, len≈162.8, ux=0.676, uy=0.737
px=-0.737, py=0.676
Tip: (370, 430)
Back1: (370-8.1-3.7, 430-8.8+3.4) = (358.2, 424.6)
Back2: (370-8.1+3.7, 430-8.8-3.4) = (365.6, 417.8)
<polygon points="370,430 365.6,417.8 358.2,424.6" fill="#C8A96E"/>
```
⚠️ Never use a fixed downward/rightward triangle on a diagonal line — arrow will point wrong.
---
## 8. Project Directory Structure
```
project/
├── svg_output/ # Raw SVGs (Executor output, contains placeholders)
├── svg_final/ # Post-processed final SVGs (finalize_svg.py output)
├── images/ # Image assets (user-provided + AI-generated)
├── notes/ # Speaker notes (.md files matching SVG names)
│ └── total.md # Complete speaker notes document (before splitting)
├── templates/ # Project templates (if any)
└── *.pptx # Exported PPT file
```

View File

@ -0,0 +1,843 @@
# Role: Strategist
## Core Mission
As a top-tier AI presentation strategist, receive source documents, perform content analysis and design planning, and output the **Design Specification & Content Outline** (hereafter `design_spec`).
## Pipeline Context
| Previous Step | Current | Next Step |
|--------------|---------|-----------|
| Project creation + Template option confirmed | **Strategist**: Eight Confirmations + Design Spec | Image_Generator or Executor |
---
## Canvas Format Quick Reference
> See [`canvas-formats.md`](canvas-formats.md) for the full format table (presentations / social / marketing) and the format-selection decision tree.
---
## 1. Eight Confirmations Process
🚧 **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.
**Two-tier confirmation (the default Confirm UI flow; chat mirrors it).** The eight items split into a dependency order, confirmed in two passes:
| Tier | 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 |
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.
> **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.
>
> **One opt-in exception**: present the spec-refinement line alongside the split-mode note (SKILL.md Step 4). It is OFF by default — the above discipline holds unchanged. Only when the user *explicitly* asks to refine the spec do you hand off to the [refine-spec](../workflows/refine-spec.md) workflow, which produces the full spec first and stops for user review/revision of any part before generation. Never enter it unprompted.
> **Default presentation surface — Confirm UI.** Deliver the bundled package through the interactive page: write your recommendations to `<project>/confirm_ui/recommendations.json`, then launch per [SKILL.md Step 4](../SKILL.md). You still author everything — enumerable fields name a recommended `id`; generative fields (color `palette`, CJK + Latin typography, generated-image style) each carry **≥3 distinct candidates**, and the deck's **visual style** (§d Layer 2) carries a **≥3-style personality spectrum** (`visual_style_spectrum`, safe / shifted / bold) — creative recommendations always offer real choice, never a single silent option, same hard rule and thinking as h.5. Honest-shortfall exception (mirrors h.5): if the constraints genuinely cannot yield 3 non-conflicting options, present the smaller set and say why — never pad with duplicates or known-conflicting fillers. **Always also print the recommendations + URL in chat** as the always-valid fallback. On confirm, read `<project>/confirm_ui/result.json` (`generation_mode: "split"` / `refine_spec: true` are explicit user choices). Skip the page if the user wants chat-only. Full launch flow, field rules, and JSON schema live in [SKILL.md Step 4](../SKILL.md) + [`scripts/docs/confirm_ui.md`](../scripts/docs/confirm_ui.md) — don't restate them here. The page is a confirmation surface only.
### a. Canvas Format Confirmation
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.
### 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.
**Material divergence** — a **free-text** intent the user states beside audience (same content-strategy cluster): in their own words, how closely the deck should follow the source vs how freely it may reshape it. This is the user's own call — a free prose field (`content_divergence`), **not** a fixed set of options and **not** something you recommend from analyzing the source. Surface the question (in the confirm UI it is a text box under audience; in chat, ask it plainly); leave it for the user to fill. Blank = a balanced default.
Read the user's prose as a point on a spectrum and apply judgment — from *stay close* (track the source's structure and wording, tune only for clarity, no substantive add / drop) through the default *balanced* (re-architect and distill into a narrative under the locked `mode`, keeping all substance) to *free* (regroup, reframe, expand terse points, draw out connections latent in the source, invent section structure and transitions).
**Hard rule — facts stay sourced however free the user asks.** Divergence is freedom to *develop* what is in the source (reorganize / reframe / expand / connect), never licence to invent. Even the freest request must not introduce facts, figures, or claims from outside the source material — that is the `topic-research` job, not divergence. `mode` and divergence are orthogonal (e.g. a pyramid that hews to the source's own points vs. a pyramid built from freely synthesized themes).
**Consumption — outline-authoring only.** Apply the user's stated intent when authoring the `§IX` outline. Record the prose (or "balanced default") in `design_spec.md §I` (Content Strategy). Do **NOT** write it to `spec_lock.md` — it is baked into `§IX` at authoring time and the Executor never reads it. It carries no page-count coupling — the §b page count stays the user's separate call. The beautify / template-fill workflows keep content verbatim, so they do not surface this field.
### d. Style Objective Confirmation
Two independent layers, each locks one catalog item. Output: `d. Mode: <mode> + Visual style: <visual_style>`.
> **Presenting a `custom` lock — spell it out.** Whenever either layer resolves to `custom`, the confirmation must state the bespoke choice in **plain language** — what the cadence / fusion / posture actually is (Layer 1), or what the aesthetic actually is (Layer 2) — so the user confirms a legible direction, never the bare word `custom`. Show this prose in the confirmation first; it is the same content you then crystallize into the `mode_behavior` / `visual_style_behavior` line. e.g. `d. Mode: custom — open with a narrative hook, then a pyramid analysis core, then a showcase close (no single dominant spine)` — not just `Mode: custom`.
#### Layer 1 — Communication mode
🚧 **GATE**: read [`modes/_index.md`](./modes/_index.md) before recommending.
The deck's **narrative + persuasion skeleton** — how the argument is organized and advanced. Lock **one** of `pyramid` / `narrative` / `instructional` / `showcase` / `briefing` (closed set; full catalog in the index).
**Source**:
- User supplied their own outline / structure → it is authoritative. Transcribe it into `§IX` as given (page order + titles preserved); still lock a mode, but for register / voice and page-internal treatment, **not** to reshape — never reorder the user's pages or rewrite their given titles. Note in `design_spec.md` that the structure is user-authored. `briefing` imposes the least if no particular "讲法" is intended.
- Beautify / re-layout workflow ([`beautify-pptx.md`](../workflows/beautify-pptx.md)) → the extracted source content is authoritative and **verbatim**, one step stricter than the user-outline case above. Each source slide becomes exactly one `§IX` page in source order; transcribe every content block word-for-word — never reshape / re-primary / condense / merge / split / reword. Lock `mode: briefing`; color (e) and typography (g) are whatever the user confirmed in the beautify plan — the source identity (theme or observed) by default, or a content / brand-aware alternative the beautify plan offered and the user picked — locked as truth (the beautify plan already ran the recommendation through the confirm UI, so do not re-recommend here). Charts / tables / images are regenerated from their extracted data in the inherited style (route chart/table data to §VII, pictures to §VIII) — data values stay frozen, the rendering is the deck's own; never carried over verbatim. Layout, hierarchy, rhythm, and visual rendering are what gets redesigned.
- A bespoke direction the five don't give — a nameable cadence (dialectic 正反合, myth-vs-reality, countdown, Socratic), a multi-act fusion of modes, or the user's own feel (confrontational here, detached there). Either the user asks, **or you recommend it** when a fusion / bespoke direction genuinely serves the deck better than a single preset (a recommendation the user confirms, like every lock). The *kind* doesn't matter → `mode: custom` + a `mode_behavior:` paragraph that **crystallizes the intent** (act sequence or posture shifts, title voice, page rhythm, register) concretely enough for the Executor to follow per page; it reads only `spec_lock.md`, never the chat. One deck locks **one** value — a fusion is one `custom` describing the acts, never several modes. Avoid only the *dodge*: don't default to `custom` when a preset genuinely fits, and prefer a dominant mode + page-level variation when one mode leads.
- No user structure or cadence → recommend by the index's auto-selection table (content / audience signal → mode) plus the deck's stated purpose; the mode does the structural lifting. Present as a recommendation; the user may override.
Write the locked value to `spec_lock.md` `- mode:` and record the rationale in `design_spec.md` (for `custom`, also write the sibling `- mode_behavior:` paragraph). Executor loads only that one mode file, or follows `mode_behavior` when the value is `custom`.
#### Layer 2 — Visual style
🚧 **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`.
**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.
**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.
**Carries no color.** A visual style governs how the deck's HEX (locked at `e`) is *used* — never which colors, same discipline as [`image-renderings`](./image-renderings/_index.md). When the deck has AI images, prefer the style's paired rendering so layout and illustration share one aesthetic.
Write the locked value to `spec_lock.md` `- visual_style:` and the rationale to `design_spec.md`. Executor loads only that one visual-style file.
> **Template vs preset**: a style mention may sound like a template name ("academic style" vs the `academic_defense/` template directory). Step 3 only triggers on an explicit template directory path supplied by the user — bare names and style words never copy templates; they map to a visual-style preset here. If a template was triggered upstream, its files are already in `<project_path>/templates/` and its fused design_spec governs.
**Downstream effect**: e / f / g / h realize the locked mode + visual style. Example: `showcase` + `dark-tech` → e applies one luminous accent on a dark field; g pairs a clean sans with mono; f minimal glow icons; h the `digital-dashboard` rendering.
### e. Color Scheme Recommendation
**Hard rule**: User / template colors are truth. If the user has specified colors (HEX, brand colors, or natural-language directives like "use blue as primary"), or a template was loaded at Step 3 via an explicit path (`<project_path>/templates/design_spec.md`), lock those directly and skip the recommendation table. Do not adjust them to fit any palette or industry default. Only when no color signal exists from user or template do you proactively propose a scheme below.
> Step 3 already collapses brand and layout inputs into one fused `design_spec.md`; this layer reads from that single source and does not need to re-resolve brand vs layout precedence.
Proactively provide a color scheme (HEX values) based on content characteristics and industry.
**Industry color quick reference** (full 14-industry list in `scripts/config.py` under `INDUSTRY_COLORS`):
| Industry | Primary Color | Characteristics |
|----------|--------------|-----------------|
| Finance / Business | `#003366` Navy Blue | Stable, trustworthy |
| Technology / Internet | `#1565C0` Bright Blue | Innovative, energetic |
| Healthcare / Health | `#00796B` Teal Green | Professional, reassuring |
| Government / Public Sector | `#C41E3A` Red | Authoritative, dignified |
**Color rules**: 60-30-10 rule (primary 60%, secondary 30%, accent 10%); text contrast ratio >= 4.5:1; no more than 4 colors per page.
**Lock the full neutral set the visual style implies** — not just primary / secondary / accent / border. Predict the extra neutral tiers the locked `visual_style` (§d Layer 2) needs and lock them now; `spec_lock.colors` must be complete before generation, and the Executor draws only from it (never invents a tone mid-deck).
| Style trait | Extra neutral tiers to lock |
|---|---|
| Layers panels / charts (e.g. `data-journalism`, `swiss-minimal`) | `surface` (panel lift), `grid` (hairline, lighter than dividers) |
| Text over imagery / dark field (e.g. `photo-editorial`, `glassmorphism`, `dark-tech`) | `scrim` / `overlay` for legibility |
| Print / hand-drawn fills (e.g. `chalkboard`, `zine`) | `block-shade`, one step off the field |
### f. Icon Usage Confirmation
| Option | Approach | Suitable Scenarios |
|--------|----------|-------------------|
| **A** | Emoji | Casual, playful, social media |
| **B** | AI-generated | Custom style needed |
| **C** | Built-in icon library | Professional scenarios (recommended) |
| **D** | Custom icons | Has brand assets |
The built-in icon library contains multiple stylistic libraries plus a brand-logo library:
See [`../templates/icons/README.md`](../templates/icons/README.md) for the current library inventory, counts, prefixes, and SVG placeholder details.
> **Mandatory rules when choosing C**:
>
> **At the eight-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
> - **`tabler-filled`** — fill, bezier curves and arcs (C/A); smooth, rounded, organic; medium weight, approachable
> - **`tabler-outline`** — stroke (line art); airy, refined, lightweight; best for screen-only (thin strokes may be hard to read in print)
> - **`phosphor-duotone`** — duotone; main shape + 20% opacity backplate; medium weight, layered, contemporary
> - ⚠️ **One presentation = one stylistic library** for generic icons (home, chart, users, etc.). Mixing `chunk-filled` / `tabler-filled` / `tabler-outline` / `phosphor-duotone` is FORBIDDEN. If the chosen library lacks an exact icon, find the closest alternative **within that same library**.
> - **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:
>
> 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>`
> 5. Use the verified filename (without `.svg`) as the icon name; always include the library prefix (e.g., `chunk-filled/home`).
> 6. **Copy each chosen icon into the project as you confirm it**`python3 skills/ppt-master/scripts/icon_sync.py <project_path> <lib/name> [<lib/name> …]`. This populates `<project>/icons/<lib>/` (the set the Executor embeds from) and, more importantly, **validates existence on the spot**.
> 7. List the final icon inventory and chosen library in `design_spec.md` §VI; record the same in `spec_lock.md icons` (including `stroke_width` for stroke-style libraries). Executor may only use icons from this list.
>
> 🚧 **GATE — missing icon = re-pick now**: if `icon_sync.py` reports any name as missing (non-zero exit), that icon is not in the library — re-pick a real filename via `ls … | grep`, fix `§VI` / `spec_lock.md`, and re-run until it exits clean. Never carry a missing icon forward to generation. Over-copying candidates is harmless — finalize embeds only the icons actually referenced by `<use data-icon>`.
>
> **Do NOT preload any index file** — when the inventory step arrives, use `ls | grep` to search on demand with zero token cost.
### g. Typography Plan Confirmation (Font + Size)
🚧 **GATE — read the locked style's type character first**: `read_file` the visual-style file locked at §d Layer 2 (`visual-styles/<visual_style>.md`) and pull its **§2 Typography character** (you only read the catalog index there; the per-style character lives in the file). Both combinations below MUST realize it, and the **title carries the personality** — the CJK body may stay a neutral pre-installed sans, but the title leads with the character the style asks for (e.g. `ink-wash` → calligraphic `KaiTi` / `FangSong`; `brutalist` / `memphis` / `vintage-poster` / `zine` → display `SimHei` / `Impact`; `editorial` / `data-journalism` / `photo-editorial` → serif `Georgia` / `Cambria` / `SimSun`; `dark-tech` / `blueprint` → clean sans + `Consolas` mono; `swiss-minimal` / `soft-rounded` → grotesque / friendly sans). For `visual_style: custom`, realize its `visual_style_behavior` character instead. Letting the title default to a neutral sans when the style asks for character is the failure mode to avoid.
#### 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.
> **⚠️ 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:
> - 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".
**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):
- `Microsoft YaHei``PingFang SC``Heiti SC`
- `SimSun``Songti SC``STSong`
- `Arial``Helvetica Neue``Segoe UI`
- `"Times New Roman"``Times`
- `Georgia``Cambria`
**Mandatory**: propose **two** combinations to the user — one concord (safe), one contrast (with tension). Do not default to "title = body, same font" without explicit user request. Pick each family by subject fit and the locked `visual_style`'s **§2 character** (read at the GATE above) — there is **no default family**; type should follow the deck's content and aesthetic, not fall back to one safe face.
> **Template precedence**: when a template was loaded at Step 3 via an explicit path and declares `title` / `body` font stacks in `<project_path>/templates/design_spec.md §III Typography` / §IV (or whichever heading the fused spec uses), lock those directly and skip the two-combination presentation. Same precedence as e. — user override > template values.
**Cross-platform pre-installed reference**:
| Category | Safe families |
|----------|--------------|
| CJK sans | Microsoft YaHei, SimHei, PingFang SC, Heiti SC |
| CJK serif | SimSun, FangSong, KaiTi, Songti SC |
| Latin sans | Arial, Calibri, Segoe UI, Verdana, Trebuchet MS, Helvetica Neue |
| Latin serif | Times New Roman, Georgia, Cambria, Palatino, Garamond, Book Antiqua |
| Mono | Consolas, Courier New |
| Display | Impact, Arial Black |
**Seed combinations** (all PPT-safe; first column is the contrast axis, not a scenario) — starting points, not the allowed set. Any client-preinstalled family is fair game; non-pre-installed expressive faces go title-only (see note below). Let the locked style's §2 character pick the axis and the title lead; the `Microsoft YaHei` body cells are the **neutral default, not a requirement** — a styled deck still varies the title even when the CJK body stays a neutral sans.
| Contrast axis | Title stack | Body stack | Code stack |
|---|---|---|---|
| Serif × sans | `Georgia, KaiTi, serif` | `"Microsoft YaHei", "PingFang SC", sans-serif` | — |
| Kai × hei | `KaiTi, Georgia, serif` | `"Microsoft YaHei", "PingFang SC", sans-serif` | — |
| Fangsong × hei | `FangSong, "Times New Roman", serif` | `SimHei, "Microsoft YaHei", sans-serif` | — |
| Double serif | `Palatino, FangSong, serif` | `Cambria, SimSun, serif` | — |
| Same family, weight contrast (900 / 300) | `"Microsoft YaHei", "PingFang SC", sans-serif` | same | — |
| Display × neutral | `Impact, "Arial Black", SimHei, sans-serif` | `Arial, "Microsoft YaHei", sans-serif` | — |
| Cool serif (academic) | `Cambria, SimSun, serif` | `"Times New Roman", SimSun, serif` | — |
| Hei × song (政务) | `SimHei, "Microsoft YaHei", sans-serif` | `SimSun, serif` | — |
| 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`.
> **Non-pre-installed directions** (require install or PPTX embed; note the constraint in Design Spec):
> - **Retro / pixel** — Press Start 2P / VT323 / Silkscreen
> - **Rounded friendly** — Nunito / Quicksand / M PLUS Rounded / OPPO Sans (closest safe substitute: `Trebuchet MS` / `Verdana`)
> - **Modern web sans** — Inter / HarmonyOS Sans / Source Han Sans / Noto Sans
> - **Calligraphic display** — 隶书 LiSu / 华文行楷 STXingkai / 华文新魏 STXinwei (closest safe substitute: `KaiTi` / `FangSong`); cover / section / hero titles only, never body
> - **Brand-specific** — McKinsey Bower, corporate VI typefaces
#### Font Size Ramp (px throughout)
> **Ramp, not a fixed menu.** All sizes derive from the `body` baseline as a ratio. `spec_lock.md typography` declares `body` plus the slots this deck uses (`title` / `subtitle` / `annotation` by default; add `cover_title` / `hero_number` / `subheading` / `lead` / `footnote` / `chart_annotation` as the outline demands).
>
> **Mandatory — scan `§IX` before locking and declare a slot for every role that recurs across pages.** Do not ship only the four defaults when the outline plainly carries more. A report / `text`-mode deck almost always recurs a per-page **core-message / lead line** (the one-sentence key-claim / takeaway under the title) and recurring **page numbers / source credits / footnotes** — declare `lead` (in the 1.11.4× lead band, and **always ≥ `body`** — the core message is a primary line, never smaller than body) and `footnote` (keep it readable — **~16px for a standard body, not shrunk smaller**) for them. Leaving these undeclared forces the Executor to improvise an unlocked size, and a core line improvised *below* `body` (with data callouts sitting larger) is exactly the hierarchy inversion this prevents. Recurring chart / figure labels get `chart_annotation` likewise.
>
> **Structural roles (page title / body / subtitle / annotation / footnote) resolve to one size each and hold it deck-wide** — that consistency is what reads as professional. Picking an intermediate in-band size is for special / feature elements (hero number, display title, one-off emphasis); a recurring one is declared as its own slot so it stays consistent too.
> **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:
| Delivery purpose (PPT 16:9) | Body (px) | Reads as |
|---|---:|---|
| `text` · read-close (report, data-dense brief, leave-behind file) | 20px | screen / handout reading at arm's length |
| `balanced` · business (presented **and** read; roadshow, review) — **default** | 24px | mixed projection + reading |
| `presentation` (projected, sparse; keynote, launch, classroom) | 32px | room projection, glance from the back |
The body baseline is **purely a function of delivery purpose** — density and visual style do **not** nudge it within a range. Body size is the reading-distance proxy; density is orthogonal and shows in **how much text per page, page count, and `page_rhythm`** (§6.1), the *other* roles, and decoration — never in growing or shrinking the body baseline. One purpose → one body size, identical across the deck. (The user may still override the value in confirmation; absent an override, this fixed value is the recommendation.)
| Canvas | Height | Body baseline | Unit |
|---|---|---|---|
| PPT 16:9 / 4:3 | 720 / 768 | 20 / 24 / 32 (by delivery purpose) | **px** |
| Xiaohongshu | 1242×1660 | 4055 | px |
| WeChat / IG 1:1 | 1080×1080 | 2736 | px |
| Story / Portrait | 1080×1920 | 4864 | px |
| A4 | 1240×1754 | 4458 | px |
> **Every canvas authors px directly.** PPT and social / print alike — the body baseline is a px number (PPT by delivery purpose above; social / print by the per-canvas value). No pt confirmation, no conversion step on any canvas.
> **Confirmed values win — never recompute over them.** The user's confirmed sizes are authoritative. **Confirm UI path**: take `result.json` `typography.body_size` / `sizes` (already px) **verbatim** — do **not** re-derive from the canvas even if the user changed it. The page deliberately does not auto-rescale font sizes when the canvas changes (it only updates the recommended-value hint), so `result.json` already reflects the user's intent; recomputing here would silently override their choice. **Chat-fallback path** (no `result.json`): take the px body baseline for the confirmed canvas + delivery purpose directly from the table above (no conversion). The `body_size` in `recommendations.json` is only a stale hint once the canvas changes — use the confirmed value, not the recommendation.
| Level | Ratio to body | 32px baseline (`presentation`) | 24px baseline (`balanced`) |
|-------|---------------|---------------|---------------|
| Cover title (hero headline) | 2.5-5x | 80-160px | 60-120px |
| Chapter / section opener | 2-2.5x | 64-80px | 48-60px |
| Page title | 1.5-2x | 48-64px | 36-48px |
| Hero number (consulting KPIs) | 1.5-2x | 48-64px | 36-48px |
| Subtitle | 1.2-1.5x | 38-48px | 29-36px |
| Lead-in / intro | 1.1-1.4x | 35-45px | 26-34px |
| Subheading | 1.1-1.3x | 35-42px | 26-31px |
| **Body** | **1x** | **32px** | **24px** |
| Annotation / caption | 0.7-0.85x | 22-27px | 17-20px |
| Page number / footnote | 0.5-0.65x | 16-21px | 12-16px |
> Two baseline columns are illustrative only — for any other `body` px value (20 / 24 / 32 / ...), multiply the row's ratio. Structural roles (page title / body / subtitle / annotation / footnote) take their locked slot value and stay there on every page — not a per-page pick. In-band freedom without pre-declaring is for special / feature elements (hero number, display title, one-off emphasis); a recurring special size should be declared as its own slot. The subtitle / lead / subheading bands overlap on purpose — pick by role, not size, then hold each at one size deck-wide. Values outside **every** band require lock extension first.
> **Round recommended sizes to clean even px — don't ship ratio leftovers.** The ratios are a guide; lock each role at a **clean even px**, not the raw product. For `body` 24px that means **title 42 · subtitle 32 · lead 30 · annotation 18 · footnote 16** — never `32.4` / `18.7` / odd tails, which read as unprofessional. Snap the ratio output to the nearest even px (…14, 16, 18, 20, 24, 28, 32, 36, 42, 48…), then lock that. (The Confirm UI already snaps its per-role suggestions this way; match it on the chat-fallback path.)
> **px is literal — write the locked number verbatim (Mandatory).** `result.json` / `design_spec.md` / `spec_lock.md` / SVG all carry px as-is; there is no conversion anywhere. The size you confirm is the size you write. The Executor's `font-size` MUST be the **exact px from `spec_lock.typography`** — if `body` is `24`, write `24`; never a "rounder" or PowerPoint-familiar number (`20` / `18` / `36`). Writing a remembered pt-style value as px is the silent drift that renders a whole deck the wrong size (e.g. a `24`px body emitted as `20` ships ~17% small); the checker's spec-lock drift guard backstops it, but author it right. Per role: honor any size the user pinned as that slot's locked value; derive the rest from the ramp and snap to clean even px. (At export the px is turned back into pt by `× 0.75`, rounded to 1 decimal — that is the only place pt ever appears, and it is automatic.)
> **Hero in single-focus / breathing pages**: when one element *is* the entire page — a large number, a headline, a key phrase — it is the visual subject, not body content. Such heroes may borrow the cover-title band (2.55×); for greater emphasis, declare a hero slot in `spec_lock.md` (e.g., `hero_number` / `hero_headline`) — checker exempts declared slots with no fixed upper limit. The row above "Hero number (consulting KPIs) 1.52×" applies only to numeric KPIs in dashboard/data layouts, not to full-page focal elements.
#### Formula Rendering Policy
Formula rendering is part of Typography confirmation. Recommend one policy and let the user confirm or override it inside item g.
| Policy | Behavior | Use |
|---|---|---|
| `mixed` (default) | Render complex formula-worthy expressions to PNG; keep simple inline math as editable text / Unicode | Most academic, engineering, educational, and technical decks |
| `render-all` | Render every formula-worthy expression to PNG | Formula-heavy teaching / research decks where visual consistency matters more than editability |
| `text-only` | Do not render formulas; keep expressions as editable text / Unicode | Business decks, light technical briefs, or user preference for editability |
**Hard rule**: `$...$` / `$$...$$` in source material are input signals only. Do not scan output files for dollar-delimited formulas. After confirmation, Strategist decides which source expressions become formula assets and writes them explicitly to `images/formula_manifest.json`.
**Formula-worthy expressions**:
| Render as PNG | Keep as text |
|---|---|
| Fractions, radicals, integrals, sums, limits, matrices, multiline derivations, complex super/subscripts | `O(n log n)`, `x = 3`, single Greek letters, short inline variables, simple percentages / KPIs |
**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:
```bash
mkdir -p <project_path>/images
python3 skills/ppt-master/scripts/latex_render.py <project_path>
```
Write the manifest first at `<project_path>/images/formula_manifest.json`. Use this shape:
```json
{
"providers": ["codecogs", "quicklatex", "mathpad", "wikimedia"],
"items": [
{
"id": "formula_001",
"latex": "E = mc^2",
"display": "block",
"color": "#1D1D1F",
"background": "#FFFFFF",
"transparent": true,
"dpi": 400,
"filename": "formula_001.png"
}
]
}
```
The script renders PNGs into `images/`, trying `codecogs`, `quicklatex`, `mathpad`, then `wikimedia` unless the manifest overrides `providers`. `codecogs`, `quicklatex`, and `mathpad` preserve requested formula color; `wikimedia` is an availability fallback and may require visual checking on dark themes. Formula PNGs are transparent by default: use `background` as the temporary render matte and local background-removal reference. Set `transparent: false` only when the final formula must keep an opaque background. It writes `pixel_width`, `pixel_height`, `ratio`, `file`, `provider`, and `status` back into the manifest. Run `analyze_images.py <project_path>/images` after formula rendering so the formula PNGs are included in the same inventory pass as user images.
### h. Image Usage Confirmation
| Option | Approach | Suitable Scenarios |
|--------|----------|-------------------|
| **A** | No images | Data reports, process documentation |
| **B** | User-provided | Has existing image assets |
| **C** | AI-generated | Custom illustrations, backgrounds needed |
| **D** | Web-sourced | Real-world reference imagery, editorial support, stock-style needs (no API key required for default providers) |
| **E** | Placeholders | Images to be added later |
> 🚧 **GATE — know your resources before recommending.** `images/` is a live working folder (source-extracted pictures, user drops, later replacements), so its facts are **re-derived on use, never trusted from a stale store**. Before recommending image usage, if `images/` is non-empty, regenerate the inventory from whatever is currently in it, then read it back:
>
> ```bash
> python3 scripts/analyze_images.py <project_path>/images
> ```
>
> Read `<project_path>/analysis/image_analysis.csv` (size / ratio / category of every in-hand picture). The AE choice is still your judgment, but it MUST be made with the current inventory in full view — never in ignorance of what is already on hand. `image_analysis.csv` is a regenerated view of the live folder, not a durable fact: re-run this whenever `images/` changes.
> **Confirmed value wins.** The `image_usage` in `result.json` (or the chat reply) **overrides the recommendation here** — map it to §VIII `Acquire Via` (`ai`→`ai`, `web`→`web`, `provided`→**`user`**, `placeholder`→`placeholder`, `none`→option A, no image rows). When it is not `ai` (and the plan has no AI part), skip h.5 entirely and write no `ai` rows. See SKILL.md Step 4 for the full mapping.
**When recommending C** — surface its three implementation modes so the user knows "no API key" is a supported state:
| Mode | Trigger | Mechanism |
|---|---|---|
| **Path A** | `IMAGE_BACKEND` configured (default) | `image_gen.py` runs in Step 5 |
| **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.
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.
#### h.5 AI Image Strategy — lock rendering + palette (only when C is selected)
When the deck includes any `ai` rows, Strategist locks a **deck-wide rendering** and **deck-wide palette** here. These two values are written into `design_spec.md §III` and `spec_lock.md colors` / `images` sections, then consumed by Image_Generator. Every AI image in the deck shares them — this is what makes multiple AI images feel like one deck.
🚧 **GATE — before recommending values**: `read_file references/image-renderings/_index.md` and `read_file references/image-palettes/_index.md`. They contain the catalog, auto-selection tables, and a rendering × palette compatibility matrix.
#### Three-candidate presentation (default path)
**Hard rule**: Unless the user has already named a specific rendering or palette (chat or template), present **≥3 distinct rendering × palette combinations** and let the user pick. Never auto-lock a single combination silently.
**Per-candidate schema** (exactly 4 lines, no extras):
```
[Plan A] <temperament label><rendering> × <palette>
Visual: <shape / line / material / light, 1-2 phrases>
Color: <secondary HEX (ratio) + primary HEX (ratio) + accent HEX (ratio); HEX values from e.>
Mood: <2-3 traits>; like <real-world analogy: company / publication / event>
```
After the candidates, append one line:
```
> Reference images: see references/ai-image-comparison/ for matching PNGs by name.
```
**Hard rules for candidate construction**:
| Rule | Behavior |
|---|---|
| Filter by e.'s HEX | Only include palettes whose temperament can carry the user's HEX. Vivid red → exclude `cool-corporate` / `mono-ink`; include `vivid-launch` / `warm-earth` / `editorial-classic`. |
| HEX values in `Color` line MUST be e.'s real values | Palette contributes only the 60-30-10 ratio + role assignment. Never substitute the palette's typical HEX. |
| Span a personality spectrum | Typically: one conservative-default (industry norm), one shifted-tone (same fit, 1-2 ticks different), one bold-contrast (more expressive, may challenge default). No near-duplicates. |
| `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. |
**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.
**Worked example** (e. = `#1E3A5F` navy + `#F8F9FA` off-white + `#D4AF37` gold; d. = consulting; chat in English):
```
[Plan A] Restrained Professional — vector-illustration × cool-corporate
Visual: flat vector, solid color blocks, no gradients or shadows
Color: off-white #F8F9FA (60-70%) + deep navy #1E3A5F main (25-30%) + gold #D4AF37 accent (<5%)
Mood: steady, trustworthy, restrained gravitas; like a McKinsey consulting report
[Plan B] Editorial Depth — editorial × editorial-classic
Visual: magazine layout, 8% paper texture, column-based partitioning
Color: off-white #F8F9FA paper (55%) + deep navy #1E3A5F column (30%) + gold #D4AF37 rule line (10-14%)
Mood: refined, considered, paced; like an Economist feature spread
[Plan C] Future Energy — 3d-isometric × tech-neon
Visual: isometric 3D, soft shading, 8% glow halos around bright elements
Color: off-white #F8F9FA digital field (50%) + deep navy #1E3A5F main (35%) + gold #D4AF37 emphasis (10-15%)
Mood: forward, energetic, futuristic; like an Apple or Stripe product keynote
> Reference images: see references/ai-image-comparison/ for matching PNGs by name.
```
**Worked example — `custom × custom`** (tail-case; e.g. 新中式 deck with `#1A1A1A` + `#F5EFE0` + `#A52A2A`):
```
[Plan A] 文人雅致 — custom × custom
Visual: dry-brush burnt-ink, five tonal gradations, 宣纸 paper-grain, deliberate negative space; 朱泥 seal as single red mark
Color: cream #F5EFE0 ~65% negative space + burnt-ink #1A1A1A ~20% strokes + cinnabar #A52A2A 3-5% seal
Mood: literati restraint; like 苏州博物馆 pacing
```
`Visual` / `Color` lines feed `spec_lock.md`'s `image_*_behavior` rows verbatim.
After the user picks a candidate (or supplies a custom variant), proceed to "Recording the lock" below.
#### Prompt depth for §VIII rows
**Hard rule**: When §VIII contains paper-figure or subject-domain rows (scientific subjects, specialized fields, regulated content), each row's `generation description` follows [`image-generator.md`](./image-generator.md) §4.2 Prompt depth — expand to the depth the subject demands; 500-1000+ words is normal.
**Forbidden — generic shortening**: never drop a paper-figure row's prompt to a 50-word generic illustration brief.
---
#### Catalog reference (for candidate construction)
The tables below are source data Strategist reads when constructing the three candidates above. They are no longer the final output by themselves.
**Rendering recommendation** (soft — user may override with any other rendering from the catalog):
| `d. Style` signal | Recommended rendering | Alternates |
|---|---|---|
| Strategic / MBB / board | `editorial` or `vector-illustration` | `blueprint`, `minimalist-swiss` |
| Corporate report / analysis / 学术答辩 | `vector-illustration` | `flat`, `editorial` |
| High-end consulting / luxury / 高端 / design-firm | `minimalist-swiss` | `editorial`, `vector-illustration` |
| Tech / SaaS / AI / 架构 | `3d-isometric`, `blueprint`, `digital-dashboard` | `flat` |
| Modern SaaS / fintech / health-tech / premium app | `glassmorphism` | `digital-dashboard`, `flat` |
| Product launch / brand / marketing | `flat`, `3d-isometric`, `corporate-photo` | `vector-illustration` |
| Education / training / 教学 / 培训 | `sketch-notes` | `vector-illustration`, `paper-cut` |
| Children / storybook / 儿童 / 治愈 | `fantasy-animation` | `paper-cut`, `watercolor`, `sketch-notes` |
| Cultural / folk / festival / 文化 / 节日 | `paper-cut` | `vintage-poster`, `screen-print` |
| Methodology / Before-After / 方法论 / manifesto | `ink-notes` | `editorial` |
| Government / formal / 政务 | `editorial` or `corporate-photo` | `vector-illustration` |
| Finance / journalism / 财经 | `editorial`, `digital-dashboard` | `vector-illustration` |
| Personal story / 个人成长 / lifestyle | `watercolor`, `warm-scene` | `corporate-photo`, `paper-cut` |
| Cultural / media / opinion / cinematic | `screen-print`, `vintage-poster` | `editorial`, `warm-scene` |
| Brand heritage / hospitality / 老字号 / 周年 | `vintage-poster` | `screen-print`, `editorial` |
| Gaming / retro / 复古 / 像素 | `pixel-art` | `vintage-poster` |
| Environment / wellness / 环保 | `nature` | `watercolor`, `paper-cut` |
| Classroom / blackboard / 课堂 | `chalkboard` | `sketch-notes` |
| Team / company / product photo | `corporate-photo` | — |
**Palette recommendation** (soft — user may override):
| Content vibe / industry | Recommended palette | Alternates |
|---|---|---|
| Consulting / finance / B2B / corporate / 学术答辩 | `cool-corporate` | `editorial-classic`, `frost-ice` |
| Tech / SaaS / AI | `tech-neon` | `cool-corporate`, `dark-cinematic` |
| Modern SaaS / fintech / health-tech | `frost-ice` | `cool-corporate`, `tech-neon` |
| Health / medical / beauty / skincare | `frost-ice` | `nature-organic`, `earthy-dusty` |
| Education / training | `macaron` | `warm-earth` |
| Methodology / Before-After | `mono-ink` | `editorial-classic` |
| Personal / lifestyle / brand story | `warm-earth` | `nature-organic`, `earthy-dusty` |
| Interior / wellness / mindfulness / slow living | `earthy-dusty` | `warm-earth`, `nature-organic` |
| Product launch / marketing | `vivid-launch` | `tech-neon`, `sunset-gradient` |
| Creative agency / travel / music / lifestyle | `sunset-gradient` | `vivid-launch`, `warm-earth` |
| Luxury / fashion / jewelry / premium / heritage | `jewel-tone` | `dark-cinematic`, `editorial-classic` |
| Children / storybook | `macaron` | `warm-earth` |
| Premium / film / entertainment | `dark-cinematic` | `jewel-tone`, `duotone` |
| Cultural / media / cover-art | `duotone` | `editorial-classic` |
| Environment / wellness | `nature-organic` | `warm-earth`, `earthy-dusty` |
| Finance / journalism | `editorial-classic` | `cool-corporate` |
After auto-selecting, cross-check `image-palettes/_index.md` compatibility matrix — if rendering × palette is `✗`, swap to the alternate palette.
**d-e-f-g linkage sanity check** (do this after picking rendering + palette):
| Linkage | What to verify |
|---|---|
| **d. Style ↔ rendering** | Rendering family should match the Style descriptor's temperament (corporate ≠ sketch-notes; tech ≠ watercolor). Already enforced by the recommendation table above. |
| **e. Color HEX ↔ palette** | HEX is truth — palette is just the "how to use these HEX" rulebook for AI images (saturation / contrast / 60-30-10 / material). Mismatch → **always swap palette to fit the HEX, never adjust the HEX to fit a palette**. E.g. user gives a vivid red but you auto-picked cool-corporate — switch to vivid-launch or warm-earth, do not propose dimming the red. |
| **f. Icon library ↔ rendering** | `tabler-outline` pairs well with all renderings (most versatile). `chunk-filled` / `tabler-filled` pair better with `vector-illustration` / `flat` / `editorial`. `phosphor-duotone` pairs with `flat` / `digital-dashboard`. Mismatch is not fatal but worth flagging. |
| **g. Typography ↔ rendering** | Serif title → pairs well with `editorial`, `corporate-photo`, `screen-print`. Hand-lettered direction → already implied by `sketch-notes` / `ink-notes` (the rendering carries the lettering, no separate font requirement). Display font → `vivid-launch` / `screen-print`. Mismatch is rarely fatal; note in conversation if it feels off. |
**Recording the lock** — after picking, write to:
- `design_spec.md §III Visual Theme` — add lines under the color table:
```
- **Image Rendering**: vector-illustration
- **Image Palette**: cool-corporate
```
- `spec_lock.md colors` section — add rows at the bottom:
```
- image_rendering: vector-illustration
- 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:
```
- image_rendering: custom
- image_rendering_behavior: "Dry-brush burnt-ink with five tonal gradations, 宣纸 paper-grain at 12% opacity, deliberate negative space; 朱泥 seal as a single red mark; no Western outlines, no gradients."
- image_palette: custom
- image_palette_behavior: "宣纸 cream `#F5EFE0` carries ~65% as negative space; burnt-ink `#1A1A1A` anchors ~20% as brush strokes; cinnabar `#A52A2A` only in 3-5% as seal. Literati restraint — no fourth color."
```
Image_Generator reads these fields and applies them deck-wide. If both are absent (legacy decks), it falls back to inferring from `d. Style` and `e. Color` — quality is acceptable but not optimal. Always lock both when C is selected.
#### hero_page suggestion (same confirmation turn)
After the user picks a candidate, scan the outline and surface any pages where the image makes more sense as the page's main voice than as a local block. Present them as a short list and let the user confirm, edit, or skip. Result is recorded as `page_role: hero_page` on the matching `ai` rows. Density is judgment-based — no fixed quota.
**Per hero_page title**: lock where it lives — `embedded` (fused into the image: neon, carved, smoke, 3D-lit lettering) or `none` (editable SVG title over an atmospheric backdrop, Primitive D). Default `none`; flip to `embedded` only when the words must be *part of the visual*, not merely a display font. Per page — may bake only the keyword while subtitle / date / chrome stay SVG. Surface it with the hero_page list for the same confirm / edit / skip.
**When selection includes B**, you must run `python3 scripts/analyze_images.py <project_path>/images` before outputting the spec, and integrate scan results into the image resource list.
**When B / C / D / E is selected**, add an image resource list to the spec:
| Column | Description |
|--------|-------------|
| Filename | e.g., `cover_bg.png` |
| Dimensions | e.g., `1280x720` |
| Ratio | e.g., `1.78` |
| Layout suggestion | e.g., `Wide landscape (suitable for full-screen/illustration)` |
| **Layout pattern** | **MANDATORY** — one or more `#<id> <name>` joined by ` + ` from `image-layout-patterns.md`. Combine a Primary id with optional Modifier ids when the page needs it (e.g. `#48 side-by-side comparison + #21 rounded rectangle crop + #29 two-stop scrim`). A single Primary is fine when the page calls for it. See the GATE earlier in this section. Empty cells or invented ids are invalid. |
| Purpose | e.g., `Cover background` |
| Type | Free-form category tag — `Background`, `Photography`, `Illustration`, `Diagram`, `Portrait`, `Latex Formula`, etc. Required for formula rows (`Latex Formula`). |
| **Acquire Via** | `ai` / `web` / `user` / `formula` / `placeholder` — only `ai` and `web` drive Step 5 dispatch |
| Status | Initial status must be `Pending`, `Existing`, `Rendered`, or `Placeholder`; see [`svg-image-embedding.md`](svg-image-embedding.md) for the full status enum |
| **Reference** | Free-form **intent description** (NOT a search query); feeds Image_Generator (ai) or Image_Searcher (web) |
| `text_policy` (optional, `ai` rows only) | `none` (no text in image) or `embedded` (text is part of the artwork). Leave blank when Image_Generator should decide per row. Long body / data / lists stay in SVG. |
| `page_role` (optional, `ai` rows only) | `local` (image is a region block on an SVG page) or `hero_page` (image is the page's main voice). Leave blank when Image_Generator should decide per row. |
**No-crop flag (exception only)**: most images are croppable — Executor defaults to `preserveAspectRatio="xMidYMid slice"`. When an image must NOT lose pixels (data screenshots, charts, certificates, contracts, dense diagrams), append `no-crop` to its `spec_lock.md images` entry. Executor will then size the container to the native ratio and use `meet`. Don't tag the rest.
**Formula rows**: rendered LaTeX PNGs are image rows with `Acquire Via: formula`, `Status: Rendered`, and `Type: Latex Formula`. Always append `no-crop` in `spec_lock.md images`. They are not AI images and never go through Step 5.
**Reference field**: Write visual intent, not provider mechanics.
| ✅ Intent description | ❌ Avoid |
|---|---|
| "Diverse engineering team collaborating around a laptop, modern office, natural light" | "team laptop office" |
| "Abstract atmospheric backdrop for academic-defense cover, calm center for text overlay, hint of campus skyline" | "use openverse, search 'office'" |
| "Sunlit forest path in autumn" | "team photo" |
**Per-row Reference grammar**:
| Acquire Via | Reference pattern |
|---|---|
| `ai` | **Subject + intent + composition** only. Do NOT repeat style words ("flat design", "modern", "vector") or HEX values — both are already locked deck-wide by h.5 (rendering + palette) and `design_spec §III` (colors). Image_Generator's prompt assembler injects them automatically. |
| `web` | Concrete subject/place/object first, then 1-3 quality descriptors |
| `formula` | Original LaTeX plus short placement intent, e.g. `formula_001: block energy-mass equation for P03` |
**Allowed web quality descriptors**:
| Descriptor | Use |
|---|---|
| `professional editorial photography` | Stock-style photography |
| `clean composition` | Covers, section dividers, image-text layouts |
| `natural light` | People, workplace, travel, lifestyle scenes |
| `high-resolution` | Large visual areas |
**Forbidden — web negative prompts**: `not tourist snapshot`, `no phone photo`, `avoid amateur style`.
| Mode | Good Reference |
|---|---|
| `web` | "Diverse team collaborating at a modern office desk, professional editorial photography, natural light, laptop visible" |
| `ai` | "Atmospheric backdrop suggesting digital innovation; calm central area reserved for slide title overlay; light geometric anchor at one edge" |
| `ai` | "Four-stage value chain from raw input to R&D output; icons should suggest tax-form → cost-reduction → equipment-upgrade → innovation; no text labels (SVG overlays them)" |
🚧 **GATE — before writing §VIII Image Resource List**: when image approach is B/C/D/E (anything other than A "no images"), this is a three-layer hard requirement, not a suggestion:
1. **Read**`read_file references/image-layout-patterns.md`. The file enumerates 72 numbered techniques split into **Part 1 — Primary Structures** (#1#19 container layouts, #38#46 image-as-canvas + native overlay, #47#56 multi-image) and **Part 2 — Modifier Layers** (#20#26 non-rectangular crops, #27#37 overlays & masks, #57#61 texture, #62#72 special). The four `Image narrative intent` values below cover only broad categories.
2. **Produce** — every non-formula row in §VIII Image Resource List MUST fill the `Layout pattern` column with one or more `#<id> <name>` joined by ` + ` drawn verbatim from this file (Primary + optional Modifiers). Rows with empty cells, paraphrased names, or invented ids are invalid. Formula rows are the only exception; use `formula-inline` or `formula-block`.
3. **Image-as-canvas coverage** — for any deck with ≥4 image-bearing pages, at least one page MUST use a `#38#46` pattern (image-as-canvas + native overlay) unless every image is a pure cover / chapter divider / atmosphere backdrop. This family is the most-skipped one and is usually the right answer for content-rich pages with photographs. If the deck legitimately has no opportunity for it, state the reason in §VIII directly under the table.
**Skip-detection signal for self-audit**: if you notice that every page's `Layout pattern` column resolves to #2/#3 (left-third or right-third), #5/#6 (top-bottom band), or generic side-by-side, you have not actually consulted the file — re-read and reconsider. The default left/right and top/bottom split bias is the failure mode this gate exists to break.
**Skip-detection signal — `text_policy` column**: if every `ai` row resolves to `none` and the deck contains any paper-figure / academic schematic / panel-comparison / data-axis page, you defaulted instead of judging per row. Consult [`image-generator.md`](image-generator.md) §5.3 positive-trigger table and re-decide each row. `none` for every row is correct only when no row matches a trigger; otherwise this is the same class of failure as the layout-pattern signal above.
**Image narrative intent** (decide *before* the ratio table — determines whether the image lives in a container at all):
| Intent | Form | When to use |
|--------|------|-------------|
| **Hero / full-bleed** | Image fills canvas/dominant zone; title floats over with gradient or opacity overlay | Covers, chapter dividers, `breathing` pages — image *is* the message |
| **Atmosphere / background** | Image as low-contrast backdrop (reduced opacity or dark overlay); text reads on top | Section backgrounds, mood-setting — image sets tone, text carries info |
| **Side-by-side** | Image and text as adjacent coequal blocks — ratio table below governs container sizing | Most content pages — image and text read together |
| **Accent / inline** | Small image beside related text, not a container; no ratio matching | Supporting visuals, spot illustrations |
> Intent follows narrative purpose, not image ratio. Don't default every image page to side-by-side.
**Side-by-side ratio alignment** (consult only when the chosen intent is *side-by-side*; detailed calculation rules in `references/image-layout-spec.md`):
| Image Ratio | Recommended Container Layout |
|-------------|-----------------------------|
| > 2.0 (ultra-wide) | Top-bottom split, top full-width |
| 1.5-2.0 (wide) | Top-bottom split |
| 1.2-1.5 (standard landscape) | Left-right split |
| 0.8-1.2 (square) | Left-right split |
| < 0.8 (portrait) | Left-right split, image on left |
Side-by-side only: container ratio must match image ratio. Hero / atmosphere / accent intents ignore ratio alignment.
> **Portrait canvases** (Xiaohongshu, Story): Layout rules differ — top-bottom is preferred for most ratios since left-right columns become too narrow. See "Portrait Canvas Override" in `references/image-layout-spec.md`.
> **Multi-image slides**: When multiple images appear on one page, use the grid formulas in the "Multi-Image Layout" section of `references/image-layout-spec.md`.
> **Pipeline handoff**: When C) AI generation is selected, Image_Generator consumes `Pending` rows and updates them to `Generated` or `Needs-Manual` before Executor proceeds. Status names are defined in [`svg-image-embedding.md`](svg-image-embedding.md).
### Template Match — Visualization + Structural Patterns (Non-blocking — Strategist recommends, no user confirmation needed)
The catalog covers **both data charts and structural information designs**. A "match" is not limited to numeric pages — any page whose content shape matches a `Pick for ...` clause is a candidate:
- **Data-type pages**: comparisons, trends, proportions, KPIs, financials, rankings, distributions, conversion funnels
- **Structural-type pages**: team rosters, agendas, principles & values, methodology phases, customer journey, capability maps, OKR cascades, roadmaps, strategic frameworks (SWOT / BCG / PEST / Porter's Five Forces / Value Chain — matched via `quadrant_text_bullets`, `quadrant_bubble_scatter`, `vertical_pillars`, `hub_inward_arrows`, `chevron_chain_with_tail` respectively)
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.
> - 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.
> 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.
>
> **Source vocabulary mismatch** — the catalog is in English. When source content uses Chinese / industry jargon ("中台", "架构图", "述职", "管道", "前后端"), translate the intent first, then match against summaries. The catalog deliberately keeps no keyword index — full-read forces semantic matching rather than lexical grep.
>
> **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
>
> | Page | Template | Path | Summary-quote (verbatim) | Usage |
> | ---- | ------------- | --------------------------------- | ------------------------ | ----- |
> | P03 | bar_chart | templates/charts/bar_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> |
>
> Runners-up considered (3 entries minimum, drawn from real second-best matches):
> - <key_A> | rejected for P03: <reason citing this deck's specifics>
> - <key_B> | rejected for P07: <reason>
> - <key_C> | rejected for P11: <reason>
> ```
> The `summary-quote` must be copy-pasted from `charts_index.json` — paraphrasing or summarizing breaks the audit. Every template name listed (selected or rejected) must `grep` cleanly inside `charts_index.json` (so misspelled or invented keys fail). If fewer than 3 visualization pages exist, list what exists and note "fewer than 3 viz pages"; runners-up still required for each page that does exist.
>
> **Fallback when no template fits**:
> 1. Re-read the full summary list with the page's intent re-stated in plain language — "non-obvious" matches often surface on the second pass (e.g. "causal chain" → `process_flow` or `sankey_chart`).
> 2. If still no fit: data-driven content → table layout; conceptual/illustrative → "AI-generated image" (Image_Generator handles); structural → "custom layout".
> 3. Mark the page `no-template-match` in section VII with the fallback chosen and why. Do NOT silently substitute a close-but-wrong chart.
### Speaker Notes Requirements (Default — no discussion needed)
- File naming: Recommended to match SVG names (`01_cover.svg` → `notes/01_cover.md`), also compatible with `notes/slide01.md`
- Fill in the Design Spec: total presentation duration, notes style (formal / conversational / interactive), presentation purpose (inform / persuade / inspire / instruct / report)
- Split note files must NOT contain `#` heading lines (`notes/total.md` master document MUST use `#` heading lines)
---
## 2. Mode & Visual-Style Catalogs (Reference for Confirmation Item d)
Confirmation `d` locks two independent catalog items:
- **Mode** — narrative skeleton: [`modes/_index.md`](./modes/_index.md) → `pyramid` / `narrative` / `instructional` / `showcase` / `briefing`.
- **Visual style** — aesthetic: [`visual-styles/_index.md`](./visual-styles/_index.md) → presets + `custom`.
Read the relevant `_index.md` at confirmation `d` (Layer 1 / Layer 2) for its catalog table and auto-selection. Executor loads the locked mode + visual-style files at generation (see SKILL Step 6).
---
## 3. Color Knowledge Base
### Consulting Brand Colors
| Brand | HEX |
|-------|-----|
| Deloitte Blue | `#0076A8` |
| McKinsey Blue | `#005587` |
| BCG Dark Blue | `#003F6C` |
| PwC Orange | `#D04A02` |
| EY Yellow | `#FFE600` |
### Versatile / General Colors
| Style | HEX |
|-------|-----|
| Tech Blue | `#2196F3` |
| Vibrant Orange | `#FF9800` |
| Growth Green | `#4CAF50` |
| Professional Purple | `#9C27B0` |
| Alert Red | `#F44336` |
### Data Visualization Colors
- Positive trend (green): `#2E7D32``#4CAF50``#81C784`
- Warning trend (yellow): `#F57C00``#FFA726``#FFD54F`
- Negative trend (red): `#C62828``#EF5350``#E57373`
---
## 4. Layout Pattern Library
> **Principle — proportion follows information weight, not preset ratios.** Combine patterns, break the grid for `breathing` pages, or propose new patterns. Defaulting every page to symmetric grid produces the "AI-generated" look.
| Pattern | Suitable Scenarios | PPT 16:9 Reference Dimensions |
|--------|-------------------|-------------------------------|
| Single column centered | Covers, conclusions, key points | Content width 800-1000px, horizontally centered |
| Symmetric split (5:5) | Comparisons where two sides carry equal weight | Column ratio 1:1, gap 40-60px |
| Asymmetric split (3:7 / 2:8) | One side dominates — chart vs. takeaway, image vs. caption | Heavier side 840-1024px, lighter side 256-440px |
| Three-column | Parallel points, process steps | Column ratio 1:1:1, gap 30-40px |
| Four-quadrant / matrix | Two-axis classification, strategic quadrants | Quadrant 560x250px, gap 20-30px |
| Top-bottom split | Ultra-wide images + text, processes, timelines | Image full-width, text area >= 150px height |
| Z-pattern / waterfall | Storytelling, case studies — blocks alternate left/right | Guide eye in Z; 3-5 alternating blocks |
| Center-radiating | Core concept + surrounding nodes | Center element 200-300px, 4-6 satellite nodes |
| Full-bleed + floating text | `breathing` / feature pages | Image fills 1280x720, text floats over opacity overlay |
| Figure-text overlap | Hero moments — headline over/against image edge | Text partially overlaps image, not beside it |
| Negative-space-driven | Single element in 40-60% whitespace | One idea, weight through emptiness |
**PPT 16:9 (1280x720) key dimensions**: Safe area 1200x640 (40px margins); Title area 1200x100; Content area 1200x500; Footer area 1200x40.
---
## 5. Template Flexibility Principle
Templates are starting points. The Strategist may adjust based on content and audience:
1. Font size ratios — reference values, adjustable
2. Color schemes — customize per brand/content
3. Layout patterns — combine, nest, or break (§4 lists 11 patterns as reference, not exhaustive)
4. 12-chapter framework — expand or reduce
5. Spacing / border radius — Executor adjusts per content density and `page_rhythm`
---
## 6. Workflow & Deliverables
### 6.1 Content Planning Strategy
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 | Per-page density & treatment | §IX content per page | page_rhythm lean |
|---|---|---|---|
| `text` · read-close | dense — pack more per page, fuller layouts | prose paragraphs, more blocks, tables / fine detail; complete sentences | leans `dense` |
| `balanced` · business (default) | balanced | one primary + supporting points; moderate text | mixed |
| `presentation` | sparse — one idea per page, generous whitespace | keywords / short phrases, a single core message, large visual; never paragraph dumps | leans `anchor` / `breathing` |
This is what makes the axis meaningful: a `presentation` deck and a `text` deck built from the **same source** must differ in per-page text volume, layout density, and rhythm — **not only in font size**. Page count (item b) stays the user's call; delivery purpose governs the **density and treatment within it**, and informs the page-count recommendation when the user has not fixed one. Record the chosen purpose in `design_spec.md §I`. The `page_rhythm` leans are a bias, not a quota — the filler-page ban and "rhythm follows narrative" rule still hold. (Preservation paths — beautify / template-fill — keep source structure verbatim: honor purpose only in styling, never to re-paginate.)
**Per-block expression**: phrase each §IX content block in the mode that fits it — prose, bullet, keyword, or any phrasing the content calls for — not a default bullet. Take the cue from the source's texture: a narrative source (article / transcript / talk) leans prose — resist compressing its argument pages into fragments; a data sheet leans bullet/keyword. Write the real sentence into §IX itself, not a skeleton point to expand later. One page mixes modes; let layout pull each (narrative → prose, structural/chart → bullets/keywords).
> Note: §IX is the only content copy the Executor re-reads after context compression — what you write there is what survives.
### 6.2 Outline Output Specification (Must include 11 chapters)
| Chapter | Content Requirements |
|---------|---------------------|
| I. Project Information | Project name, canvas format, page count, style, audience, scenario, delivery purpose, date |
| II. Canvas Specification | Format, dimensions, viewBox, margins, content area |
| III. Visual Theme | Style description, light/dark theme, tone, color scheme (with HEX table), gradient scheme |
| IV. Typography System | Font plan (per-role families — title / body / emphasis / code), font size hierarchy |
| V. Layout Principles | Page structure (header/content/footer zones), layout pattern library (combine/break as content demands), spacing spec |
| VI. Icon Usage Spec | Source description, placeholder syntax, recommended icon list |
| VII. Visualization Reference List | Visualization type, reference template path, used-in pages, purpose |
| VIII. Image Resource List | Filename, dimensions, ratio, purpose, status, generation description |
| IX. Content Outline | Grouped by chapter; each page includes layout, title, core message (the page's one idea), content blocks (in the selected phrasing mode), visualization type (if applicable) |
| X. Speaker Notes Requirements | File naming rules, content structure description |
| XI. Technical Constraints Reminder | SVG generation rules, PPT compatibility rules |
**Generation steps**:
1. Read reference template: `templates/design_spec_reference.md`
2. Generate complete spec from scratch based on analysis
3. Save to: `projects/<project_name>.../design_spec.md`
4. **Generate execution lock**: read `templates/spec_lock_reference.md` and produce `projects/<project_name>.../spec_lock.md` — a distilled, machine-readable short form of the color / typography / icon / image / **page_rhythm** / **page_layouts** / **page_charts** decisions above. This file is what the Executor re-reads before every page (see [executor-base.md](executor-base.md) §2.1). The values in `spec_lock.md` MUST exactly match the decisions recorded in `design_spec.md`; if they ever diverge, `spec_lock.md` wins and `design_spec.md` should be treated as historical narrative.
- **page_rhythm is mandatory**: Based on the page list in §IX Content Outline, assign each page one of `anchor` / `dense` / `breathing` (see `spec_lock_reference.md` for the full vocabulary). This is what breaks the uniform "every page is a card grid" feel — without it the Executor defaults all pages to `dense`.
- **Rhythm follows narrative, not quota**: `breathing` pages mark natural pauses — chapter transitions, standalone emphasis (hero quote / big number), SCQA bridges. Dense decks may legitimately be all `dense`. **Do NOT invent filler pages** ("Thank you", empty dividers) to pad rhythm — every `breathing` page must say something independent. Delivery purpose biases the overall lean (`presentation` toward more `anchor` / `breathing`, `text` toward `dense`; see §6.1) — a bias, never a quota.
- **Cover impact is mandatory**: Page `P01` is the deck's first visual contract, not a generic title slide. In `design_spec.md §IX`, add a `Cover impact` line for `P01` that names one concrete hook and one concrete composition strategy. Use the source's strongest available signal: a provocative core claim, object / scene metaphor, hero number, founder / product / audience moment, or a distilled conflict. Pair it with one concrete composition strategy — such as `full-bleed image + floating title`, `typographic poster`, `hero object`, `data hook`, `editorial scene`, `high-contrast abstract geometry`, or a fresh composition the deck's subject suggests (these are starting points, not the allowed set). If no external or AI image is available, still specify a native-SVG visual hook; do not fall back to "title + subtitle + decorative background". (Beautify / template-fill keep the source cover verbatim — this rule does not apply on those preservation paths.)
- **Cover rhythm lock**: `P01` remains `anchor` in `spec_lock.md page_rhythm`, but its §IX `Cover impact` must prevent content-page patterns. Do not plan multi-card grids, agenda-like bullets, or equal-weight columns on the cover unless a template explicitly requires that structure, or a preservation path (beautify / template-fill) is transcribing the source cover verbatim.
- **Closing impact (only when the deck closes)**: the deck's last page is its final visual contract — the strongest impression after the cover. When the deck genuinely lands on a conclusion / call-to-action / final-takeaway page, give it a `Closing impact` line in §IX: name the one thing the audience should leave with (a distilled takeaway, a forward call, a memorable restatement of the core claim) + one composition that delivers it — never a generic "Thank you" / contact-only slide or a centered-title reprise of the cover. **Do NOT invent a closing page to satisfy this** — the filler-page ban above still holds; apply it only to the page where the deck actually resolves. Same exemptions as the cover: skip on template / beautify / template-fill preservation paths.
- **page_layouts (write only when a template is in use)**: For each page that inherits a template SVG, add `P<NN>: <svg_basename>` (e.g., `P04: 03a_content_image_text`). Pages designed freely get **no entry** — Executor reads the absence as "free design, no inheritance". If zero pages use a template, omit the section entirely.
- **page_charts (write only for chart pages that match a catalog template)**: For each page in `design_spec.md §VII` whose `reference template path` points to `templates/charts/<name>.svg`, add `P<NN>: <chart_name>`. Pages with `no-template-match` in §VII MUST NOT appear here (Executor would look for a non-existent reference). If the deck has no data-visualization pages, omit the section.
- **Hard rule**: Use both `page_layouts` and `page_charts` for the same page only when the layout template is a compatible shell for the chart. Do not pair chart pages with conflicting page layouts (e.g., `waterfall_chart` + timeline layout, KPI cards + circle-diagram layout). If no compatible layout exists, omit the page from `page_layouts`.
---
## 7. Project Folder
Project folder must exist before Strategist runs. If not, execute:
```bash
python3 scripts/project_manager.py init <project_name> --format <canvas_format>
```
Save outputs to `projects/<project_name>_<format>_<YYYYMMDD>/design_spec.md`.
---
## 8. Complete Design Spec and Prompt Next Steps
After writing `design_spec.md` and `spec_lock.md`, output the next-step prompt below. This is a handoff instruction, not part of `design_spec.md`. Pick the variant by whether Step 3 copied a template into `<project_path>/templates/`.
### Template mode (template applied in Step 3)
```
✅ Design spec complete. Template ready.
Next step:
- Images include AI generation → Invoke Image_Generator
- Otherwise → Invoke Executor
```
### Free design (default, no template)
```
✅ Design spec complete.
Next step:
- Images include AI generation → Invoke Image_Generator
- Otherwise → Invoke Executor (free design for every page)
```

View File

@ -0,0 +1,187 @@
> See shared-standards.md for common technical constraints.
# SVG Image Embedding Guide
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.
```markdown
| Filename | Dimensions | Purpose | Type | Acquire Via | Status | Reference |
|----------|------------|---------|------|-------------|--------|-----------|
| cover_bg.png | 1280x720 | Cover background | Background | ai | Pending | Modern tech abstract, deep blue gradient |
| team.jpg | 800x600 | Team photo | Photography | web | Pending | Diverse engineering team in modern office |
| product.png | 600x400 | Page 3 product photo | Photography | user | Existing | - |
| formula_001.png | 736x168 | Page 3 block equation | Latex Formula | formula | Rendered | `E = mc^2` |
| chart.png | 600x400 | Page 5 placeholder | Illustration | placeholder | Placeholder | Team collaboration scene to be added later |
```
### Image Status Enum
| Status | Meaning | Executor Handling |
|--------|---------|-------------------|
| **Pending** | Acquisition needed (`Acquire Via: ai` or `web`); not yet attempted | Image Acquisition Phase (Step 5) consumes this; must not remain after Step 5 |
| **Generated** | AI-generated file exists at expected path | Reference from `../images/`; no on-slide credit needed |
| **Sourced** | Web-sourced file exists at expected path | Reference from `../images/`; check `image_sources.json` for `license_tier` — if `attribution-required`, render an inline credit element on the slide (see [executor-base.md §6](./executor-base.md) and [image-searcher.md §7](./image-searcher.md) for the visual spec) |
| **Rendered** | Deterministic formula PNG exists at expected path (`Acquire Via: formula`) | Reference from `../images/`; use `preserveAspectRatio="xMidYMid meet"` and do not crop |
| **Needs-Manual** | Acquisition attempted once + one retry, failed | Dashed placeholder unless user has manually supplied the file |
| **Existing** | User already has image (`Acquire Via: user`) | Place in `images/`, reference with `<image>` |
| **Placeholder** | Intentionally not prepared yet (`Acquire Via: placeholder`) | Dashed border placeholder; replace later |
---
## Workflow
```
1. Strategist defines image needs → Add image resource list with Acquire Via + Status per row
2. Image Acquisition (Step 5):
- Pending + ai → Image_Generator runs image_gen.py → Generated
- Pending + web → Image_Searcher runs image_search.py → Sourced
- formula / user / placeholder rows are skipped
3. Executor generates SVGs (svg_output/)
├── Existing / Generated → <image href="../images/xxx.png" .../>
├── Sourced + license_tier=no-attribution → <image href=...> only
├── Sourced + license_tier=attribution-required → <image href=...> + small <text> credit element on the slide
├── Rendered formula → <image href="../images/formula_001.png" preserveAspectRatio="xMidYMid meet" .../>
└── Placeholder / Needs-Manual without file → Dashed border + description text
4. Preview: python3 -m http.server -d <project_path> 8000 → /svg_output/<filename>.svg
5. Post-processing & Export → follow shared-standards.md §5
```
> Keep external references in `svg_output/` during generation. `finalize_svg.py` auto-embeds images into `svg_final/`; export PPTX from `svg_final/`.
---
## External Reference vs Base64 Embedding
| Method | Pros | Cons | Suitable For |
|--------|------|------|-------------|
| **External reference** | Small file size, fast iteration, easy to replace | Preview requires HTTP server from project root | `svg_output/` development phase |
| **Base64 embedding** | Self-contained file, stable export | Large file size | `svg_final/` delivery phase |
---
## Method 1: External Reference (Recommended for Generation Phase)
### Syntax
```xml
<image href="../images/image.png" x="0" y="0" width="1280" height="720"
preserveAspectRatio="xMidYMid slice"/>
```
### Key Attributes
| Attribute | Description | Example |
|-----------|-------------|---------|
| `href` | Image path (relative or absolute) | `"../images/cover.png"` |
| `x`, `y` | Image top-left corner position | `x="0" y="0"` |
| `width`, `height` | Image display dimensions | `width="1280" height="720"` |
| `preserveAspectRatio` | Scaling mode | `"xMidYMid slice"` |
### preserveAspectRatio Common Values
| Value | Effect |
|-------|--------|
| `xMidYMid slice` | Center crop (similar to CSS `cover`) |
| `xMidYMid meet` | Complete display (similar to CSS `contain`) |
| `none` | Stretch to fill, no aspect ratio preservation |
### Preview Method
Browser security blocks external images on directly opened SVGs. Serve via HTTP from the project root:
```bash
python3 -m http.server -d <project_path> 8000
# Visit http://localhost:8000/svg_output/your_file.svg
```
---
## Method 2: Base64 Embedding (Recommended for Delivery Phase)
### Syntax
```xml
<image href="data:image/png;base64,iVBORw0KGgo..." x="0" y="0" width="1280" height="720"/>
```
### MIME Types
| MIME Type | File Format |
|-----------|-------------|
| `image/png` | PNG |
| `image/jpeg` | JPG/JPEG |
| `image/gif` | GIF |
| `image/webp` | WebP |
| `image/svg+xml` | SVG |
---
## Conversion Process
Use the unified pipeline in [shared-standards.md §5](shared-standards.md). `finalize_svg.py` runs before export so image references in `svg_output/` become embedded assets in `svg_final/`.
```bash
python3 scripts/finalize_svg.py <project_path>
python3 scripts/svg_to_pptx.py <project_path>
```
### Standalone: 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
```
---
## Best Practices
### Image Optimization
Compress before embedding to reduce file size:
```bash
convert input.png -quality 85 -resize 1920x1080\> output.png # ImageMagick
pngquant --quality=65-80 input.png -o output.png # pngquant (recommended)
```
### File Organization
```
project/
├── images/ # Image assets
├── sources/ # Source files and their accompanying images
│ └── article_files/
├── svg_output/ # Raw version (external references)
└── svg_final/ # Final version (images embedded)
```
### Rounded Corner / Non-rectangular Image Cropping
`clipPath` **on `<image>` elements** is conditionally allowed — authoritative constraints in [shared-standards.md §1.2](shared-standards.md); do not restate or relax here.
Fallback when `clipPath` doesn't fit: bake rounded corners into the source image (PNG with alpha) before embedding.
---
## FAQ
**Q: Can't see images when opening SVG directly?**
Browser security blocks cross-directory requests. Serve via HTTP from project root, or run `finalize_svg.py` first and view from `svg_final/`.
**Q: Base64 file too large?**
Compress the source, use JPEG, reduce resolution to match actual display dimensions.
**Q: How to reverse-extract a Base64 image?**
```bash
base64 -d image.b64 > image.png
```

View File

@ -0,0 +1,104 @@
# Visual Styles — Index
A **visual style** is how the deck **looks** — shape language, decoration density, whitespace rhythm, typographic character, texture / elevation. Lock **one per deck**; it anchors the aesthetic of the SVG layout itself (cards, dividers, spacing, corner radius, shadow use).
> **Styles carry NO HEX and lock no palette.** Color truth lives in `design_spec.colors` / `spec_lock.colors` (confirmation `e`); color *behavior* lives in [`image-palettes/`](../image-palettes/). A visual style only describes how the deck's existing colors are *used* — never which colors. (Same discipline as [`image-renderings/`](../image-renderings/) for AI images.)
>
> A visual style is *not* a mode. **Visual style = how it looks; mode = how you argue** (see [`modes/_index.md`](../modes/_index.md)). Locked independently — any style pairs with any mode.
---
## 1. Catalog
Each style has its own file with: shape & decoration, typography character, color-usage discipline (no HEX), texture / elevation, and the paired image-rendering. **Read only the file for the style you lock** — never glob the directory. The catalog mirrors [`image-renderings`](../image-renderings/_index.md): each style's "Paired rendering" names the illustration family that shares its aesthetic.
> The **`visual_style` value is only ever a first-column `id`** (`swiss-minimal`, `editorial`, …). The "Paired rendering" column lists **§h image-rendering** names (`flat`, `minimalist-swiss`, `digital-dashboard`, …) — never lock one of those as the `visual_style`; they belong to confirmation h.
### 1.1 Corporate / product
| Visual style | Character | Best for | Paired rendering |
|---|---|---|---|
| [`swiss-minimal`](./swiss-minimal.md) | Grid-locked, sharp, aggressive whitespace, no decoration | High-end consulting, architecture, type-led | `minimalist-swiss` |
| [`soft-rounded`](./soft-rounded.md) | Rounded cards, gentle elevation, approachable | Product, SaaS, training, consumer | `flat` |
| [`glassmorphism`](./glassmorphism.md) | Translucent glass panels, gradient light, floating depth | Modern SaaS, fintech, product launches, AI demos | `glassmorphism` |
| [`dark-tech`](./dark-tech.md) | Dark canvas, glow accents, geometric precision | Tech, AI, data products, launches | `digital-dashboard` |
| [`blueprint`](./blueprint.md) | Schematic line work on dark paper, isometric, annotated | Technical briefings, architecture, engineering | `blueprint` |
### 1.2 Editorial / publication
| Visual style | Character | Best for | Paired rendering |
|---|---|---|---|
| [`editorial`](./editorial.md) | Magazine hierarchy, rules & columns, serif/sans interplay | Finance, journalism, analysis, explainers | `editorial` |
| [`photo-editorial`](./photo-editorial.md) | Full-bleed photography dominates, text points & captions | Architecture, design, fashion, culture, photo-led | `corporate-photo` |
| [`data-journalism`](./data-journalism.md) | Multi-column micro-charts, sidebars, source lines, dense | Finance, market reviews, research, data reports | `editorial` |
| [`brutalist`](./brutalist.md) | Newsprint density, ruled boxes, raw structure, flat | Annual reviews, research digests, manifestos | `screen-print` / `editorial` |
### 1.3 Expressive / print
| Visual style | Character | Best for | Paired rendering |
|---|---|---|---|
| [`memphis`](./memphis.md) | Clashing color blocks, geometric confetti, bold outlines | Festivals, consumer, youth, launch hype | `flat` |
| [`zine`](./zine.md) | Riso misregistration, halftone, limited palette, print grit | Culture, design talks, indie brands | `screen-print` |
| [`vintage-poster`](./vintage-poster.md) | Mid-century flat blocks, halftone, retro-geometric warmth | Heritage, hospitality, cultural, anniversaries | `vintage-poster` |
| [`paper-cut`](./paper-cut.md) | Layered cut-paper sheets, soft inter-layer shadow, tactile | Cultural / folk, children, festival, sustainability | `paper-cut` |
### 1.4 Hand-drawn / brush
| Visual style | Character | Best for | Paired rendering |
|---|---|---|---|
| [`sketch-notes`](./sketch-notes.md) | Warm paper, doodle line work, soft pastel blocks | Education, training, onboarding, knowledge | `sketch-notes` |
| [`ink-notes`](./ink-notes.md) | Pale field, black hand-ink, sparse semantic accent | Methodology, before/after, manifestos | `ink-notes` |
| [`chalkboard`](./chalkboard.md) | Dark slate, chalk strokes, powdery pastel accents | Teaching, tutorials, classroom, academic | `chalkboard` |
| [`ink-wash`](./ink-wash.md) | Rice-paper whitespace, brush marks, seal accent, still | Cultural, philosophy, heritage, 新中式 | `ink-notes` / `watercolor` |
### 1.5 Specialty
| Visual style | Character | Best for | Paired rendering |
|---|---|---|---|
| [`pixel-art`](./pixel-art.md) | Strict pixel grid, blocky forms, limited palette, flat | Gaming, retro-tech, nostalgic, game-flavored | `pixel-art` |
---
## 2. Auto-selection — content vibe / industry → style
| Signal | Recommended style | Alternates |
|---|---|---|
| High-end consulting / architecture / luxury / minimal | `swiss-minimal` | `editorial` |
| Finance / journalism / research / long-form analysis | `editorial` | `data-journalism` |
| Photography-led / architecture / design / fashion / 大图 | `photo-editorial` | `editorial` |
| Data report / market review / 财经 / Bloomberg / Economist | `data-journalism` | `editorial` |
| Product / SaaS / training / consumer / friendly | `soft-rounded` | `editorial` |
| Modern SaaS / fintech / health-tech / premium app | `glassmorphism` | `dark-tech` |
| Tech / AI / dev tools / data / futuristic | `dark-tech` | `glassmorphism` |
| Cultural / philosophy / heritage / 新中式 / 东方 | `ink-wash` | `editorial` |
| Engineering / systems / architecture walkthrough | `blueprint` | `dark-tech` |
| Annual review / manifesto / max-density editorial | `brutalist` | `editorial` |
| Festival / consumer brand / youth / loud launch | `memphis` | `soft-rounded` |
| Indie publishing / design / culture / printed feel | `zine` | `editorial` |
| Heritage / hospitality / retro brand / 老字号 / 周年 | `vintage-poster` | `zine` |
| Cultural / folk / festival / children / sustainability | `paper-cut` | `sketch-notes` |
| Education / training / onboarding / 教学 | `sketch-notes` | `paper-cut` |
| Methodology / before-after / manifesto / 方法论 | `ink-notes` | `editorial` |
| Classroom / tutorial / academic / 课堂 | `chalkboard` | `sketch-notes` |
| Gaming / retro / 8-bit / 复古游戏 | `pixel-art` | `vintage-poster` |
> When the deck has AI images, align style with rendering: a `swiss-minimal` layout reads best with a `minimalist-swiss` rendering, so page and illustrations share one aesthetic. The "Paired rendering" column is the default pairing; override when content demands.
>
> Not every image-rendering becomes its own visual style. A rendering earns a layout twin only when it defines a whole-page layout language (shape, whitespace, composition, texture) — not merely how an inserted image looks. Purely atmospheric renderings (`nature`, `warm-scene`, `fantasy-animation`) stay imagery-only: they pair with whichever layout style fits rather than being one. (Note the distinction `photo-editorial` draws: photography as a *rendering* is image-look, but photo-*led composition* is a real layout language — so the style exists, paired with `corporate-photo`.)
---
## 3. Escape hatch — `custom`
When no preset captures the intended aesthetic, set `- visual_style: custom` in `spec_lock.md` and add a `- visual_style_behavior:` line: one paragraph naming shape language, decoration density, whitespace, typographic character, and texture — **no HEX, no color names as values**. `custom` is a tail-case, not a default; reach for a preset first.
---
## 4. How to use
1. Strategist reads this index at confirmation `d. Layer 2`.
2. Pick one style from the auto-selection table + the deck's vibe.
3. Lock it: write `- visual_style: <name>` into `spec_lock.md`, record rationale in `design_spec.md`.
4. Executor reads **only** `visual-styles/<locked-style>.md` at generation entry — never globs this directory.
**Lock scope**: deck-wide (one style per deck). It anchors taste as a **reference**, not a whitelist — pages may deviate with reason.

View File

@ -0,0 +1,33 @@
# Visual style: blueprint
Engineering schematic — thin line work on dark blueprint paper, isometric projection, technical-annotation language. Speaks like a drawing hung on a wall, not a marketing slide. For architecture walkthroughs, technical briefings, engineering whitepapers, systems explainers.
---
## 1. Shape & decoration
- Shape language: thin single-weight line frames (no heavy fills); components drawn as outlined geometry; optional isometric / 3D-axonometric projection for structures. Slight or zero corner rounding.
- Decoration: the engineering-drawing vocabulary — dimension lines, leader arrows, component codes, coordinate labels, a faint gridline backdrop under everything. Annotation *is* the decoration.
- Whitespace: the grid breathes through; let line work float on the dark field with measured spacing.
## 2. Typography character
- Clean sans for labels and body; monospace for every component name / code / coordinate — mirroring how real technical docs read.
- Small, precise annotation type; wide tracking on coordinate / dimension labels. Restraint over emphasis.
> Families are chosen at confirmation `g`; this style asks for a clean sans + monospace pairing.
## 3. Using the deck's colors
- Dark paper field; a single line-color carries all the schematic line work (frames, connectors, edges); one spot accent marks the current state / key path / callout — the classic engineering-drawing convention of one highlight color.
- Everything else stays low-key line work. The accent appears at few points, high contrast.
> HEX values come from confirmation `e`; this style only governs the line-vs-accent discipline — it names no colors.
## 4. Texture / elevation
- Flat line work, not material elevation. Depth reads from isometric projection and layered line weights, not shadows. Optional subtle corner vignette / accent glow on the dark paper — keep it faint. (Dark-field legibility: [`shared-standards.md §6`](../shared-standards.md).)
## 5. Paired image-rendering
`blueprint` — lock it so AI imagery shares the schematic line-drawing aesthetic.

View File

@ -0,0 +1,34 @@
# Visual style: brutalist
Brutalist editorial newspaper. Wall-to-wall small type, irregular column widths, heavy rule lines, raw structure on show. Reportorial and information-dense — for annual reviews, research digests, manifestos, editorial decks that flaunt density.
---
## 1. Shape & decoration
- Shape language: hard rectangles and ruled boxes; thick black borders / cell frames; visible column dividers. Corner radius `rx="0"` — never rounded.
- Decoration: the grid itself is the decoration — masthead bars, rule lines, boxed pull-quotes, halftone fills. No gradients, no soft cards, no shadows.
- Whitespace: tight and deliberate — narrow margins, dense columns, a newspaper's packed rhythm. Density is the point; one or two breathing zones per page keep it readable, not airy.
- Irregular multi-column layout (mixed column widths) over a uniform grid; asymmetry is intentional.
## 2. Typography character
- Three-family hard contrast: a heavy display sans for headlines (poster-black weight), a serif for column body, monospace for figures / data — the collision is the look.
- Small body size, high density; strong size jump between masthead headline and body. Flush-left columns, tight leading.
> Families are chosen at confirmation `g`; this style asks for a display-black × serif-body × monospace-data *character*.
## 3. Using the deck's colors
- Near-monochrome: ink-dark structure and type on a paper-light field; a single spot accent appears rarely (a masthead rule, one key figure, a stamp) — a few percent of canvas at most.
- Color as punctuation, not fill. No color blocking, no gradients — the accent earns attention by scarcity.
> HEX values come from confirmation `e`; this style only governs how sparingly the accent is used — it names no colors.
## 4. Texture / elevation
- Strictly flat — no drop shadows, no elevation. Depth comes from rule weight and halftone texture, not material. Optional paper-grain / halftone `<pattern>` for a printed feel.
## 5. Paired image-rendering
`screen-print` or `editorial` — halftone monochrome imagery that sits inside the newsprint aesthetic.

View File

@ -0,0 +1,32 @@
# Visual style: chalkboard
Classroom chalkboard — a dark slate field, soft chalk-stroke line work, powdery pastel accents. Nostalgic and instructional. For teaching decks, tutorials, school / academic content, retro-classroom atmosphere.
---
## 1. Shape & decoration
- Shape language: chalk-stroke line work with slightly diffused, dry-medium edges; sketched boxes, brackets, arrows in chalk. Confident but never mechanical — the sketched boxes and arrows are `<path>` with non-aligned points; a primitive `<rect>` / `<line>` snaps the chalk back to mechanical.
- Decoration: underlines and emphasis marks; a few sprinkled chalk stars / dots. Blackboard pedagogy — organized sections, a clear central focus.
- Whitespace: the dark board reads as room; let chalk marks breathe rather than crowd.
## 2. Typography character
- Hand-lettered chalk character for titles; legible body. Dry, nostalgic, classroom-warm.
> Families are chosen at confirmation `g`; this style asks for a hand-lettered chalk title *character*.
## 3. Using the deck's colors
- Dark slate field; off-white chalk carries most marks; the deck's colors appear as soft, powdery pastel chalk accents, used sparingly.
- Restrained and powdery — never saturated fills.
> HEX values come from confirmation `e`; this style only governs the chalk-on-slate, powdery-accent discipline — it names no colors. (Dark-field legibility: [`shared-standards.md §6`](../shared-standards.md).)
## 4. Texture / elevation
- Flat — depth from chalk-stroke weight, not material. Chalk-dust grain texture across the board is on-brand; no drop shadows.
## 5. Paired image-rendering
`chalkboard` — chalk-on-slate imagery with the same classroom feel.

View File

@ -0,0 +1,33 @@
# Visual style: dark-tech
Dark canvas, luminous accents, geometric precision. For tech, AI, dev tools, data products, launches.
---
## 1. Shape & decoration
- Shape language: crisp geometry; thin glowing rules; hexagon / circuit / grid motifs used sparingly. Slight rounding (`rx` 4-8) or sharp.
- Decoration: glow accents, fine grid backgrounds, monospace labels, node / connector lines. Restrained — precision over clutter.
- Whitespace: dark negative space reads as depth; let elements float on it.
## 2. Typography character
- Clean sans for body; monospace for labels / figures / code cues. Wide tracking on small-caps labels.
- High-contrast hierarchy against the dark field.
> Families are chosen at confirmation `g`; this style asks for a clean sans + monospace pairing.
## 3. Using the deck's colors
- Dark background; one or two luminous accents carry focus (glowing figures, active nodes); everything else low-key.
- The accent does the work of attention — few points, high contrast.
> HEX values come from confirmation `e`; this style only governs the dark-field, luminous-accent discipline — it names no colors.
## 4. Texture / elevation
- Depth via glow and layering on dark, not drop shadows. Outer glow / light strokes mark elevation; gradients stay same-hue and subtle. (Dark-theme legibility — prefer light stroke / outer glow over black shadow: [`shared-standards.md §6`](../shared-standards.md).)
## 5. Paired image-rendering
`digital-dashboard` or `blueprint` — polished UI / technical-schematic look for AI images.

View File

@ -0,0 +1,32 @@
# Visual style: data-journalism
Bloomberg / Economist news-infographic — publication-grade information density: multi-column grids, inline micro-charts, data tables, editorial sidebars, source lines. Cool and restrained, read like a financial long-read rather than a keynote. For finance, market reviews, research, annual data reports, data-driven explainers.
---
## 1. Shape & decoration
- Shape language: a dense multi-column grid carrying many small charts and data tables inline; editorial sidebars and pull-stats; hairline dividers; hero numbers; a running source / footnote line. Information density is the look — kept legible by a rigorous grid.
- Decoration: minimal beyond the data — a single accent rule, sparing annotation. Charts and numbers are the visual interest, not ornament.
- Whitespace: tight but structured; the grid earns density without clutter.
## 2. Typography character
- Serif headline / hero-number for authority × a clean sans or monospace for numeric precision in tables and chart labels. Small captions and source lines; tight, deliberate hierarchy.
> Families are chosen at confirmation `g`; this style asks for a serif-headline × precise-sans/mono-data *character*.
## 3. Using the deck's colors
- A restrained field — light publication paper or dark graphite both fit (Economist vs Bloomberg-terminal); the deck's accent marks risk / key figures, an optional secondary distinguishes a second series; charts use tints of the same family, never a rainbow.
- Numbers are colored to *mean* (up / down / risk / focus), not to decorate.
> HEX values come from confirmation `e`; this style only governs the restrained-field, meaning-coded-data discipline — it names no colors. (Dark-field legibility, if dark: [`shared-standards.md §6`](../shared-standards.md).)
## 4. Texture / elevation
- Flat, publication-grade — hairline rules over heavy cards; optional scrim on any image; no glow, no decorative shadow.
## 5. Paired image-rendering
`editorial` — magazine-style infographic imagery sharing the data-publication aesthetic.

View File

@ -0,0 +1,34 @@
# Visual style: editorial
Magazine-grade hierarchy. Columns, hairline rules, a serif / sans interplay, strong typographic structure. For finance, journalism, research, long-form analysis.
---
## 1. Shape & decoration
- Shape language: rectilinear; thin rules and column dividers instead of cards. Minimal rounding (`rx` 0-4).
- Decoration: hairline rules, kickers / eyebrows, pull quotes, drop-style emphasis — typographic, not graphic. Sparing.
- Whitespace: structured by columns and baseline rhythm; comfortable but information-rich.
- Multi-column text flow where content suits.
## 2. Typography character
- Serif / sans interplay: a serif for headlines or pull quotes against a clean sans body (or the reverse). Clear role contrast.
- Strong vertical hierarchy: kicker → headline → standfirst → body. Generous leading.
> Families are chosen at confirmation `g`; this style asks for an editorial serif/sans *pairing*, not specific fonts.
## 3. Using the deck's colors
- Mostly monochrome text on a light field; one accent for emphasis (a rule, a highlighted figure, a kicker).
- Restraint — color marks structure and emphasis, not decoration.
> HEX values come from confirmation `e`; this style only governs the monochrome-with-structural-accent discipline — it names no colors.
## 4. Texture / elevation
- Flat to barely-raised. Rules and whitespace separate content, not shadows. Shadow only on a genuine floating element.
## 5. Paired image-rendering
`editorial` — magazine-style infographic look for AI images.

View File

@ -0,0 +1,32 @@
# Visual style: glassmorphism
Frosted-glass SaaS — translucent layered panels, flowing gradient light, floating depth on a dark field. Future-tech, weightless, premium. For modern SaaS, fintech, health-tech, product launches, AI demos.
---
## 1. Shape & decoration
- Shape language: rounded translucent glass panels (low fill-opacity over the dark field) with bright hairline edges; layered, floating cards that imply blur and frost; rounded corners (`rx` 12-20).
- Decoration: soft radial light blooms in the background; thin luminous edge highlights along panels; restrained — the glass material is the decoration, not added ornament. Realize the radial bloom / glow halo as a `<circle>` / `<ellipse>` with a `<radialGradient>` fill, never a `rect rx=w/2` standing in for it.
- Whitespace: dark negative space reads as depth; let panels float on it with room to breathe.
## 2. Typography character
- Clean modern sans; light / medium weights; airy. Headlines can carry a luminous gradient on the dark field.
> Families are chosen at confirmation `g`; this style asks for a clean, modern, slightly-light sans *character*.
## 3. Using the deck's colors
- Dark field; the deck's colors read as luminous gradients flowing across panels and titles, low-opacity glass tints, and a neon accent at ~10%. Color behaves like light through glass, not flat fill.
- Depth and hierarchy come from how brightly the glass glows, not from heavy saturation.
> HEX values come from confirmation `e`; this style only governs the translucent-glass, luminous-gradient discipline — it names no colors.
## 4. Texture / elevation
- Depth via translucency, layering, bright edge highlights, and soft background glow — not hard drop shadows. Smooth multi-stop gradients are intrinsic here (the one style where generous gradient use is on-brand); keep them luminous, not muddy. (Dark-field legibility: [`shared-standards.md §6`](../shared-standards.md).)
## 5. Paired image-rendering
`glassmorphism` — frosted translucent panels / soft-gradient imagery matching the glass surfaces.

View File

@ -0,0 +1,33 @@
# Visual style: ink-notes
Whiteboard-ink minimalism — a pale field, confident black hand-ink line work, sparse semantic color. Considered and manifesto-clear, the professional end of hand-drawn. For methodology, before/after essays, mindset-shift narratives, technical manifestos.
---
## 1. Shape & decoration
- Shape language: hand-drawn line work with slight, intentional wobble — boxes, arrows, dividers and brackets sketched as if on a thoughtful whiteboard; never mechanically straight — realize it as `<path>` / `<polyline>` with off-grid points, not `<rect>` / `<line>` primitives. Line defines structure; no filled cards.
- Decoration: minimal — a few doodle marks (stars, dashes, dots, underlines) for emphasis. Restraint is the look; clutter breaks the "considered" feel.
- Whitespace: generous and empty; the pale field carries most of the canvas, elements float with room around them.
## 2. Typography character
- Hand-lettered / humanist character for titles — bold, slightly oversized, confident. Plain legible sans for body.
- Reads as written-by-hand-but-deliberate, not corporate-precise.
> Families are chosen at confirmation `g`; this style asks for a humanist / hand-lettered title *character*.
## 3. Using the deck's colors
- Near-monochrome: ink-dark line work on a pale field does ~85% of the work; the deck's accent appears only as a semantic mark (risk / positive / highlight) under ~10% of canvas.
- Color carries meaning, not decoration — one or two accents, used where they signify.
> HEX values come from confirmation `e`; this style only governs the monochrome-with-semantic-accent discipline — it names no colors.
## 4. Texture / elevation
- Strictly flat — no shadows, no paper grain (the field stays clean). Depth reads from line weight and spacing alone.
## 5. Paired image-rendering
`ink-notes` — black-ink visual-note imagery on a clean field, matching the considered hand-drawn aesthetic.

View File

@ -0,0 +1,32 @@
# Visual style: ink-wash
New-Chinese ink-wash — a rice-paper field, vast literati whitespace, restrained brush marks, a single seal-stamp accent. Still, considered, Eastern. For cultural reading-shares, philosophy, heritage, self-cultivation, 新中式 narratives.
---
## 1. Shape & decoration
- Shape language: minimal brush-stroke marks and hairline dividers; the occasional ink-dark block; a single seal-stamp (印章) square as a focal accent. No cards, no boxes — emptiness is the structure. The brush-stroke and ink-bleed marks are irregular `<path>` shapes with uneven control points — never an `<ellipse>` / `<circle>` standing in for a wash, which reads as fake ink. (The seal-stamp square is the one deliberate hard edge.)
- Decoration: almost none; what little appears reads as brush and seal. Asymmetric, scroll-like composition with deliberate off-balance.
- Whitespace: vast and intentional — the rice-paper field carries most of the page; a few elements float in great calm.
## 2. Typography character
- Brush / serif character for titles (calligraphic, expressive) against a clean modern sans body — a Kai × Hei contrast axis. Large airy titles, generous leading, vertical rhythm welcome.
> Families are chosen at confirmation `g`; this style asks for a calligraphic-brush title × clean-sans body *character*.
## 3. Using the deck's colors
- A pale rice-paper field dominates; ink-dark carries type and the rare ink shape; a single warm seal-red accent appears at one key point — scarce, like a stamp on a scroll.
- Near-monochrome ink discipline — restraint is the aesthetic, not abundance.
> HEX values come from confirmation `e`; this style only governs the ink-on-paper, single-seal-accent discipline — it names no colors.
## 4. Texture / elevation
- Flat — emptiness and brush weight carry depth, not shadow. Optional faint paper grain or low-opacity ink-bleed wash; no drop shadows.
## 5. Paired image-rendering
`ink-notes` or `watercolor` — mono-ink or soft-wash imagery that shares the literati restraint.

View File

@ -0,0 +1,33 @@
# Visual style: memphis
Memphis / Pop — clashing color blocks, geometric confetti, bold outlines, 80s-revival exuberance. Loud and playful. For festivals, consumer brands, youth culture, launch hype, anything that wants energy over restraint.
---
## 1. Shape & decoration
- Shape language: bold geometric primitives — circles, triangles, zigzags, squiggles, blobs — with thick dark outlines (2-4px). Mixed corner radii allowed; playful inconsistency is on-brand.
- Decoration: scattered geometric confetti, color-block backings, pattern fills (dots / stripes), oversized punctuation. Generous decoration — but composed, not chaotic.
- Whitespace: energetic asymmetry; props float at angles. Still leave the focal content room to read against the noise.
## 2. Typography character
- Display poster type for headlines (heavy, attention-grabbing); a neutral readable sans for body so density stays legible under the visual energy.
- Big, confident headline scale; tight to the artwork. Body kept clean and quiet by contrast.
> Families are chosen at confirmation `g`; this style asks for a display-poster headline × neutral-sans body *character*.
## 3. Using the deck's colors
- Multi-accent clash is the signature — but bounded: the clashing colors stay a minority of canvas (≈40% cap), and any one page fronts only two or three of them, not the whole set.
- A light field carries the noise; dark outlines anchor every shape so the clash reads as composed, not muddy. Disciplined exuberance, never rainbow soup.
> HEX values come from confirmation `e`; this style governs how many accents appear and how boldly — it names no colors.
## 4. Texture / elevation
- Mostly flat pop-art blocks; bold outlines do the separating, not shadows. Optional hard-offset (sticker / cutout) shadows for a retro pop feel — flat, not soft. Keep gradients rare.
## 5. Paired image-rendering
`flat` — clean vivid flat-color illustration that matches the pop-block energy.

View File

@ -0,0 +1,32 @@
# Visual style: paper-cut
Layered paper-craft — scissor-cut shapes stacked in tactile layers, soft shadow where layers overlap. Warm, hand-made, child-friendly without being childish. For education, children's content, cultural / folk topics, festival, sustainability.
---
## 1. Shape & decoration
- Shape language: forms defined by crisp, slightly-irregular cut edges (no outlines); simplified, stylized shapes that read as cut paper rather than illustration. Those cut edges are irregular `<polygon>` / `<path>` outlines, not a clean `<rect>` / `<circle>`, which reads as a digital box rather than torn paper.
- Decoration: layering itself is the device — each element is a "sheet" stacked over the one beneath; small cut-out accents on the top layer.
- Whitespace: cozy, composed — the backing sheet shows through as breathing room.
## 2. Typography character
- Clean friendly sans; warm, not severe. Titles can sit on a cut-paper banner shape.
> Families are chosen at confirmation `g`; this style asks for a warm, rounded, approachable sans *character*.
## 3. Using the deck's colors
- Each color reads as one sheet of paper — the primary is the dominant foreground sheet, the secondary the backing field, the accent a small top-layer cut-out.
- Layering and overlap drive emphasis more than proportion: even a small primary sheet in front of a large secondary reads as primary-led.
> HEX values come from confirmation `e`; this style only governs the each-color-is-a-sheet, layering discipline — it names no colors.
## 4. Texture / elevation
- Real layered depth — a soft 8-12% drop shadow under each cut layer is core here (the one style where layered shadow is the point, not a violation). Matte paper grain on each sheet. (Shadow rules: [`shared-standards.md §6`](../shared-standards.md).)
## 5. Paired image-rendering
`paper-cut` — layered cut-paper imagery sharing the tactile, hand-made depth.

View File

@ -0,0 +1,34 @@
# Visual style: photo-editorial
Photo-led editorial — large full-bleed photography dominates the page, text points and captions. The big image speaks; words title it. Magazine photo-essay rhythm. For architecture, design, fashion, culture, photography-forward long-reads.
---
## 1. Shape & decoration
- Shape language: large full-bleed / edge-to-edge image fields are the page's spine; text sits in restrained columns, caption blocks, kickers, or overlay headlines. Minimal chrome — the photograph carries the page.
- Decoration: thin rules, section numbering, small figure notes; nothing competes with the image.
- Whitespace: generous around text; the photo fills, the type breathes beside it. Asymmetric magazine composition.
> **No usable image → fall back to `editorial`.** This style's spine is the photograph; when a page has no suitable image available, render it in the `editorial` text-led layout (magazine columns) rather than a full-bleed placeholder — an empty / dashed image frame contradicts the style's whole premise. A deterministic, observable condition, not a judgment call.
## 2. Typography character
- Editorial serif / CJK title × clean sans body; magazine-column cadence; small precise captions and figure notes. Words are concise — they point, they don't fill.
> Families are chosen at confirmation `g`; this style asks for an editorial serif-title × clean-sans-body *character*.
## 3. Using the deck's colors
- The photograph carries the color; the text-side field stays a quiet neutral so imagery dominates; one restrained accent marks numbering, rules, or a key word.
- Deliberately understated on the type side — the image is the loudest element, by design.
> HEX values come from confirmation `e`; this style only governs the image-dominant, understated-type discipline — it names no colors.
## 4. Texture / elevation
- Flat — no decorative shadows. The one practical exception: a scrim gradient over an image where overlay text needs legibility. Photography supplies the texture.
## 5. Paired image-rendering
`corporate-photo` — real editorial photography as the hero imagery the layout is built around.

View File

@ -0,0 +1,32 @@
# Visual style: pixel-art
8-bit retro game — strict pixel grid, chunky blocky forms, a limited palette, no anti-aliasing. Playful and nostalgic. For gaming decks, retro-tech decks, nostalgic or game-flavored education / entertainment.
---
## 1. Shape & decoration
- Shape language: everything aligns to a visible pixel grid — blocky shapes, stepped edges, sharp transitions, no smooth curves; optional 1-pixel darker outlines for definition.
- Decoration: classic game framing — HUD bars, tile floors, sprite icons, chunky pixel borders. References NES / SNES / arcade composition.
- Whitespace: grid-disciplined; let blocks sit on clean tiled ground rather than crowd.
## 2. Typography character
- Pixel / bitmap display character for headlines; keep body in a clean legible face — full-pixel body type strains at reading length.
> Families are chosen at confirmation `g`; this style asks for a pixel / bitmap display *character* for titles, legible body alongside.
## 3. Using the deck's colors
- Colors used as palette slots: primary the dominant object, secondary the terrain / background, accent the highlights and markers; a darker shade of the primary serves as outline pixels.
- Flat blocks only — shading comes from palette layering (lighter top, darker bottom), never gradients.
> HEX values come from confirmation `e`; this style only governs the palette-slot, flat-pixel discipline — it names no colors.
## 4. Texture / elevation
- No texture beyond the pixel grid itself; strictly flat blocks. Depth reads from lighter-top / darker-bottom pixel shading, not drop shadows.
## 5. Paired image-rendering
`pixel-art` — 8-bit imagery on the same grid, sharing the retro-game aesthetic.

View File

@ -0,0 +1,32 @@
# Visual style: sketch-notes
Warm hand-drawn sketchnote — soft paper field, black ink doodle line work, gentle pastel blocks. The most approachable style, friendly over precise. For education, training, onboarding, science communication, knowledge content.
---
## 1. Shape & decoration
- Shape language: rounded shapes drawn with a slight wobble; pastel block fills that slightly overshoot their outlines (hand-painted feel); simple cartoon icons. Draw that wobble as a `<path>` with non-aligned points — a `<rect rx>` is not wobble.
- Decoration: small doodles — stars, sparkles, dots, underlines — sprinkled sparingly for warmth; wavy hand-drawn arrows connecting ideas with short inline labels.
- Whitespace: airy and well-organized; generous gaps between elements keep it friendly, never dense.
## 2. Typography character
- Friendly hand-lettered titles; clear humanist body. Warmth and legibility over corporate severity.
> Families are chosen at confirmation `g`; this style asks for a warm hand-lettered / humanist *character*.
## 3. Using the deck's colors
- Warm, soft paper field; the deck's colors rendered as gentle pastel tints inside the blocks rather than full saturation; one accent reserved for a key arrow or emphasis.
- Generous but gentle — soft tints, never high-chroma or rainbow.
> HEX values come from confirmation `e`; this style only governs the soft-pastel, warm-field discipline — it names no colors.
## 4. Texture / elevation
- Flat 2D — sketchnote is intentionally flat. Optional subtle paper grain for warmth; no drop shadows.
## 5. Paired image-rendering
`sketch-notes` — cream-paper hand-drawn imagery with soft pastel fills, sharing the friendly note aesthetic.

View File

@ -0,0 +1,33 @@
# Visual style: soft-rounded
Approachable and modern. Rounded cards, gentle elevation, friendly rhythm. For product, SaaS, training, consumer decks.
---
## 1. Shape & decoration
- Shape language: rounded rectangles (`rx` 12-16), pill tags, soft containers. Consistent radius deck-wide.
- Decoration: cards as the primary container; icon accents; numbered circles; gentle dividers. Moderate, in service of clarity.
- Whitespace: comfortable padding inside cards; even gutters; balanced rather than austere.
## 2. Typography character
- Friendly sans (humanist or geometric); medium weights; clear but not severe hierarchy.
- Rounded, open letterforms suit; avoid condensed / industrial faces.
> Families are chosen at confirmation `g`; this style asks for an approachable sans *character*.
## 3. Using the deck's colors
- Theme color used confidently on covers / chapter backgrounds; same-hue tints for card backings; accent for key figures.
- Warmer, more generous color use than swiss / editorial — still disciplined (60-30-10), never rainbow.
> HEX values come from confirmation `e`; this style only governs the confident-but-disciplined (60-30-10) color use — it names no colors.
## 4. Texture / elevation
- Gentle elevation: soft shadows on floating cards (resting tier), subtle tints, optional same-hue gradients. Two-tier elevation max; keep peer-grid cards flat. (Full shadow rules: [`shared-standards.md §6`](../shared-standards.md).)
## 5. Paired image-rendering
`flat` — clean modern blocks for AI images. (For frosted-glass depth, see the dedicated [`glassmorphism`](./glassmorphism.md) style.)

View File

@ -0,0 +1,34 @@
# Visual style: swiss-minimal
Strict Swiss-grid discipline. Modular grid, sharp geometry, aggressive whitespace, near-zero decoration. The most restrained style — for high-end consulting, architecture, design firms, type-led decks.
---
## 1. Shape & decoration
- Shape language: sharp rectangles, true circles, single-weight rules. Corner radius `rx="0"` by default; if rounding at all, ≤4.
- Decoration: none. No gradient fills, no decorative blocks, no badges — structure carries the page.
- Whitespace: vast and deliberate; negative space carries as much weight as content. Wide margins, generous gutters.
- Layout snaps to a visible or implied modular grid; rigorous column / row alignment.
## 2. Typography character
- Sans-serif, single family; weight contrast (e.g. 900 / 300) over family contrast. Tight, rigorous spacing.
- Strong size hierarchy — large headlines, small precise body. Left-aligned, flush.
> Family is chosen at confirmation `g` by subject fit — this style asks for a grotesque / neo-grotesque *character*, not a specific font.
## 3. Using the deck's colors
- One color dominates a deliberate grid zone; the field stays near-white; the accent appears at a single point — never more than a few percent of canvas.
- Color as conceptual zone, not decoration. No gradients.
> HEX values come from confirmation `e`; this style only governs how sparingly they are applied — it names no colors.
## 4. Texture / elevation
- Strictly flat. No shadows, no depth, no material — 2D conceptual planes only.
## 5. Paired image-rendering
`minimalist-swiss` — lock it for AI images so illustrations share the grid-austere aesthetic.

View File

@ -0,0 +1,32 @@
# Visual style: vintage-poster
Mid-century print poster (1950s1970s) — bold rounded-geometric shapes, limited flat color blocks, halftone / paper grain, retro warmth. For cultural decks, brand storytelling, hospitality, retro-tech narratives, anniversaries, heritage brands.
---
## 1. Shape & decoration
- Shape language: bold geometric shapes with rounded organic edges, often slightly off-axis for retro tension; overlapping flat blocks; thick hand-aware lines; stylized, reduced iconography (a stylized sun, an angular mountain).
- Decoration: the print artifacts — halftone dot overlays, slight ink misregistration — carry the character; imagery stays reduced and graphic, never photoreal.
- Whitespace: confident poster composition — a few large blocks, deliberate negative space.
## 2. Typography character
- Retro display character for headlines (mid-century geometric, confident); a simple legible body by contrast.
> Families are chosen at confirmation `g`; this style asks for a mid-century display *character*.
## 3. Using the deck's colors
- Applied as limited flat blocks (a small set of colors): the primary dominates large blocks, the secondary reads as a warm paper field, the accent marks small shapes or tints the halftone.
- Flat color only — no gradients; depth comes from overlap, not blending.
> HEX values come from confirmation `e`; this style only governs the limited-flat-block, halftone discipline — it names no colors.
## 4. Texture / elevation
- Flat — depth implied via overlapping shapes, never via shadow. Halftone dot pattern or paper grain at low opacity gives printed age; slight ink-offset character is on-brand.
## 5. Paired image-rendering
`vintage-poster` — mid-century poster imagery with matching halftone and retro-geometric warmth.

View File

@ -0,0 +1,33 @@
# Visual style: zine
Risograph zine / DIY poster — misregistered color layers, halftone dots, a tightly limited palette, handmade print grit. Indie-publishing texture over polish. For culture decks, design talks, indie brands, anything wanting a printed, hand-assembled feel.
---
## 1. Shape & decoration
- Shape language: cut-and-paste blocks, offset color shapes, rough frames; outlines in a near-black ink tone. Corner radius low or zero — print-flat, not soft-digital.
- Decoration: the riso print artifacts — 1-3px color-layer misregistration, halftone-dot `<pattern>` texture, overlapping color blocks that imply a third color where they cross. Texture is the decoration.
- Whitespace: poster-like — bold focal blocks with raw margins; deliberate roughness over clean alignment.
## 2. Typography character
- Punk-DIY contrast: a heavy poster display face for headlines, a plain readable sans for body, monospace for annotation (typewriter / photocopier feel).
- Big headline tension against quiet body; slight intentional looseness reads as hand-set rather than mechanical.
> Families are chosen at confirmation `g`; this style asks for a display-poster × plain-sans × monospace-annotation *character*.
## 3. Using the deck's colors
- A strictly limited spot palette (riso logic) on a warm paper field; two ink layers do most of the work and a third spot color appears rarely (<5%).
- Color is laid as flat spot fills, not gradients; overlap and offset of the same few inks create depth and the third-color illusion. Scarcity and overlap, not variety.
> HEX values come from confirmation `e`; this style governs the flat-spot, misregistered-overlay discipline — it names no colors.
## 4. Texture / elevation
- Flat print, no digital elevation. Depth from layer offset and halftone grain, not shadows. Warm-paper grain via faint texture / low-opacity halftone is on-brand; avoid drop shadows and slick gradients.
## 5. Paired image-rendering
`screen-print` — duotone / spot-ink imagery that shares the riso print aesthetic.

View File

@ -0,0 +1,464 @@
#!/usr/bin/env python3
"""
PPT Master - Error Message Helper
Provides user-friendly error messages and specific fix suggestions.
"""
import argparse
from typing import Dict, List, Optional
class ErrorHelper:
"""Error message helper."""
# Error types and their corresponding fix suggestions
ERROR_SOLUTIONS = {
'missing_readme': {
'message': 'Missing README.md file',
'solutions': [
'Create a README.md file with project description, usage instructions, etc.',
'Reference template: examples/google_annual_report_ppt169_20251116/README.md',
'Or use command: cp examples/google_annual_report_ppt169_20251116/README.md <your_project>/'
],
'severity': 'error'
},
'missing_spec': {
'message': 'Missing design specification file',
'solutions': [
'Create a design_spec.md file',
'Include: canvas specs, color scheme, font specs, layout specs, content outline',
'Refer to the design specification generated by the Strategist role'
],
'severity': 'warning'
},
'missing_svg_output': {
'message': 'Missing svg_output directory',
'solutions': [
'Create the svg_output directory: mkdir svg_output',
'Place generated SVG files in this directory',
'Ensure SVG files follow naming convention: slide_XX_name.svg'
],
'severity': 'error'
},
'empty_svg_output': {
'message': 'svg_output directory is empty',
'solutions': [
'Use the AI role (Executor) to generate SVG files',
'Save SVG files to the svg_output directory',
'Ensure file naming format: slide_01_cover.svg, slide_02_content.svg, etc.'
],
'severity': 'warning'
},
'invalid_svg_naming': {
'message': 'Non-standard SVG file naming',
'solutions': [
'Rename SVG files using format: slide_XX_name.svg',
'XX should be a two-digit number (01, 02, ...)',
'name should use English or pinyin, separated by underscores',
'Example: slide_01_cover.svg, slide_02_overview.svg'
],
'severity': 'warning'
},
'missing_project_date': {
'message': 'Project directory missing date suffix',
'solutions': [
'Rename the project directory to add a date suffix: _YYYYMMDD',
'Format: {project_name}_{format}_{YYYYMMDD}',
'Example: my_project_ppt169_20251116',
'Command: mv old_name new_name_ppt169_20251116'
],
'severity': 'warning'
},
'viewbox_mismatch': {
'message': 'SVG viewBox does not match canvas format',
'solutions': [
'Check the viewBox attribute of SVG files',
'Ensure it matches the project canvas format',
'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'
],
'severity': 'warning'
},
'multiple_viewboxes': {
'message': 'Multiple different viewBox settings detected',
'solutions': [
'Unify the viewBox across all SVG files',
'All pages in the same project should use the same canvas size',
'Use find-and-replace tools for batch correction',
'Reference the viewBox setting of the first page'
],
'severity': 'warning'
},
'no_viewbox': {
'message': 'SVG file missing viewBox attribute',
'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'
],
'severity': 'error'
},
'foreignobject_detected': {
'message': 'Forbidden <foreignObject> element detected',
'solutions': [
'Remove <foreignObject> elements',
'Use <text> + <tspan> for manual line wrapping',
'This is a project technical specification requirement',
'Reference: references/shared-standards.md'
],
'severity': 'error'
},
'clippath_on_non_image': {
'message': 'clip-path is only allowed on <image> elements',
'solutions': [
'Remove clip-path from shapes / groups / text',
'Draw the target geometry directly with the matching native element: <circle> / <ellipse> / <rect rx="..."> / <polygon> / <path>. A rect clipped to a circle is just a <circle>.',
'clip-path on <image> is conditionally allowed — see references/shared-standards.md §1.2'
],
'severity': 'error'
},
'clippath_def_missing': {
'message': 'clip-path references a <clipPath> id that does not exist in <defs>',
'solutions': [
'Define the referenced <clipPath id="..."> inside <defs>',
'The clipPath must contain exactly one shape child (circle / ellipse / rect with rx,ry / path / polygon)',
'Reference: references/shared-standards.md §1.2'
],
'severity': 'error'
},
'mask_detected': {
'message': 'Forbidden <mask> element detected',
'solutions': [
'Remove <mask> elements',
'PPT does not support SVG masks',
'Use opacity (opacity/fill-opacity) as an alternative'
],
'severity': 'error'
},
'style_element_detected': {
'message': 'Forbidden <style> element detected',
'solutions': [
'Remove <style> elements',
'Convert CSS styles to inline attributes',
'Example: fill="#000" instead of class="text-black"'
],
'severity': 'error'
},
'class_attribute_detected': {
'message': 'Forbidden class attribute detected',
'solutions': [
'Remove all class attributes',
'Use inline styles instead',
'Example: fill="#000" stroke="#333" directly on the element'
],
'severity': 'error'
},
'id_attribute_detected': {
'message': 'Forbidden id attribute detected',
'solutions': [
'Remove all id attributes',
'Use inline styles instead',
'Avoid relying on selectors for positioning or style reuse'
],
'severity': 'error'
},
'external_css_detected': {
'message': 'Forbidden external CSS reference detected',
'solutions': [
'Remove <?xml-stylesheet?> declarations',
'Remove <link rel="stylesheet"> references',
'Remove @import external styles',
'Convert styles to inline attributes'
],
'severity': 'error'
},
'symbol_use_detected': {
'message': 'Forbidden <symbol> + <use> complex usage detected',
'solutions': [
'Expand <symbol> into actual SVG code',
'Avoid <symbol> + <use> reuse structures',
'Embed SVG paths directly when icons are needed'
],
'severity': 'error'
},
# Note: <marker> and marker-end are NO LONGER forbidden — they are
# conditionally allowed (see references/shared-standards.md §1.1).
# The converter maps qualifying markers to native DrawingML arrow heads.
'marker_orphan_ref': {
'message': 'marker-start/marker-end references a marker id, but no <marker> element is defined',
'solutions': [
'Define the <marker> inside <defs>',
'Or remove the marker-start/marker-end attribute',
'See shared-standards.md §1.1 for marker constraints',
],
'severity': 'error'
},
'rgba_detected': {
'message': 'Forbidden rgba() color detected',
'solutions': [
'Replace rgba() with hex + opacity notation',
'Example: fill="#FFFFFF" fill-opacity="0.1"',
'Use stroke-opacity for strokes'
],
'severity': 'error'
},
'group_opacity_detected': {
'message': 'Forbidden <g opacity> detected',
'solutions': [
'Remove group-level opacity',
'Set opacity individually on each child element',
'Use fill-opacity / stroke-opacity for control'
],
'severity': 'error'
},
'image_opacity_detected': {
'message': 'Forbidden <image opacity> detected',
'solutions': [
'Remove image opacity attribute',
'Add a <rect> overlay to control transparency',
'Ensure overlay color matches the background'
],
'severity': 'error'
},
'event_attribute_detected': {
'message': 'Forbidden event attribute detected',
'solutions': [
'Remove onclick/onload and other event attributes',
'Scripts and event handling are forbidden in SVG',
'Implement interactivity in PPT instead'
],
'severity': 'error'
},
'set_detected': {
'message': 'Forbidden <set> element detected',
'solutions': [
'Remove <set> elements',
'SVG animations will not be exported to PPT',
'Use PPT native animations for animation effects'
],
'severity': 'error'
},
'iframe_detected': {
'message': 'Forbidden <iframe> element detected',
'solutions': [
'Remove <iframe> elements',
'External pages should not be embedded in SVG'
],
'severity': 'error'
},
'textpath_detected': {
'message': 'Forbidden <textPath> element detected',
'solutions': [
'Remove <textPath> elements',
'Text on path is not compatible with PPT',
'Use regular <text> elements and adjust position manually'
],
'severity': 'error'
},
'webfont_detected': {
'message': 'Forbidden web font (@font-face) detected',
'solutions': [
'Remove @font-face declarations',
'End every font-family stack with a PPT-safe pre-installed family',
'Example: font-family: "Microsoft YaHei", Arial, sans-serif'
],
'severity': 'error'
},
'animation_detected': {
'message': 'Forbidden SMIL animation element detected',
'solutions': [
'Remove all <animate>, <animateMotion>, <animateTransform> and similar elements',
'SVG animations will not be exported to PPT',
'Use PPT native animations for animation effects'
],
'severity': 'error'
},
'script_detected': {
'message': 'Forbidden <script> element detected',
'solutions': [
'Remove <script> elements',
'Scripts and event handling are forbidden',
'JavaScript in SVG will not execute in PPT'
],
'severity': 'error'
},
'invalid_font': {
'message': 'Font stack does not end on a PPT-safe family',
'solutions': [
'End the stack with a cross-platform pre-installed family',
'CJK: "Microsoft YaHei", sans-serif | SimSun, serif',
'Latin: Arial, sans-serif | "Times New Roman", serif',
'Mono: Consolas, "Courier New", monospace',
'See strategist.md §g for the full PPT-safe discipline'
],
'severity': 'warning'
}
}
@classmethod
def get_solution(cls, error_type: str, context: Optional[Dict] = None) -> Dict:
"""
Get the solution for an error.
Args:
error_type: Error type
context: Context information (optional)
Returns:
Dictionary containing message, solutions, severity
"""
if error_type in cls.ERROR_SOLUTIONS:
solution = cls.ERROR_SOLUTIONS[error_type].copy()
# Customize message based on context
if context:
solution = cls._customize_solution(solution, context)
return solution
# Unknown error type
return {
'message': 'Unknown error',
'solutions': ['Please check the documentation or contact the maintainer'],
'severity': 'error'
}
@classmethod
def _customize_solution(cls, solution: Dict, context: Dict) -> Dict:
"""
Customize solution based on context.
Args:
solution: Original solution
context: Context information
Returns:
Customized solution
"""
customized = solution.copy()
# Customize based on project path
if 'project_path' in context:
project_path = context['project_path']
customized['solutions'] = [
s.replace('<project_path>', project_path).replace(
'<your_project>', project_path)
for s in customized['solutions']
]
# Customize based on filename
if 'file_name' in context:
file_name = context['file_name']
customized['message'] = f"{customized['message']}: {file_name}"
# Customize based on expected/actual values
if 'expected' in context and 'actual' in context:
customized['message'] += f" (expected: {context['expected']}, actual: {context['actual']})"
return customized
@classmethod
def format_error_message(cls, error_type: str, context: Optional[Dict] = None) -> str:
"""
Format error message (for terminal output).
Args:
error_type: Error type
context: Context information
Returns:
Formatted error message string
"""
solution = cls.get_solution(error_type, context)
lines = []
# Error message
severity_icon = "[ERROR]" if solution['severity'] == 'error' else "[WARN]"
lines.append(f"{severity_icon} {solution['message']}")
# Solutions
if solution['solutions']:
lines.append("\nSuggested fixes:")
for i, sol in enumerate(solution['solutions'], 1):
lines.append(f" {i}. {sol}")
return "\n".join(lines)
@classmethod
def print_error(cls, error_type: str, context: Optional[Dict] = None):
"""
Print formatted error message.
Args:
error_type: Error type
context: Context information
"""
print(cls.format_error_message(error_type, context))
@classmethod
def get_all_error_types(cls) -> List[str]:
"""Get all supported error types."""
return list(cls.ERROR_SOLUTIONS.keys())
@classmethod
def print_help(cls):
"""Print all error types and solutions."""
print("PPT Master - Error Types and Solutions\n")
print("=" * 80)
for error_type, info in cls.ERROR_SOLUTIONS.items():
print(f"\n[{error_type}]")
print(f"Message: {info['message']}")
print(f"Severity: {info['severity']}")
print("Solutions:")
for i, sol in enumerate(info['solutions'], 1):
print(f" {i}. {sol}")
print("-" * 80)
def build_parser() -> argparse.ArgumentParser:
"""Build the command-line parser."""
parser = argparse.ArgumentParser(
description="Look up PPT Master error messages and suggested fixes.",
)
parser.add_argument(
"error_type",
nargs="?",
choices=sorted(ErrorHelper.ERROR_SOLUTIONS),
help="Error type to explain",
)
parser.add_argument(
"context",
nargs="*",
metavar="key=value",
help="Optional context values used by templates",
)
return parser
def main(argv: list[str] | None = None) -> int:
"""Run the CLI entry point for error lookup."""
parser = build_parser()
args = parser.parse_args(argv)
if not args.error_type:
ErrorHelper.print_help()
return 0
context = {}
for item in args.context:
if '=' not in item:
parser.error(f"context values must use key=value syntax: {item}")
key, value = item.split('=', 1)
context[key] = value
print(ErrorHelper.format_error_message(args.error_type, context))
return 0
if __name__ == '__main__':
raise SystemExit(main())

View File

@ -1,177 +0,0 @@
"""fetch_icon.py: 从 Iconify CDN 拉个性化图标,按主题色染色,缓存本地。
Iconify 聚合了 150+ 免费开源图标集,无需账号 API key:
tabler -- 现代描边 (Apache 2.0) 推荐
lucide -- 开源经典 (ISC)
heroicons -- Tailwind (MIT)
material-symbols -- Google (Apache 2.0)
carbon -- IBM (Apache 2.0)
fluent -- Microsoft (MIT)
mdi -- Material Design (Apache 2.0)
每个集都有数千图标, https://icon-sets.iconify.design/ 浏览找名字
用法:
# 推荐: 染主色,导出 PNG (需 cairosvg 或 svglib)
python fetch_icon.py rocket --set tabler --color C00000 --size 128 \\
-o slides/rocket.png
# 只要 SVG (PowerPoint 2016+ 支持嵌入 SVG)
python fetch_icon.py target --set lucide --color FFC107 \\
-o slides/target.svg
# 默认值: set=tabler, color=C00000(主红), size=128
python fetch_icon.py chart-bar -o slides/chart_bar.png
环境:
PNG 转换依赖任一: `pip install cairosvg` (推荐) `pip install svglib`
若都没有,会保存 .svg 到目标路径(扩展名自动改).
退出码:
0 = 成功 PNG/SVG
1 = SVG 有了但 PNG 转换失败 (已保存 SVG)
2 = 网络/图标名错误 (没拉到)
"""
from __future__ import annotations
import argparse
import io
import sys
import urllib.parse
import urllib.request
from pathlib import Path
ICONIFY_API = "https://api.iconify.design/{set}/{name}.svg"
def fetch_svg(name: str, icon_set: str, color: str, size: int) -> str:
"""从 Iconify 拉 SVG,带主题色和大小参数。"""
params: dict[str, str] = {}
if color:
params["color"] = "#" + color.lstrip("#")
if size:
params["height"] = str(size)
params["width"] = str(size)
url = ICONIFY_API.format(set=icon_set, name=name)
if params:
url += "?" + urllib.parse.urlencode(params)
req = urllib.request.Request(
url, headers={"User-Agent": "ppt-skill-fetch_icon/1.0"}
)
with urllib.request.urlopen(req, timeout=15) as resp:
body = resp.read().decode("utf-8")
return body
def svg_to_png(svg_text: str, out_path: Path, size: int) -> bool:
"""SVG → PNG,降级链:cairosvg → svglib+reportlab → 失败。"""
# 路径 1: cairosvg (推荐,质量最好)
try:
import cairosvg # type: ignore
cairosvg.svg2png(
bytestring=svg_text.encode("utf-8"),
write_to=str(out_path),
output_width=size,
output_height=size,
)
return True
except ImportError:
pass
except Exception as e:
print(f"[warn] cairosvg 渲染失败: {e}", file=sys.stderr)
# 路径 2: svglib + reportlab
try:
from svglib.svglib import svg2rlg # type: ignore
from reportlab.graphics import renderPM # type: ignore
drawing = svg2rlg(io.StringIO(svg_text))
if drawing is None:
return False
renderPM.drawToFile(drawing, str(out_path), fmt="PNG")
return True
except ImportError:
pass
except Exception as e:
print(f"[warn] svglib 渲染失败: {e}", file=sys.stderr)
return False
def main() -> int:
ap = argparse.ArgumentParser(
description="从 Iconify CDN 拉个性化 SVG/PNG 图标"
)
ap.add_argument("name", help="图标名,见 https://icon-sets.iconify.design/")
ap.add_argument(
"--set", default="tabler",
help="图标集 (默认 tabler;可选 lucide/heroicons/material-symbols/carbon/fluent/mdi)",
)
ap.add_argument(
"--color", default="C00000",
help="主题色 hex,无 # (默认 C00000 商务红主色)",
)
ap.add_argument(
"--size", type=int, default=128,
help="像素 (默认 128,适合 0.5-1.0 in PPT 图标)",
)
ap.add_argument(
"-o", "--out", required=True, type=Path,
help="输出路径 (.png 或 .svg)",
)
ap.add_argument(
"--svg-only", action="store_true",
help="只输出 SVG,跳过 PNG 转换",
)
args = ap.parse_args()
args.out.parent.mkdir(parents=True, exist_ok=True)
try:
svg = fetch_svg(args.name, args.set, args.color, args.size)
except urllib.error.HTTPError as e:
print(
f"[error] Iconify 返回 {e.code}: 图标 '{args.set}:{args.name}' "
f"可能不存在,在 https://icon-sets.iconify.design/{args.set}/ 搜",
file=sys.stderr,
)
return 2
except Exception as e:
print(f"[error] 拉取失败: {e}", file=sys.stderr)
return 2
if "<svg" not in svg:
print(
f"[error] 返回不是 SVG: 图标 '{args.set}:{args.name}' 不存在",
file=sys.stderr,
)
return 2
out: Path = args.out
want_svg = args.svg_only or out.suffix.lower() == ".svg"
if want_svg:
if out.suffix.lower() != ".svg":
out = out.with_suffix(".svg")
out.write_text(svg, encoding="utf-8")
print(f"[ok] SVG → {out}")
return 0
if svg_to_png(svg, out, args.size):
print(f"[ok] PNG → {out} ({args.set}:{args.name} #{args.color})")
return 0
# PNG 转换失败,保存 SVG 兜底
svg_alt = out.with_suffix(".svg")
svg_alt.write_text(svg, encoding="utf-8")
print(
f"[warn] PNG 转换不可用 (装 `pip install cairosvg` 或 `pip install svglib`)\n"
f" 已保存 SVG → {svg_alt}\n"
f" PowerPoint 2016+ 直接 add_picture(svg) 也可以",
file=sys.stderr,
)
return 1
if __name__ == "__main__":
sys.exit(main())

View File

@ -0,0 +1,348 @@
#!/usr/bin/env python3
"""
PPT Master - SVG Post-processing Tool (Unified Entry Point)
Processes SVG files from svg_output/ and outputs them to svg_final/.
By default, all processing steps are executed. You can also specify
individual steps via arguments.
Architecture note: this module's outputs feed svg_final/ on disk AND its
sub-modules (svg_finalize.embed_icons, svg_finalize.flatten_tspan, ...)
are memory-reused by svg_to_pptx during native conversion. Deleting any
step here may also break native pptx output, not just svg_final/.
See docs/technical-design.md "Post-Processing Pipeline" before modifying.
Usage:
# Execute all processing steps (recommended)
python3 scripts/finalize_svg.py <project_directory>
# Execute only specific steps
python3 scripts/finalize_svg.py <project_directory> --only embed-icons fix-rounded
Examples:
python3 scripts/finalize_svg.py projects/my_project
python3 scripts/finalize_svg.py examples/ppt169_demo --only embed-icons
Processing options:
embed-icons - Replace <use data-icon="..."/> with actual icon SVG
align-images - Align (slice/meet) and Base64-embed all <image> in one pass.
Replaces the former crop-images + fix-aspect + embed-images
trio. The old names remain accepted as aliases for the
merged step, so existing --only invocations keep working.
flatten-text - Convert <tspan> to independent <text> (for special renderers)
fix-rounded - Convert <rect rx="..."/> to <path> (for PPT shape conversion)
"""
import os
import sys
try: # zcbot: Windows GBK 控制台兼容,避免 emoji/© 等触发 UnicodeEncodeError
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
sys.stderr.reconfigure(encoding="utf-8", errors="replace")
except Exception:
pass
import shutil
import argparse
from pathlib import Path
# Import finalize helpers from the internal package.
sys.path.insert(0, str(Path(__file__).parent))
from svg_finalize.align_embed_images import (
align_and_embed_images_in_svg,
count_office_vector_refs_in_svg,
)
from svg_finalize.embed_icons import process_svg_file as embed_icons_in_file
def safe_print(text: str) -> None:
"""Print text while tolerating Windows terminal encoding limits."""
try:
print(text)
except UnicodeEncodeError:
replacements = {
chr(0x23F3): "[..]",
chr(0x2705): "[DONE]",
chr(0x274C): "[ERROR]",
chr(0x26A0) + chr(0xFE0F): "[WARN]",
chr(0x1F4C1): "[DIR]",
chr(0x1F4C4): "[FILE]",
chr(0x1F4E6): "[OK]",
}
for source, target in replacements.items():
text = text.replace(source, target)
print(text)
def process_flatten_text(svg_file: Path, verbose: bool = False) -> bool:
"""Flatten text in a single SVG file (in-place modification)"""
try:
from svg_finalize.flatten_tspan import flatten_text_with_tspans
from xml.etree import ElementTree as ET
tree = ET.parse(str(svg_file))
changed = flatten_text_with_tspans(tree)
if changed:
tree.write(str(svg_file), encoding='unicode', xml_declaration=False)
if verbose:
safe_print(f" [OK] {svg_file.name}: text flattened")
return changed
except Exception as e:
if verbose:
safe_print(f" [ERROR] {svg_file.name}: {e}")
return False
def process_rounded_rect(svg_file: Path, verbose: bool = False) -> int:
"""Convert rounded rectangles in a single SVG file (in-place modification)"""
try:
from svg_finalize.svg_rect_to_path import process_svg
with open(svg_file, 'r', encoding='utf-8') as f:
content = f.read()
processed, count = process_svg(content, verbose=False)
if count > 0:
with open(svg_file, 'w', encoding='utf-8') as f:
f.write(processed)
if verbose:
safe_print(f" [OK] {svg_file.name}: {count} rounded rectangle(s)")
return count
except Exception as e:
if verbose:
safe_print(f" [ERROR] {svg_file.name}: {e}")
return 0
def finalize_project(
project_dir: Path,
options: dict[str, bool],
dry_run: bool = False,
quiet: bool = False,
compress: bool = False,
max_dimension: int | None = None,
) -> bool:
"""
Finalize SVG files in the project
Args:
project_dir: Project directory path
options: Processing options dictionary
dry_run: Preview only, do not execute
quiet: Quiet mode, reduce output
compress: Compress images before embedding
max_dimension: Downscale images exceeding this dimension
"""
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
# Check if svg_output exists
if not svg_output.exists():
safe_print(f"[ERROR] svg_output directory not found: {svg_output}")
return False
# Get list of SVG files
svg_files = list(svg_output.glob('*.svg'))
if not svg_files:
safe_print(f"[ERROR] No SVG files in svg_output")
return False
if not quiet:
print()
safe_print(f"[DIR] Project: {project_dir.name}")
safe_print(f"[FILE] {len(svg_files)} SVG file(s)")
if dry_run:
safe_print("[PREVIEW] Preview mode, no operations will be performed")
return True
# Step 1: Copy directory
if svg_final.exists():
shutil.rmtree(svg_final)
shutil.copytree(svg_output, svg_final)
if not quiet:
print()
# Step 2: Embed icons
if options.get('embed_icons'):
if not quiet:
safe_print("[1/4] Embedding icons...")
icons_count = 0
for svg_file in svg_final.glob('*.svg'):
count = embed_icons_in_file(svg_file, icons_dir, dry_run=False, verbose=False, fallback_dir=icons_fallback_dir)
icons_count += count
if not quiet:
if icons_count > 0:
safe_print(f" {icons_count} icon(s) embedded")
else:
safe_print(" No icons")
# Step 3: Align (slice/meet) and Base64-embed all <image> in one pass.
# Replaces the former crop-images / fix-aspect / embed-images trio: the
# spatial transform (slice → crop, meet → fit-box) and the asset embed
# are mutually exclusive branches per image, sequenced together so each
# SVG is only parsed and serialized once and each bitmap is only read
# from disk once.
if options.get('align_images'):
if not quiet:
safe_print("[2/4] Aligning + embedding images...")
img_count = 0
img_errors = 0
office_vector_count = 0
for svg_file in svg_final.glob('*.svg'):
office_vector_count += count_office_vector_refs_in_svg(svg_file)
count, errs = align_and_embed_images_in_svg(
svg_file,
dry_run=False,
verbose=False,
compress=compress,
max_dimension=max_dimension,
)
img_count += count
img_errors += errs
if not quiet:
if img_count > 0:
msg = f" {img_count} image(s) aligned + embedded"
if img_errors:
msg += f" ({img_errors} error(s))"
safe_print(msg)
if office_vector_count:
safe_print(
f" {office_vector_count} Office vector(s) left external "
"for native PPTX passthrough"
)
elif office_vector_count:
safe_print(
f" {office_vector_count} Office vector(s) left external "
"for native PPTX passthrough"
)
else:
safe_print(" No images")
# Step 4: Flatten text
if options.get('flatten_text'):
if not quiet:
safe_print("[3/4] Flattening text...")
flatten_count = 0
for svg_file in svg_final.glob('*.svg'):
if process_flatten_text(svg_file, verbose=False):
flatten_count += 1
if not quiet:
if flatten_count > 0:
safe_print(f" {flatten_count} file(s) processed")
else:
safe_print(" No processing needed")
# Step 5: Convert rounded rects to Path
if options.get('fix_rounded'):
if not quiet:
safe_print("[4/4] Converting rounded rects to Path...")
rounded_count = 0
for svg_file in svg_final.glob('*.svg'):
count = process_rounded_rect(svg_file, verbose=False)
rounded_count += count
if not quiet:
if rounded_count > 0:
safe_print(f" {rounded_count} rounded rectangle(s) converted")
else:
safe_print(" No rounded rectangles")
# Done
if not quiet:
print()
safe_print("[OK] Done!")
print()
print("Next steps:")
print(f" python scripts/svg_to_pptx.py \"{project_dir}\"")
return True
def main() -> None:
"""Run the CLI entry point."""
parser = argparse.ArgumentParser(
description='PPT Master - SVG Post-processing Tool',
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog='''
Examples:
%(prog)s projects/my_project # Execute all processing (default)
%(prog)s projects/my_project --only embed-icons fix-rounded
%(prog)s projects/my_project -q # Quiet mode
Processing options (for --only):
embed-icons Embed icons
align-images Align (slice/meet) + Base64-embed all <image> (single pass)
flatten-text Flatten text
fix-rounded Convert rounded rects to Path
Aliases (still accepted):
crop-images, fix-aspect, embed-images all map to align-images
'''
)
parser.add_argument('project_dir', type=Path, help='Project directory path')
parser.add_argument(
'--only', nargs='+', metavar='OPTION',
choices=[
'embed-icons',
'align-images',
# Backwards-compatible aliases — all three map to align-images now.
'crop-images', 'fix-aspect', 'embed-images',
'flatten-text', 'fix-rounded',
],
help=('Execute only specified processing steps (default: all). '
'crop-images / fix-aspect / embed-images are accepted as '
'aliases for the merged align-images step.'),
)
parser.add_argument('--dry-run', '-n', action='store_true',
help='Preview only, do not execute')
parser.add_argument('--quiet', '-q', action='store_true',
help='Quiet mode, reduce output')
parser.add_argument('--compress', action='store_true',
help='Compress images before embedding (JPEG quality=85, PNG optimize)')
parser.add_argument('--max-dimension', type=int, default=None,
help='Downscale images exceeding this dimension on either axis (e.g., 2560)')
args = parser.parse_args()
if not args.project_dir.exists():
safe_print(f"[ERROR] Project directory does not exist: {args.project_dir}")
sys.exit(1)
# Aliases: any of crop-images / fix-aspect / embed-images implies the
# merged align-images step. Older invocations stay valid.
_ALIGN_ALIASES = {'align-images', 'crop-images', 'fix-aspect', 'embed-images'}
# Determine processing options
if args.only:
only = set(args.only)
options = {
'embed_icons': 'embed-icons' in only,
'align_images': bool(only & _ALIGN_ALIASES),
'flatten_text': 'flatten-text' in only,
'fix_rounded': 'fix-rounded' in only,
}
else:
# Execute all by default
options = {
'embed_icons': True,
'align_images': True,
'flatten_text': True,
'fix_rounded': True,
}
success = finalize_project(args.project_dir, options, args.dry_run, args.quiet,
compress=args.compress,
max_dimension=args.max_dimension)
sys.exit(0 if success else 1)
if __name__ == '__main__':
main()

View File

@ -1,822 +0,0 @@
"""pptx_helpers.py — PPT skill 的共享版式工具箱(卡片式视觉系统)。
deck 在一个 `build_deck.py` 里构建,每页一个小函数,这些 helper 统一在
`P.` 命名空间下调用 既省 token,又保证长 deck 里坐标/配色不漂移
用法( build_deck.py 顶部):
import sys; sys.path.insert(0, "<skill_dir>/scripts") # <skill_dir> 用 system prompt 注入值
import pptx_helpers as P
prs = P.new_presentation("16:9") # 默认 16:9,可传 4:3 / 9:16 / 3:4
P.set_palette(spec_path="<task_dir>/...spec.md") # 默认商务红;spec 覆盖了才需要
slide = P.add_slide(prs)
P.apply_brand(slide, "cover")
P.add_textbox(slide, 0.9, 2.6, 11.9, 1.4, "标题", 44, bold=True, color=P.INK)
prs.save("<task_dir>/<topic>.pptx")
视觉系统(相对老版"平矩形 + 圆点 bullet"的升级):
- **卡片**:`add_card` 圆角 + 柔和投影 + 可选底色/边线/强调条 内容页主力容器
- **色阶**:`set_palette` 从主//强调派生 wash/soft/dark 明暗阶,白底之外有层次
- **渐变**:`add_gradient_rect` 用于封面/章节大色块(原生可编辑,非图片)
- **组件**:`add_kpi`(数字卡) `add_pill`(胶囊标签) `add_icon_tile`(图标底块)
`add_eyebrow`(小标签) `add_chevron`(流程箭头) `add_notes`(演讲者备注)
一律用 `P.xxx` 访问颜色常量与函数 set_palette 靠改模块属性生效,
`from pptx_helpers import *` 会把旧绑定拷进页面命名空间,覆盖配色不生效
"""
from __future__ import annotations
import re
from pathlib import Path
from pptx import Presentation
from pptx.util import Inches, Pt, Emu
from pptx.dml.color import RGBColor
from pptx.enum.text import PP_ALIGN, MSO_ANCHOR, MSO_AUTO_SIZE
from pptx.enum.shapes import MSO_SHAPE
from pptx.oxml.ns import qn
# ============================================================
# 配色 (商务红 — 硬约束默认)
# ============================================================
# ⛔ 不允许擅自换色:除非用户明确点名其它配色 或 spec 已写其它 hex,否则就是这套红。
# 要换走 set_palette(),禁止以"这场景蓝色更专业"这类自我合理化做替换。
PRIMARY = RGBColor(0xC0, 0x00, 0x00) # 深红 - 标题/强调/关键数据
SECONDARY = RGBColor(0xE1, 0x55, 0x54) # 砖红 - 次要图形
ACCENT = RGBColor(0xFF, 0xC1, 0x07) # 金黄 - 关键数据点/CTA
INK = RGBColor(0x1F, 0x1F, 0x1F)
GREY = RGBColor(0x59, 0x59, 0x59)
GREY_LIGHT = RGBColor(0x88, 0x88, 0x88)
HAIRLINE = RGBColor(0xDD, 0xDD, 0xDD) # 细分隔线
BG = RGBColor(0xFA, 0xFA, 0xFA) # 背景近白
WHITE = RGBColor(0xFF, 0xFF, 0xFF)
# 语义状态色(升/降)—— 数据趋势标注用,绿=正/红=负,业界通用约定。
# 不计入"商务红三色制"(quality_check 把绿色当语义状态色豁免)。
GOOD = RGBColor(0x1E, 0x9E, 0x62) # 增长 / 正向
BAD = RGBColor(0xD1, 0x34, 0x38) # 下降 / 风险
# —— 从主/辅/强调派生的明暗色阶 (set_palette 里按当前三色重算) ——
# 卡片底色 / 章节渐变 / 标签底 都从这套阶取,避免"白底 + 纯红"两极、缺中间层次。
PRIMARY_WASH = RGBColor(0xF7, 0xE6, 0xE6) # 主色 92% 兑白 —— 整页/大区域浅底
PRIMARY_SOFT = RGBColor(0xF0, 0xCC, 0xCC) # 主色 80% 兑白 —— 卡片/标签浅底
PRIMARY_DARK = RGBColor(0x8A, 0x00, 0x00) # 主色压暗 —— 渐变深端
ACCENT_SOFT = RGBColor(0xFF, 0xEC, 0xB8) # 强调 80% 兑白 —— 高亮底
SURFACE = RGBColor(0xFF, 0xFF, 0xFF) # 卡片面(白,衬在 BG 上靠投影浮起)
CN_FONT = "微软雅黑" # 中文字形走 <a:ea> 槽位
EN_FONT = "Arial" # 拉丁字形走 <a:latin> 槽位
# ============================================================
# 画布与安全区 (new_presentation / load 会按实际尺寸回填这些)
# ============================================================
SLIDE_W = 13.33
SLIDE_H = 7.5
MARGIN_X = 0.7
MARGIN_Y = 0.5
SAFE_LEFT = MARGIN_X
SAFE_TOP = MARGIN_Y
SAFE_RIGHT = SLIDE_W - MARGIN_X
SAFE_BOTTOM = SLIDE_H - MARGIN_Y
SAFE_W = SAFE_RIGHT - SAFE_LEFT
SAFE_H = SAFE_BOTTOM - SAFE_TOP
_CANVAS = {
"16:9": (13.33, 7.5),
"4:3": (10.0, 7.5),
"9:16": (7.5, 13.33),
"3:4": (7.5, 10.0),
}
def _recompute_safe() -> None:
global SAFE_LEFT, SAFE_TOP, SAFE_RIGHT, SAFE_BOTTOM, SAFE_W, SAFE_H
SAFE_LEFT = MARGIN_X
SAFE_TOP = MARGIN_Y
SAFE_RIGHT = SLIDE_W - MARGIN_X
SAFE_BOTTOM = SLIDE_H - MARGIN_Y
SAFE_W = SAFE_RIGHT - SAFE_LEFT
SAFE_H = SAFE_BOTTOM - SAFE_TOP
def new_presentation(canvas: str = "16:9") -> Presentation:
"""建空白 deck 并设画布尺寸,同步回填模块的安全区常量。第一页用。"""
global SLIDE_W, SLIDE_H
if canvas not in _CANVAS:
raise ValueError(f"未知画布 {canvas!r},支持 {list(_CANVAS)}")
SLIDE_W, SLIDE_H = _CANVAS[canvas]
_recompute_safe()
prs = Presentation()
prs.slide_width = Inches(SLIDE_W)
prs.slide_height = Inches(SLIDE_H)
return prs
def load(path) -> Presentation:
"""载入已有 deck,并按文件实际尺寸回填模块画布常量(逐页进程间自动同步)。"""
global SLIDE_W, SLIDE_H
prs = Presentation(str(path))
SLIDE_W = prs.slide_width / 914400
SLIDE_H = prs.slide_height / 914400
_recompute_safe()
return prs
def add_slide(prs: Presentation):
"""追加一张空白版式(layout 6)的 slide。"""
return prs.slides.add_slide(prs.slide_layouts[6])
# ============================================================
# 配色覆盖 (默认商务红;spec 写了别的色才调) + 色阶派生
# ============================================================
def _to_rgb(h: str) -> RGBColor:
return RGBColor.from_string(h.lstrip("#").upper())
def _mix(c1: RGBColor, c2: RGBColor, t: float) -> RGBColor:
"""线性混合:t=0 → c1,t=1 → c2。"""
return RGBColor(
round(c1[0] + (c2[0] - c1[0]) * t),
round(c1[1] + (c2[1] - c1[1]) * t),
round(c1[2] + (c2[2] - c1[2]) * t),
)
def tint(c: RGBColor, pct: float) -> RGBColor:
"""提亮:pct=0.85 → 兑 85% 白(越大越浅)。"""
return _mix(c, WHITE, pct)
def shade(c: RGBColor, pct: float) -> RGBColor:
"""压暗:pct=0.2 → 混 20% 黑(越大越深)。"""
return _mix(c, RGBColor(0, 0, 0), pct)
def _recompute_ramp() -> None:
"""按当前 PRIMARY/ACCENT 重算明暗色阶。set_palette 末尾调。"""
global PRIMARY_WASH, PRIMARY_SOFT, PRIMARY_DARK, ACCENT_SOFT
PRIMARY_WASH = tint(PRIMARY, 0.92)
PRIMARY_SOFT = tint(PRIMARY, 0.80)
PRIMARY_DARK = shade(PRIMARY, 0.42) # 加深:渐变要肉眼看得出深浅,别两端几乎同色
ACCENT_SOFT = tint(ACCENT, 0.78)
def set_palette(primary: str | None = None, secondary: str | None = None,
accent: str | None = None, cn_font: str | None = None,
en_font: str | None = None, spec_path=None) -> None:
"""覆盖主题色 / 字体,并重算派生色阶。整 deck 设一次。
- 显式传 primary/secondary/accent(hex,带不带 # 都行)即覆盖对应色。
- spec_path: spec.md 按文档顺序取前 3 #hex 作 主/辅/强调
(spec 模板里配色行是 hex 唯一出现处)找不到则保持商务红默认
- 都不传 = 维持商务红,无副作用
"""
global PRIMARY, SECONDARY, ACCENT, CN_FONT, EN_FONT
if spec_path:
p = Path(spec_path)
if p.exists():
hexes = re.findall(r"#([0-9A-Fa-f]{6})", p.read_text(encoding="utf-8"))
if len(hexes) >= 1 and primary is None:
primary = hexes[0]
if len(hexes) >= 2 and secondary is None:
secondary = hexes[1]
if len(hexes) >= 3 and accent is None:
accent = hexes[2]
if primary:
PRIMARY = _to_rgb(primary)
if secondary:
SECONDARY = _to_rgb(secondary)
if accent:
ACCENT = _to_rgb(accent)
if cn_font:
CN_FONT = cn_font
if en_font:
EN_FONT = en_font
_recompute_ramp()
# ============================================================
# 安全区校验
# ============================================================
def assert_inside(left, top, width, height, name="") -> None:
"""放置前调一次。越界直接报错而不是悄悄超出。"""
if left < 0 or top < 0:
raise ValueError(f"[{name}] 左/上为负: ({left}, {top})")
if left + width > SLIDE_W + 1e-3:
raise ValueError(f"[{name}] 右越界: {left}+{width} > {SLIDE_W}")
if top + height > SLIDE_H + 1e-3:
raise ValueError(f"[{name}] 下越界: {top}+{height} > {SLIDE_H}")
# ============================================================
# 文本辅助
# ============================================================
def _apply_run_font(run, size, bold, color, latin_font, ea_font) -> None:
"""设字号/粗细/颜色 + 同时设 latin(拉丁)与 ea/cs(东亚)字体。
关键:python-pptx `run.font.name = x` 只写 <a:latin>中文字形走 <a:ea>
槽位,不设的话会落到主题默认字体 这就是指定了微软雅黑却没真生效的根因
这里 latin=英文体ea/cs=中文体,中英混排各自命中正确字体
"""
run.font.size = Pt(size)
run.font.bold = bold
run.font.color.rgb = color
run.font.name = latin_font # <a:latin>
rPr = run._r.get_or_add_rPr()
for tag in ("a:ea", "a:cs"):
el = rPr.find(qn(tag))
if el is None:
el = rPr.makeelement(qn(tag), {})
rPr.append(el)
el.set("typeface", ea_font)
def set_text(tf, text, size, bold=False, color=INK, align=PP_ALIGN.LEFT,
font=None) -> None:
"""写文本并设样式。**多行(含 \\n)时每一段都上色** —— 否则 `\\n` 产生的
2 段会继承主题默认色(踩过:封面副标题第二行变暗色看不见)
font=None 拉丁 EN_FONT + 东亚 CN_FONT; font 则两槽都用它(纯英文大字/数字)"""
latin = font or EN_FONT
ea = font or CN_FONT
tf.text = text
for p in tf.paragraphs:
p.alignment = align
for r in p.runs:
_apply_run_font(r, size, bold, color, latin, ea)
def add_textbox(slide, left, top, width, height, text, size,
bold=False, color=INK, align=PP_ALIGN.LEFT,
anchor=MSO_ANCHOR.TOP, font=None, shrink=True,
name="textbox"):
"""加文本框。默认 word_wrap + shrink-to-fit 兜底(不替代字数预算)。"""
assert_inside(left, top, width, height, name)
tb = slide.shapes.add_textbox(Inches(left), Inches(top),
Inches(width), Inches(height))
tb.name = name # 语义名写进 pptx —— quality_check 按名豁免标签 / 计 bullet 靠这个
tf = tb.text_frame
tf.vertical_anchor = anchor
tf.word_wrap = True
if shrink:
tf.auto_size = MSO_AUTO_SIZE.TEXT_TO_FIT_SHAPE
set_text(tf, text, size, bold, color, align, font)
return tb
# ============================================================
# 形状辅助 (无边线实心填充)
# ============================================================
def add_rect(slide, left, top, width, height, fill, name="rect"):
assert_inside(left, top, width, height, name)
s = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, Inches(left), Inches(top),
Inches(width), Inches(height))
s.name = name
s.fill.solid()
s.fill.fore_color.rgb = fill
s.line.fill.background()
return s
def add_shape(slide, kind, left, top, width, height, fill, name="shape"):
assert_inside(left, top, width, height, name)
s = slide.shapes.add_shape(kind, Inches(left), Inches(top),
Inches(width), Inches(height))
s.name = name
s.fill.solid()
s.fill.fore_color.rgb = fill
s.line.fill.background()
return s
def add_dot(slide, x, y, size=0.18, color=None):
return add_shape(slide, MSO_SHAPE.OVAL, x, y, size, size,
ACCENT if color is None else color, "dot")
def add_accent_line(slide, x, y, length=1.0, thickness=0.05, color=None):
"""标题下面那条强调线,替代大色块。"""
return add_rect(slide, x, y, length, thickness,
ACCENT if color is None else color, "accent_line")
def add_badge(slide, x, y, num, diameter=0.7, fill=None, fg=None):
"""编号徽章 (圆 + 数字)。"""
c = add_shape(slide, MSO_SHAPE.OVAL, x, y, diameter, diameter,
PRIMARY if fill is None else fill, "badge")
tf = c.text_frame
tf.text = str(num)
p = tf.paragraphs[0]
p.alignment = PP_ALIGN.CENTER
_apply_run_font(p.runs[0], int(diameter * 28), True,
WHITE if fg is None else fg, EN_FONT, EN_FONT)
return c
# ============================================================
# 视觉质感:投影 / 圆角 / 渐变 / 描边
# ============================================================
def set_shadow(shape, blur=0.10, dist=0.045, dir_deg=90, alpha=0.26,
color="000000") -> None:
"""给形状加柔和外投影(写 <a:effectLst><a:outerShdw>)。
blur/dist 单位英寸;dir_deg 投影方向(90=正下,默认);alpha 不透明度(0-1)
卡片靠这个从背景"浮起",是平矩形与卡片观感的关键差
"""
spPr = shape._element.spPr
for el in spPr.findall(qn("a:effectLst")):
spPr.remove(el)
eff = spPr.makeelement(qn("a:effectLst"), {})
shd = eff.makeelement(qn("a:outerShdw"), {
"blurRad": str(int(Inches(blur))),
"dist": str(int(Inches(dist))),
"dir": str(int(dir_deg * 60000)),
"rotWithShape": "0",
})
clr = shd.makeelement(qn("a:srgbClr"), {"val": color})
a = clr.makeelement(qn("a:alpha"), {"val": str(int(alpha * 100000))})
clr.append(a)
shd.append(clr)
eff.append(shd)
spPr.append(eff)
def set_line(shape, color, weight=0.75) -> None:
"""给形状描边(weight 单位 pt)。weight=0 / color=None 走无边线。"""
if color is None:
shape.line.fill.background()
return
shape.line.color.rgb = color
shape.line.width = Pt(weight)
def _round_adj(shape, radius_in) -> None:
"""把圆角矩形的圆角设成约 radius_in 英寸(adjustments[0] 是相对短边的比例)。"""
try:
short = min(shape.width, shape.height) / 914400.0
if short > 0:
shape.adjustments[0] = max(0.0, min(0.5, radius_in / short))
except (IndexError, ZeroDivisionError):
pass
def add_round_rect(slide, left, top, width, height, fill, radius=0.10,
name="round_rect"):
"""无边线圆角矩形。radius 单位英寸(约 0.08-0.14 观感最稳)。"""
assert_inside(left, top, width, height, name)
s = slide.shapes.add_shape(MSO_SHAPE.ROUNDED_RECTANGLE,
Inches(left), Inches(top),
Inches(width), Inches(height))
s.name = name
s.fill.solid()
s.fill.fore_color.rgb = fill
s.line.fill.background()
_round_adj(s, radius)
return s
def add_gradient_rect(slide, left, top, width, height, c1, c2, angle=90,
rounded=False, radius=0.10, name="gradient"):
"""渐变矩形(原生可编辑,非图片)。封面/章节大色块用。
angle:渐变方向(,0=,90=)rounded=True 走圆角
"""
assert_inside(left, top, width, height, name)
kind = MSO_SHAPE.ROUNDED_RECTANGLE if rounded else MSO_SHAPE.RECTANGLE
s = slide.shapes.add_shape(kind, Inches(left), Inches(top),
Inches(width), Inches(height))
s.name = name
s.line.fill.background()
if rounded:
_round_adj(s, radius)
s.fill.gradient()
stops = s.fill.gradient_stops
stops[0].color.rgb = c1
stops[0].position = 0.0
stops[1].color.rgb = c2
stops[1].position = 1.0
try:
s.fill.gradient_angle = float(angle)
except (ValueError, TypeError):
pass
return s
def add_bg(slide, color=None):
"""整页背景色块(每页第一笔铺,后续元素叠其上)。默认近白 BG。"""
return add_rect(slide, 0, 0, SLIDE_W, SLIDE_H,
BG if color is None else color, "bg")
# ============================================================
# 卡片 (内容页主力容器) + 组件
# ============================================================
def add_card(slide, left, top, width, height, fill=None, radius=0.12,
shadow=False, border=None, accent=None, accent_w=0.07,
name="card"):
"""圆角卡片。**视觉手段单选**(投影 / 描边 / 底色 三选一,不叠加 = 模板味)。
- 默认**平卡**:白底卡自动描发丝边定义边界(不投影)平铺网格里的对等卡都该这样
- shadow=True:**只给真正"悬浮"的卡**(照片上的卡被挑出的推荐项);
pptmaster 铁律:每页 2-3 个投影元素,对等网格卡一律平
- fill PRIMARY_WASH/SOFT 等浅底时,底色即是手段,不再描边
- accent:左内缘细竖条(语义标记,"这一张") 有它就不再自动描边
"""
is_white = fill is None
fill = SURFACE if fill is None else fill
card = add_round_rect(slide, left, top, width, height, fill, radius, name)
if shadow:
set_shadow(card) # 手段:投影(悬浮卡专用)
else:
if border is None: # 手段:描边(仅白底平卡且无 accent 时自动)
border = is_white and accent is None
if border:
set_line(card, HAIRLINE, 1.0)
if accent is not None: # 手段:左侧语义强调条
add_round_rect(slide, left + 0.18, top + 0.22, accent_w,
max(0.4, height - 0.44), accent, radius=0.04,
name=name + "_accent")
return card
def add_icon_tile(slide, x, y, size=0.9, png_path=None, fill=None,
radius=0.12, name="icon_tile"):
"""图标底块:圆角浅色方块 + 居中图标 PNG(没 PNG 就只出底块)。
fill 默认 PRIMARY_SOFT(主色浅底)图标按 ~58% 居中,留呼吸
业务概念页(战略/能力/模块)用它替代"光秃秃图标""只有圆点"
"""
tile = add_round_rect(slide, x, y, size, size,
PRIMARY_SOFT if fill is None else fill, radius,
name)
if png_path and Path(str(png_path)).exists():
ic = size * 0.56
off = (size - ic) / 2
slide.shapes.add_picture(str(png_path), Inches(x + off),
Inches(y + off), width=Inches(ic))
return tile
def add_icon(slide, png_path, x, y, size=0.6):
"""直接摆图标 PNG(方形源,只给 width 等比)。底块版用 add_icon_tile。"""
if png_path and Path(str(png_path)).exists():
return slide.shapes.add_picture(str(png_path), Inches(x), Inches(y),
width=Inches(size))
return None
def add_pill(slide, x, y, width, height, text, fill=None, fg=None, size=12,
name="pill"):
"""胶囊标签 / chip:全圆角小块 + 居中文字。分类标签、状态、eyebrow 用。"""
s = slide.shapes.add_shape(MSO_SHAPE.ROUNDED_RECTANGLE,
Inches(x), Inches(y),
Inches(width), Inches(height))
s.name = name
s.fill.solid()
s.fill.fore_color.rgb = PRIMARY if fill is None else fill
s.line.fill.background()
s.adjustments[0] = 0.5 # 全圆角
tf = s.text_frame
tf.word_wrap = False
tf.margin_top = tf.margin_bottom = 0
set_text(tf, text, size, bold=True, color=WHITE if fg is None else fg,
align=PP_ALIGN.CENTER)
tf.paragraphs[0].alignment = PP_ALIGN.CENTER
s.text_frame.vertical_anchor = MSO_ANCHOR.MIDDLE
return s
def add_eyebrow(slide, x, y, text, color=None, size=13, width=4.0):
"""小标签 / kicker:标题上方一行弱化前缀(如『核心结论 / 01』)。"""
return add_textbox(slide, x, y, width, 0.35, text, size, bold=True,
color=PRIMARY if color is None else color,
shrink=False, name="eyebrow")
def add_kpi(slide, left, top, width, height, value, label, baseline=None,
delta=None, delta_dir=None, value_color=None, card=True,
value_size=40, name="kpi"):
"""KPI 数字卡:大号数字 + 标签 +(对比基准)+(升降趋势)。
**数据语境化铁律**(pptmaster):数字不要孤立出现尽量给:
- baseline:对比基准, "行业均值 82%" / "上季 1.0M"(灰色小字)
- delta:趋势标注, "12.3%" / "+11pt";delta_dir 'up'/'down'/'flat' 决定升降色
(绿= / = / =);不传 delta_dir 则从 delta 开头的 +/-// 推断
数据页优先 2-4 张并排,比小柱图信息密度与质感都高value EN_FONT
"""
if card:
add_card(slide, left, top, width, height, fill=SURFACE, name=name + "_card")
pad = 0.28
add_textbox(slide, left + pad, top + pad, width - 2 * pad, height * 0.42,
str(value), value_size, bold=True,
color=PRIMARY if value_color is None else value_color,
font=EN_FONT, anchor=MSO_ANCHOR.BOTTOM, shrink=False,
name=name + "_val")
add_textbox(slide, left + pad, top + pad + height * 0.42, width - 2 * pad,
0.36, label, 15, color=INK, anchor=MSO_ANCHOR.TOP,
name=name + "_label")
yb = top + height - 0.42
if delta:
if delta_dir is None:
s = str(delta)
delta_dir = ("up" if (s[:1] in "+↑" or "" in s or "" in s)
else "down" if (s[:1] in "-↓" or "" in s or "" in s)
else "flat")
dcol = GOOD if delta_dir == "up" else BAD if delta_dir == "down" else GREY
add_textbox(slide, left + pad, yb, width - 2 * pad, 0.34, str(delta),
13, bold=True, color=dcol, shrink=False, name=name + "_delta")
yb -= 0.32
if baseline:
add_textbox(slide, left + pad, yb, width - 2 * pad, 0.32, str(baseline),
12, color=GREY_LIGHT, shrink=False, name=name + "_base")
def add_takeaway(slide, text, top=None, name="takeaway"):
"""Takeaway Box:标题下一句话**结论**(浅主色底 + 左主色短条)。
咨询风内容页标配 "这页要讲什么"压成一句可带走的结论(pyramid 结论先行)
:"Q4 同比增 158%,创历史新高" 而不是 "营收情况"
"""
y = (SAFE_TOP + 1.0) if top is None else top
add_round_rect(slide, SAFE_LEFT, y, SAFE_W, 0.6, PRIMARY_WASH, radius=0.05,
name=name)
add_round_rect(slide, SAFE_LEFT, y, 0.09, 0.6, PRIMARY, radius=0.02,
name=name + "_bar")
add_textbox(slide, SAFE_LEFT + 0.32, y, SAFE_W - 0.6, 0.6, text, 16,
bold=True, color=INK, anchor=MSO_ANCHOR.MIDDLE,
name=name + "_txt")
def add_source(slide, text, name="source"):
"""数据来源标注(右下角弱化)。**含数据的页必标**(咨询风硬规则)。"""
add_textbox(slide, SAFE_LEFT, SLIDE_H - 0.48, SAFE_W, 0.32,
f"来源:{text}", 11, color=GREY_LIGHT, align=PP_ALIGN.RIGHT,
shrink=False, name=name)
def add_chevron(slide, x, y, width=0.55, height=0.5, color=None):
"""流程箭头(步骤之间的指向)。"""
return add_shape(slide, MSO_SHAPE.CHEVRON, x, y, width, height,
(GREY_LIGHT if color is None else color), "chevron")
def add_divider(slide, x, y, length, vertical=False, color=None):
"""细分隔线(横/竖)。"""
c = HAIRLINE if color is None else color
if vertical:
return add_rect(slide, x, y, 0.02, length, c, "divider")
return add_rect(slide, x, y, length, 0.02, c, "divider")
# ============================================================
# 组合版式件:均衡网格 / 时间轴 / 流程闭环 / 背景图
# —— 模型直接调一个函数,别再手摆参差网格 / 用卡片硬凑时间线
# ============================================================
import math as _math
import os as _os
_GRID_COLS = {1: 1, 2: 2, 3: 3, 4: 2, 5: 3, 6: 3, 7: 4, 8: 4, 9: 3}
def _unpack(item, keys, defaults):
if isinstance(item, dict):
return [item.get(k, d) for k, d in zip(keys, defaults)]
vals = list(item) + list(defaults)
return vals[:len(keys)]
def add_card_grid(slide, items, top, height, cols=None, gap=0.35,
icon_dir=None, icon_color="C00000", accent=None,
title_size=18, body_size=14, name="grid"):
"""一次摆 N 张概念卡,**自动均衡行列**(2×2 / 2×3,不再手摆参差 3+2)。
items: 每项 {icon, title, body} (icon, title, body);icon 是图标名
( tabler_ 前缀, 'target'), icon_dir PNG;None 则不放图标
top/height: 网格纵向区域( 标题下 ~1.95 底部留页脚约 6.9)
布局自适应:**单行**(rows=1)图标顶置成高特征卡;**多行**图标左置成横向卡
(正文拿到整卡高度,不会被顶置图标挤溢出)正文请保持精炼( ~18 /)
"""
n = len(items)
if cols is None:
cols = _GRID_COLS.get(n, 4)
rows = _math.ceil(n / cols)
cw = (SAFE_W - gap * (cols - 1)) / cols
ch = (height - gap * (rows - 1)) / rows
pad = 0.32
icon_top = rows == 1
for i, it in enumerate(items):
icon, title, body = _unpack(it, ("icon", "title", "body"), (None, "", ""))
r, c = divmod(i, cols)
x = SAFE_LEFT + c * (cw + gap)
y = top + r * (ch + gap)
add_card(slide, x, y, cw, ch, accent=accent, name=f"{name}_card_{i}")
has_icon = bool(icon and icon_dir)
png = (_os.path.join(str(icon_dir), f"tabler_{icon}_{icon_color}_128.png")
if has_icon else None)
if icon_top:
tile = max(0.95, min(1.3, cw * 0.30))
if has_icon:
add_icon_tile(slide, x + pad, y + 0.4, tile, png_path=png,
name=f"{name}_tile_{i}")
ty = y + 0.4 + tile + 0.2
else:
ty = y + pad
tx, tw = x + pad, cw - 2 * pad
add_textbox(slide, tx, ty, tw, 0.45, title, title_size, bold=True,
color=INK, name=f"{name}_t_{i}")
add_textbox(slide, tx, ty + 0.5, tw, y + ch - 0.25 - (ty + 0.5),
body, body_size, color=GREY, name=f"{name}_b_{i}")
else:
# 多行:图标左置,文字竖直居中(整卡高度给文字,不会被挤)
tile = max(0.72, min(0.95, ch * 0.46, cw * 0.26))
if has_icon:
add_icon_tile(slide, x + pad, y + (ch - tile) / 2, tile,
png_path=png, name=f"{name}_tile_{i}")
tx = x + pad + tile + 0.26
else:
tx = x + pad
tw = x + cw - pad - tx
blk = 1.15 # 文字块估高,用于竖直居中
ty = y + max(pad, (ch - blk) / 2)
add_textbox(slide, tx, ty, tw, 0.42, title, title_size, bold=True,
color=INK, name=f"{name}_t_{i}")
add_textbox(slide, tx, ty + 0.46, tw, y + ch - pad - (ty + 0.46),
body, body_size, color=GREY, name=f"{name}_b_{i}")
def add_timeline(slide, nodes, y=3.2, name="tl"):
"""横向时间轴:主轴线 + 均布节点(年份 pill 在上,标题/说明在下)。
nodes: list of {year, title, body} (year, title, body)3-6 个最佳
发展历程 / 路线图 / 里程碑类内容用它,**别塞进卡片网格**
"""
n = len(nodes)
x0 = SAFE_LEFT + 0.4
x1 = SAFE_RIGHT - 0.4
span = x1 - x0
add_rect(slide, x0, y, span, 0.035, PRIMARY, "tl_axis")
step = span / (n - 1) if n > 1 else 0
for i, nd in enumerate(nodes):
year, title, body = _unpack(nd, ("year", "title", "body"), ("", "", ""))
cx = x0 + i * step
d = 0.26
add_shape(slide, MSO_SHAPE.OVAL, cx - d / 2, y + 0.0175 - d / 2, d, d,
PRIMARY, f"tl_dot_{i}")
pw = 1.15
px = max(0.2, min(cx - pw / 2, SLIDE_W - pw - 0.2))
add_pill(slide, px, y - 0.66, pw, 0.42, str(year), fill=ACCENT,
fg=INK, size=14, name=f"tl_year_{i}")
bw = min(2.5, step * 0.96) if n > 1 else 3.0
tx = max(0.2, min(cx - bw / 2, SLIDE_W - bw - 0.2))
add_textbox(slide, tx, y + 0.42, bw, 0.45, title, 16, bold=True,
color=INK, align=PP_ALIGN.CENTER, name=f"tl_t_{i}")
add_textbox(slide, tx, y + 0.92, bw, 1.4, body, 14, color=GREY,
align=PP_ALIGN.CENTER, name=f"tl_b_{i}")
def add_cycle(slide, steps, cx=None, cy=4.5, radius=1.55, center_label=None,
name="cyc"):
"""流程闭环:节点沿圆环顺时针均布 + 可选中心词 + 浅环连线。
steps: list of {title, body} (title, body)4-6 个最佳
"感知-规划-执行-反馈"这类**循环**用它,别做成平铺卡片(丢了闭环语义)
"""
n = len(steps)
if cx is None:
cx = SLIDE_W / 2
ry = radius * 0.80 # 纵向压扁成椭圆,16:9 上更协调
ring = add_shape(slide, MSO_SHAPE.OVAL, cx - radius, cy - ry,
2 * radius, 2 * ry, WHITE, name + "_ring")
ring.fill.background()
set_line(ring, HAIRLINE, 1.5)
if center_label:
cd = radius * 0.80
add_shape(slide, MSO_SHAPE.OVAL, cx - cd / 2, cy - cd / 2, cd, cd,
PRIMARY_WASH, name + "_hub")
add_textbox(slide, cx - cd / 2, cy - cd / 2, cd, cd, center_label, 17,
bold=True, color=PRIMARY, align=PP_ALIGN.CENTER,
anchor=MSO_ANCHOR.MIDDLE, name=name + "_hublabel")
nd = 1.0
for i, st in enumerate(steps):
title, body = _unpack(st, ("title", "body"), ("", ""))
ang = _math.radians(-90 + i * 360 / n)
nx = cx + radius * _math.cos(ang)
ny = cy + ry * _math.sin(ang)
add_badge(slide, nx - nd / 2, ny - nd / 2, i + 1, diameter=nd)
lw = 2.1
lx = max(0.2, min(nx - lw / 2, SLIDE_W - lw - 0.2))
ly = (ny - nd / 2 - 0.46) if ny <= cy else (ny + nd / 2 + 0.06)
ly = max(0.2, min(ly, SLIDE_H - 0.4))
add_textbox(slide, lx, ly, lw, 0.4, title, 15, bold=True, color=INK,
align=PP_ALIGN.CENTER, name=f"{name}_t_{i}")
def add_toc(slide, items, top=2.2, row_h=None, name="toc"):
"""贯通整宽的目录:每行 = 序号 + 标题 +(右侧副标)+ 发丝分隔线。
items: 每项 (title, caption) {title, caption} 或纯 title 字符串
"左侧一列编号圆点"铺满版面信息更足(副标给每章一句定位)
"""
n = len(items)
if row_h is None:
row_h = min(0.95, (SAFE_BOTTOM - 0.2 - top) / n)
for i, it in enumerate(items):
if isinstance(it, str):
title, cap = it, ""
else:
title, cap = _unpack(it, ("title", "caption"), ("", ""))
y = top + i * row_h
add_textbox(slide, SAFE_LEFT, y, 1.05, row_h - 0.18, f"{i + 1:02d}", 34,
bold=True, color=PRIMARY, font=EN_FONT,
anchor=MSO_ANCHOR.MIDDLE, name=f"{name}_n_{i}")
add_textbox(slide, SAFE_LEFT + 1.25, y, 6.3, row_h - 0.18, title, 21,
bold=True, color=INK, anchor=MSO_ANCHOR.MIDDLE,
name=f"{name}_t_{i}")
if cap:
add_textbox(slide, SAFE_LEFT + 8.0, y, SAFE_W - 8.0, row_h - 0.18,
cap, 15, color=GREY, align=PP_ALIGN.RIGHT,
anchor=MSO_ANCHOR.MIDDLE, name=f"{name}_c_{i}")
add_divider(slide, SAFE_LEFT, y + row_h - 0.1, SAFE_W)
def add_picture_bg(slide, png):
"""整页铺一张渲染好的高清背景图(混合方案:背景图 + 其上原生可编辑文字)。
封面/章节用: `add_picture_bg(slide, bg.png)`,再叠 `add_textbox` 文字
背景不可改但文字仍能在 PPT 里编辑 editable 前提下拿到的最佳观感
"""
if png and Path(str(png)).exists():
return slide.shapes.add_picture(str(png), Inches(0), Inches(0),
width=Inches(SLIDE_W),
height=Inches(SLIDE_H))
return None
# ============================================================
# 演讲者备注
# ============================================================
def add_notes(slide, text) -> None:
"""写演讲者备注(演示时可见,正式产物标配)。每页给 2-4 句口述要点。"""
slide.notes_slide.notes_text_frame.text = text or ""
# ============================================================
# 标题套件 (内页通用)
# ============================================================
def page_title(slide, text, page_num=None, total=None, footer="项目汇报",
eyebrow=None):
"""内页标题 + 强调线 (+ 可选 eyebrow 小标签 + 页脚页码)。
eyebrow:标题上方一行弱化前缀(章节名 / 分类),给则标题整体下移
"""
ty = SAFE_TOP
if eyebrow:
add_eyebrow(slide, SAFE_LEFT, SAFE_TOP, eyebrow)
ty = SAFE_TOP + 0.4
add_textbox(slide, SAFE_LEFT, ty, SAFE_W, 0.7, text,
32, bold=True, color=PRIMARY, name="title")
add_accent_line(slide, SAFE_LEFT, ty + 0.85, length=0.8)
if page_num is not None and total is not None:
add_textbox(slide, SAFE_LEFT, SLIDE_H - 0.5, 6, 0.4, footer,
11, color=GREY_LIGHT, shrink=False, name="footer")
add_textbox(slide, SLIDE_W - 1.33, SLIDE_H - 0.5, 1.2, 0.4,
f"{page_num} / {total}", 11, color=GREY_LIGHT,
align=PP_ALIGN.RIGHT, shrink=False, name="page_num")
# ============================================================
# 品牌条 (每页起手必调,确保不是裸白纸)
# ============================================================
def apply_brand(slide, kind="inner"):
"""统一品牌锚点。每个版式第一行调用。
cover 右侧主色深主色渐变大块 + 左侧细强调短线(现代封面)
inner (默认) 近白底 + 左侧主色窄条 + 底部细灰线
section 主色深主色整页渐变 + 强调装饰条(章节大色块)
end 浅底 + /底强调短线
"""
btm = SLIDE_H - 0.32
if kind == "cover":
add_bg(slide, WHITE)
# 右侧约 38% 宽的渐变色块,封面从"白纸加条"升级成有视觉重量的构图
bw = SLIDE_W * 0.40
add_gradient_rect(slide, SLIDE_W - bw, 0, bw, SLIDE_H,
PRIMARY, PRIMARY_DARK, angle=60, name="cover_block")
add_rect(slide, SAFE_LEFT, 0.7, 0.55, 0.07, ACCENT, "brand_top_line")
add_rect(slide, SAFE_LEFT, btm, SLIDE_W - bw - SAFE_LEFT - 0.3, 0.02,
HAIRLINE, "brand_btm_hairline")
elif kind == "section":
add_gradient_rect(slide, 0, 0, SLIDE_W, SLIDE_H,
PRIMARY, PRIMARY_DARK, angle=55, name="section_bg")
add_rect(slide, 0.7, SLIDE_H / 3, 0.09, SLIDE_H / 3, ACCENT,
"brand_section_bar")
elif kind == "end":
add_bg(slide, PRIMARY_WASH)
add_rect(slide, SAFE_LEFT, 0.6, 0.8, 0.06, ACCENT, "brand_top_line")
add_rect(slide, SAFE_RIGHT - 0.8, SLIDE_H - 0.65, 0.8, 0.06, ACCENT,
"brand_btm_line")
else: # inner
add_bg(slide, BG)
add_rect(slide, 0, 0, 0.10, SLIDE_H, PRIMARY, "brand_left_bar")
add_rect(slide, SAFE_LEFT, btm, SAFE_W, 0.02, HAIRLINE,
"brand_btm_hairline")

View File

@ -1,220 +0,0 @@
"""pptx_preview.py: 把 .pptx 渲成 PNG 预览图(无头 Chrome),用于**肉眼验收版面**。
quality_check 只查"越界/溢出/配色"等结构问题,看不出"好不好看"本脚本把每页
按形状坐标还原成 HTML Chrome 截图 PNG,让人(或模型用 Read)真看一眼版面层次
留白对齐配色观感支持本 skill 用到的形状子集:矩形/圆角矩形/渐变块/文本框/图片
用法:
python pptx_preview.py <deck.pptx> -o <out_dir> [--pages 1,4,6]
产物:<out_dir>/p01.png p02.png ...(每页一张,2x 超采样)
依赖:本机 Chrome / Edge( render_bg.py)非本 skill 生成的复杂 pptx 可能还原不全
"""
from __future__ import annotations
import argparse
import html as _html
import subprocess
import tempfile
from pathlib import Path
from pptx import Presentation
from pptx.enum.dml import MSO_FILL, MSO_COLOR_TYPE
from pptx.enum.shapes import MSO_SHAPE_TYPE
from pptx.enum.text import PP_ALIGN, MSO_ANCHOR
from pptx.oxml.ns import qn
from render_bg import find_browser # 复用浏览器定位
EMU = 914400
PXI = 96 # px per inch
def _rgb(color):
try:
if color.type == MSO_COLOR_TYPE.RGB:
return "#" + str(color.rgb)
except (AttributeError, TypeError, KeyError, ValueError):
pass
return None
def _fill_css(shape):
"""返回 (css背景, 是否有填充)。支持纯色 / 线性渐变。"""
try:
f = shape.fill
if f.type == MSO_FILL.SOLID:
c = _rgb(f.fore_color)
return (c, True) if c else (None, False)
if f.type == MSO_FILL.GRADIENT:
stops = []
for gs in f.gradient_stops:
c = _rgb(gs.color)
if c:
stops.append(f"{c} {int(gs.position * 100)}%")
if len(stops) >= 2:
try:
ang = f.gradient_angle
except (AttributeError, ValueError, TypeError):
ang = 90
# pptx 角度→css:0=左→右 即 css 90deg
return (f"linear-gradient({90 + (ang or 0)}deg,{','.join(stops)})", True)
except (AttributeError, TypeError, KeyError, ValueError):
pass
return (None, False)
def _round_px(shape, w, h):
try:
adj = shape.adjustments[0]
return adj * min(w, h)
except (IndexError, AttributeError, ValueError, TypeError):
return 0
def _line(shape):
try:
ln = shape.line
c = _rgb(ln.color)
if c and ln.width is not None and ln.width > 0:
return c, max(1, ln.width / EMU * PXI)
except (AttributeError, TypeError, KeyError, ValueError):
pass
return None, 0
def _anchor_flex(tf):
a = tf.vertical_anchor
if a == MSO_ANCHOR.MIDDLE:
return "center"
if a == MSO_ANCHOR.BOTTOM:
return "flex-end"
return "flex-start"
def _align_css(p):
return {PP_ALIGN.CENTER: "center", PP_ALIGN.RIGHT: "right"}.get(
p.alignment, "left")
def _para_html(p):
align = _align_css(p)
runs = []
size = 18
color = "#1F1F1F"
bold = False
for r in p.runs:
if r.font.size:
size = r.font.size.pt
c = _rgb(r.font.color)
if c:
color = c
bold = bool(r.font.bold)
runs.append(_html.escape(r.text or ""))
txt = "".join(runs) or _html.escape(p.text or "")
if not txt.strip():
return ""
lh = 1.25
return (f'<div style="text-align:{align};font-size:{size}px;color:{color};'
f'font-weight:{"700" if bold else "400"};line-height:{lh}">{txt}</div>')
def slide_html(slide, imgdir: Path, idx: int) -> str:
parts = []
for s_i, sh in enumerate(slide.shapes):
try:
l = sh.left / EMU * PXI
t = sh.top / EMU * PXI
w = sh.width / EMU * PXI
h = sh.height / EMU * PXI
except (TypeError, AttributeError):
continue
base = (f"position:absolute;left:{l:.1f}px;top:{t:.1f}px;"
f"width:{w:.1f}px;height:{h:.1f}px;box-sizing:border-box;")
# 图片
try:
is_pic = sh.shape_type == MSO_SHAPE_TYPE.PICTURE
except (AttributeError, ValueError):
is_pic = False
if is_pic:
try:
blob = sh.image.blob
ext = sh.image.ext
fp = imgdir / f"p{idx}_{s_i}.{ext}"
fp.write_bytes(blob)
parts.append(f'<img src="{fp.as_uri()}" style="{base}'
f'object-fit:cover"/>')
except (AttributeError, KeyError, ValueError):
pass
continue
# 形状填充 / 圆角 / 描边
css = base
bg, has = _fill_css(sh)
if has:
css += f"background:{bg};"
# 椭圆/圆 → 50% 圆角(badge/dot/hub 才显示成圆,不然是方块)
prst = None
try:
g = sh._element.spPr.find(qn("a:prstGeom"))
prst = g.get("prst") if g is not None else None
except (AttributeError, TypeError):
pass
if prst == "ellipse":
css += "border-radius:50%;"
else:
r = _round_px(sh, w, h)
if r > 0:
css += f"border-radius:{r:.1f}px;"
lc, lw = _line(sh)
if lc:
css += f"border:{lw:.1f}px solid {lc};"
# 文本
inner = ""
if sh.has_text_frame and (sh.text_frame.text or "").strip():
tf = sh.text_frame
css += ("display:flex;flex-direction:column;padding:2px 6px;"
f"justify-content:{_anchor_flex(tf)};")
inner = "".join(_para_html(p) for p in tf.paragraphs)
if has or r > 0 or lc or inner:
parts.append(f'<div style="{css}">{inner}</div>')
body = "\n".join(parts)
return (f'<div style="position:relative;width:1280px;height:720px;'
f'overflow:hidden;background:#fff;font-family:\'Microsoft YaHei\','
f'Arial,sans-serif">{body}</div>')
def render(html_str: str, out: Path):
browser = find_browser()
with tempfile.TemporaryDirectory() as td:
hp = Path(td) / "s.html"
hp.write_text(f"<!doctype html><meta charset=utf-8>"
f"<style>html,body{{margin:0}}</style>{html_str}",
encoding="utf-8")
subprocess.run([browser, "--headless", "--disable-gpu",
"--hide-scrollbars", "--force-device-scale-factor=2",
"--window-size=1280,720", f"--screenshot={out}",
hp.resolve().as_uri()], check=False,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
def main():
ap = argparse.ArgumentParser()
ap.add_argument("pptx", type=Path)
ap.add_argument("-o", "--out", type=Path, required=True)
ap.add_argument("--pages", default=None, help="如 1,4,6;省略=全部")
args = ap.parse_args()
args.out.mkdir(parents=True, exist_ok=True)
imgdir = args.out / "_img"
imgdir.mkdir(exist_ok=True)
prs = Presentation(str(args.pptx))
want = (set(int(x) for x in args.pages.split(",")) if args.pages else None)
for i, slide in enumerate(prs.slides, 1):
if want and i not in want:
continue
out = args.out / f"p{i:02d}.png"
render(slide_html(slide, imgdir, i), out)
print(f"[ok] {out}" if out.exists() else f"[fail] p{i}")
if __name__ == "__main__":
main()

View File

@ -0,0 +1,485 @@
#!/usr/bin/env python3
"""
PPT Master - Project Utilities Module
Provides common functions for project information parsing and validation,
reusable by other tools.
"""
import argparse
import re
from pathlib import Path
from datetime import datetime
from typing import Dict, List, Optional, Tuple
# Canvas format definitions (unified source)
try:
from config import CANVAS_FORMATS
except ImportError:
# Fallback: maintain minimal usable configuration to avoid runtime crashes
CANVAS_FORMATS = {
'ppt169': {
'name': 'PPT 16:9',
'dimensions': '1280×720',
'viewbox': '0 0 1280 720',
'aspect_ratio': '16:9'
},
'ppt43': {
'name': 'PPT 4:3',
'dimensions': '1024×768',
'viewbox': '0 0 1024 768',
'aspect_ratio': '4:3'
},
'wechat': {
'name': 'WeChat Article Header',
'dimensions': '900×383',
'viewbox': '0 0 900 383',
'aspect_ratio': '2.35:1'
},
'xiaohongshu': {
'name': '小红书',
'dimensions': '1242×1660',
'viewbox': '0 0 1242 1660',
'aspect_ratio': '3:4'
},
'moments': {
'name': 'Moments/Instagram',
'dimensions': '1080×1080',
'viewbox': '0 0 1080 1080',
'aspect_ratio': '1:1'
},
'story': {
'name': 'Story/Vertical',
'dimensions': '1080×1920',
'viewbox': '0 0 1080 1920',
'aspect_ratio': '9:16'
},
'banner': {
'name': 'Horizontal Banner',
'dimensions': '1920×1080',
'viewbox': '0 0 1920 1080',
'aspect_ratio': '16:9'
},
'a4': {
'name': 'A4 Print',
'dimensions': '1240×1754',
'viewbox': '0 0 1240 1754',
'aspect_ratio': '√2:1'
}
}
CANVAS_FORMAT_ALIASES = {
'xhs': 'xiaohongshu',
'wechat_moment': 'moments',
'wechat-moment': 'moments',
'朋友圈': 'moments',
'小红书': 'xiaohongshu',
}
def normalize_canvas_format(format_key: str) -> str:
"""Normalize canvas format key name (supports common aliases)."""
if not format_key:
return ''
key = format_key.strip().lower()
return CANVAS_FORMAT_ALIASES.get(key, key)
def parse_project_name(dir_name: str) -> Dict[str, str]:
"""
Parse project information from the project directory name.
Args:
dir_name: Project directory name
Returns:
Dictionary containing name, format, date
"""
result = {
'name': dir_name,
'format': 'unknown',
'format_name': 'Unknown format',
'date': 'unknown',
'date_formatted': 'Unknown date'
}
dir_name_lower = dir_name.lower()
# Extract date (format: _YYYYMMDD)
date_match = re.search(r'_(\d{8})$', dir_name)
if date_match:
date_str = date_match.group(1)
result['date'] = date_str
try:
date_obj = datetime.strptime(date_str, '%Y%m%d')
result['date_formatted'] = date_obj.strftime('%Y-%m-%d')
except ValueError:
pass
# Prefer parsing standard format: name_format_YYYYMMDD
full_match = re.match(r'^(?P<name>.+)_(?P<format>[a-z0-9_-]+)_(?P<date>\d{8})$', dir_name_lower)
if full_match:
raw_format = full_match.group('format')
normalized_format = normalize_canvas_format(raw_format)
if normalized_format in CANVAS_FORMATS:
result['format'] = normalized_format
result['format_name'] = CANVAS_FORMATS[normalized_format]['name']
result['name'] = dir_name[:len(full_match.group('name'))]
return result
# Fallback: only match trailing `_format` to avoid deleting parts of the project name
sorted_formats = sorted(CANVAS_FORMATS.keys(), key=len, reverse=True)
for fmt_key in sorted_formats:
if re.search(rf'_{re.escape(fmt_key)}(?:_\d{{8}})?$', dir_name_lower):
result['format'] = fmt_key
result['format_name'] = CANVAS_FORMATS[fmt_key]['name']
break
# Extract project name (only remove trailing date and format suffix)
name = re.sub(r'_\d{8}$', '', dir_name)
if result['format'] != 'unknown':
name = re.sub(rf'_{re.escape(result["format"])}$', '', name, flags=re.IGNORECASE)
result['name'] = name
return result
def get_project_info(project_path: str) -> Dict:
"""
Get detailed project information.
Args:
project_path: Project directory path
Returns:
Project information dictionary
"""
project_path = Path(project_path)
# Parse directory name
parsed = parse_project_name(project_path.name)
info = {
'path': str(project_path),
'dir_name': project_path.name,
'name': parsed['name'],
'format': parsed['format'],
'format_name': parsed['format_name'],
'date': parsed['date'],
'date_formatted': parsed['date_formatted'],
'exists': project_path.exists(),
'svg_count': 0,
'has_spec': False,
'has_readme': False,
'has_source': False,
'source_count': 0,
'spec_file': None,
'svg_files': []
}
if not project_path.exists():
return info
# Check README.md
info['has_readme'] = (project_path / 'README.md').exists()
# Check design specification files (current standard + legacy names)
spec_files = ['design_spec.md', '设计规范与内容大纲.md', 'design_specification.md', '设计规范.md']
for spec_file in spec_files:
if (project_path / spec_file).exists():
info['has_spec'] = True
info['spec_file'] = spec_file
break
# Check source documents
legacy_source_file = project_path / '来源文档.md'
sources_dir = project_path / 'sources'
info['has_source'] = legacy_source_file.exists() or sources_dir.exists()
if sources_dir.exists():
info['source_count'] = len([p for p in sources_dir.iterdir() if p.is_file()])
# Count SVG files
svg_output = project_path / 'svg_output'
if svg_output.exists():
svg_files = sorted(svg_output.glob('*.svg'))
info['svg_count'] = len(svg_files)
info['svg_files'] = [f.name for f in svg_files]
# Get canvas format details
if info['format'] in CANVAS_FORMATS:
info['canvas_info'] = CANVAS_FORMATS[info['format']]
return info
def validate_project_structure(project_path: str, verbose: bool = False) -> Tuple[bool, List[str], List[str]]:
"""
Validate project structure completeness.
Args:
project_path: Project directory path
verbose: Whether to show detailed fix suggestions
Returns:
(is_valid, error_list, warning_list)
"""
project_path = Path(project_path)
errors = []
warnings = []
# Try to import error helper
try:
from error_helper import ErrorHelper
use_helper = True
except ImportError:
use_helper = False
# Check if directory exists
if not project_path.exists():
msg = f"Project directory does not exist: {project_path}"
if use_helper and verbose:
msg += "\n" + ErrorHelper.format_error_message('missing_directory',
{'project_path': str(project_path)})
errors.append(msg)
return False, errors, warnings
if not project_path.is_dir():
errors.append(f"Not a valid directory: {project_path}")
return False, errors, warnings
# Check required files
if not (project_path / 'README.md').exists():
msg = "Missing required file: README.md"
if use_helper and verbose:
msg += "\n" + ErrorHelper.format_error_message('missing_readme',
{'project_path': str(project_path)})
errors.append(msg)
# Check design specification file
spec_files = ['design_spec.md', '设计规范与内容大纲.md', 'design_specification.md', '设计规范.md']
has_spec = any((project_path / f).exists() for f in spec_files)
if not has_spec:
msg = "Missing design specification file (suggested filename: design_spec.md)"
if use_helper and verbose:
msg += "\n" + ErrorHelper.format_error_message('missing_spec')
warnings.append(msg)
# Check svg_output directory
svg_output = project_path / 'svg_output'
if not svg_output.exists():
msg = "Missing svg_output directory"
if use_helper and verbose:
msg += "\n" + \
ErrorHelper.format_error_message('missing_svg_output')
errors.append(msg)
elif not svg_output.is_dir():
errors.append("svg_output is not a directory")
else:
# Check for SVG files
svg_files = list(svg_output.glob('*.svg'))
if not svg_files:
msg = "svg_output directory is empty, no SVG files found"
if use_helper and verbose:
msg += "\n" + \
ErrorHelper.format_error_message('empty_svg_output')
warnings.append(msg)
else:
# Validate SVG file naming (consistent with project_manager.py)
for svg_file in svg_files:
if not re.match(r'^(slide_\d+_\w+|P?\d+_.+)\.svg$', svg_file.name):
msg = f"Non-standard SVG file naming: {svg_file.name}"
if use_helper and verbose:
msg += "\n" + ErrorHelper.format_error_message('invalid_svg_naming',
{'file_name': svg_file.name})
warnings.append(msg)
# Check directory naming format
dir_name = project_path.name
if not re.search(r'_\d{8}$', dir_name):
msg = f"Directory name missing date suffix (_YYYYMMDD): {dir_name}"
if use_helper and verbose:
msg += "\n" + \
ErrorHelper.format_error_message('missing_date_suffix')
warnings.append(msg)
is_valid = len(errors) == 0
return is_valid, errors, warnings
def validate_svg_viewbox(svg_files: List[Path], expected_format: Optional[str] = None) -> List[str]:
"""
Validate the viewBox settings of SVG files.
Args:
svg_files: List of SVG files
expected_format: Expected canvas format (e.g. 'ppt169')
Returns:
List of warnings
"""
warnings = []
viewbox_pattern = re.compile(r'viewBox="([^"]+)"')
viewboxes = set()
# Determine expected viewBox
expected_viewbox = None
if expected_format and expected_format in CANVAS_FORMATS:
expected_viewbox = CANVAS_FORMATS[expected_format]['viewbox']
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)
# 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")
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}")
return warnings
def find_all_projects(base_dir: str) -> List[Path]:
"""
Find all projects under the specified directory.
Args:
base_dir: Base directory path
Returns:
List of project directories
"""
base_path = Path(base_dir)
if not base_path.exists():
return []
projects = []
for item in base_path.iterdir():
if item.is_dir() and not item.name.startswith('.'):
# Check if it's a valid project directory (contains svg_output or design spec)
has_svg_output = (item / 'svg_output').exists()
has_spec = any((item / f).exists() for f in
['design_spec.md', '设计规范与内容大纲.md', 'design_specification.md', '设计规范.md'])
if has_svg_output or has_spec:
projects.append(item)
return sorted(projects)
def format_file_size(size_bytes: int) -> str:
"""
Format file size.
Args:
size_bytes: File size in bytes
Returns:
Formatted file size string
"""
for unit in ['B', 'KB', 'MB', 'GB']:
if size_bytes < 1024.0:
return f"{size_bytes:.1f} {unit}"
size_bytes /= 1024.0
return f"{size_bytes:.1f} TB"
def get_project_stats(project_path: str) -> Dict:
"""
Get project statistics.
Args:
project_path: Project directory path
Returns:
Statistics dictionary
"""
project_path = Path(project_path)
stats = {
'total_files': 0,
'svg_files': 0,
'md_files': 0,
'html_files': 0,
'total_size': 0,
'svg_size': 0
}
if not project_path.exists():
return stats
for file in project_path.rglob('*'):
if file.is_file():
stats['total_files'] += 1
file_size = file.stat().st_size
stats['total_size'] += file_size
if file.suffix == '.svg':
stats['svg_files'] += 1
stats['svg_size'] += file_size
elif file.suffix == '.md':
stats['md_files'] += 1
elif file.suffix == '.html':
stats['html_files'] += 1
return stats
def build_parser() -> argparse.ArgumentParser:
"""Build the command-line parser for the diagnostic entry point."""
parser = argparse.ArgumentParser(description="Inspect and validate a PPT Master project.")
parser.add_argument("project_path", help="Project directory")
return parser
def main(argv: list[str] | None = None) -> int:
"""Run the diagnostic CLI entry point."""
parser = build_parser()
args = parser.parse_args(argv)
project_path = args.project_path
info = get_project_info(project_path)
print(f"\nProject Info: {info['dir_name']}")
print("=" * 60)
print(f"Project Name: {info['name']}")
print(f"Canvas Format: {info['format_name']} ({info['format']})")
print(f"Created: {info['date_formatted']}")
print(f"SVG Files: {info['svg_count']}")
print(f"README: {'Yes' if info['has_readme'] else 'No'}")
print(f"Design Spec: {'Yes' if info['has_spec'] else 'No'}")
print("\nValidation Results:")
print("-" * 60)
is_valid, errors, warnings = validate_project_structure(project_path)
if errors:
print("[ERROR]")
for error in errors:
print(f" - {error}")
if warnings:
print("[WARN]")
for warning in warnings:
print(f" - {warning}")
if is_valid and not warnings:
print("[OK] Project structure is complete, no issues found")
return 0 if is_valid else 1
if __name__ == '__main__':
raise SystemExit(main())

View File

@ -1,412 +0,0 @@
"""quality_check.py: 验收 .pptx,产出问题清单。
用法:
python quality_check.py <output.pptx> [--spec spec.md]
检查项:
- 文件存在且 > 10KB
- 总页数与 spec 一致 (如提供 spec.md)
- 每页有标题
- 每页 bullet 5
- 文字字号 14pt (除页脚)
- 非灰阶(彩色) 3 (三色制;文字色 + 形状填充色都计,灰阶/白不计)
- 出现 spec 之外的非灰阶色 (擅自换色 / 非主题色)
- 没有 untitled / output / placeholder 等占位文件名
- **形状不越出画布边界** (left+width / top+height 超界即报)
- **textbox 文本估算行数 > 框高度** 推断溢出
- **内容形状互相重叠** (文字压文字 / 文字压图标 / 图标压图标;装饰填充不计)
退出码:
0 = 全通过
1 = warning
2 = 致命问题 (文件缺失等)
"""
from __future__ import annotations
import argparse
import colorsys
import re
import sys
from pathlib import Path
try:
from pptx import Presentation
from pptx.util import Pt
from pptx.enum.dml import MSO_FILL, MSO_COLOR_TYPE
from pptx.enum.shapes import MSO_SHAPE_TYPE
except ImportError:
print("[fatal] pip install python-pptx", file=sys.stderr)
sys.exit(2)
# ---- 重叠检测参数 ----
# 只检"内容形状"(有文字 / 图片)两两重叠 —— 装饰形状(无文字纯色填充:品牌条/分隔线/
# 圆点/色块标签/装饰星箭头)天然不算内容,不参与;"文字叠在色块上"也不会误报(色块无
# 文字)。要抓的是文字压文字 / 文字压图标 / 图标压图标这类真缺陷。
_OVERLAP_MIN_DIM = 0.08 # in:交叠的宽和高都需超过此值(滤掉边缘贴合/发丝线)
_OVERLAP_MIN_RATIO = 0.25 # 交叠面积 / 较小形状面积 超过此比例才算"压住"
# ---- 颜色辅助 ----
# 三色制按"色系数"判定,不是"hex 数":主/辅常同色系,主色的明暗阶(深红 #8A0000)、
# 浅底(wash/soft tint #F2CCCC)都从那三色派生,不该被算成"新色"。所以:
# - 低饱和的浅色/灰阶 → 中性(卡片底、wash 底),不计入彩色
# - 高饱和的算"彩色",但按色相(hue)归桶 —— 同色系(红的深浅)收敛成一个
# 这样"白底+红卡片+深红渐变+金强调"= 2 个色系,不会误报超 3 色。
def _hsv(hex6: str):
r, g, b = (int(hex6[0:2], 16) / 255, int(hex6[2:4], 16) / 255,
int(hex6[4:6], 16) / 255)
return colorsys.rgb_to_hsv(r, g, b) # h,s,v ∈ [0,1]
def _is_chromatic(hex6: str) -> bool:
"""是否计入"彩色"。低饱和(浅底/wash/灰阶)或近黑 → 中性,不计。"""
try:
_h, s, v = _hsv(hex6)
except (ValueError, IndexError):
return False
return s >= 0.30 and v >= 0.18
def _hue_family(hex6: str) -> int:
"""色相归桶(30° 一桶)。同色系的深浅落同一桶,收敛成一个色。"""
h, _s, _v = _hsv(hex6)
return int((h * 360) // 30)
def _is_semantic_status(hex6: str) -> bool:
"""语义状态色(绿=正向趋势):业界通用约定,不计入"三色制"
绿色相带( 95°-175°)且有一定饱和 视为趋势/成功色,豁免"""
try:
h, s, _v = _hsv(hex6)
except (ValueError, IndexError):
return False
return 95 <= h * 360 <= 175 and s >= 0.30
def _is_neutral(hex6: str) -> bool:
"""保留旧名:非彩色(中性)= 不计入三色制。"""
return not _is_chromatic(hex6)
# 标签类形状名:这些天然用小字号(eyebrow/胶囊/页脚/数据来源/KPI 小注),
# 不参与"字号 < 14pt"与"bullet ≤ 5"的统计 —— 它们不是正文 bullet。
_LABEL_NAME_RE = re.compile(
r"(pill|eyebrow|footer|page_num|source|meta|_sub|kpi_sub|badge|tag|label)",
re.IGNORECASE,
)
# bullet 类形状名:真正的要点列表才计入 bullet 数。
_BULLET_NAME_RE = re.compile(r"(bullet|_pt_|agenda|list|item)", re.IGNORECASE)
def _shape_fill_hex(shape) -> str | None:
"""取形状的纯色填充 hex(大写,无 #)。非实心 / 主题色 / 取不到 → None。"""
try:
fill = shape.fill
if fill.type != MSO_FILL.SOLID:
return None
fc = fill.fore_color
if fc.type != MSO_COLOR_TYPE.RGB: # 主题色访问 .rgb 会抛,先挡掉
return None
return str(fc.rgb).upper()
except (TypeError, AttributeError, KeyError, ValueError):
return None
# ---- spec 解析 (松散 markdown 解析,够用就行) ----
def parse_spec(spec_path: Path) -> dict:
if not spec_path or not spec_path.exists():
return {}
text = spec_path.read_text(encoding="utf-8")
spec: dict = {}
m = re.search(r"页数[:\s]*(\d+)", text)
if m:
spec["page_count"] = int(m.group(1))
m = re.search(r"画布[:\s]*(16:9|4:3|9:16|1:1|3:4)", text)
if m:
spec["canvas"] = m.group(1)
hexes = re.findall(r"#([0-9A-Fa-f]{6})", text)
if hexes:
spec["colors"] = [h.upper() for h in hexes[:5]]
return spec
# ---- 检查 ----
def check_pptx(path: Path, spec: dict) -> tuple[list, list]:
"""returns (errors, warnings)"""
errors, warnings = [], []
if not path.exists():
errors.append(f"文件不存在: {path}")
return errors, warnings
size_kb = path.stat().st_size / 1024
if size_kb < 10:
errors.append(f"文件太小 ({size_kb:.1f}KB),python-pptx 可能没写完")
name = path.stem.lower()
if name in ("untitled", "output", "presentation", "untitled1", "new", "test"):
warnings.append(
f"文件名 '{path.name}' 太通用,建议按主题命名"
)
prs = Presentation(path)
n_slides = len(prs.slides)
slide_w_in = prs.slide_width / 914400 # EMU → inch
slide_h_in = prs.slide_height / 914400
print(
f"[info] 文件: {path.name} 大小: {size_kb:.1f}KB "
f"页数: {n_slides} 画布: {slide_w_in:.2f}×{slide_h_in:.2f} in"
)
expected = spec.get("page_count")
if expected and n_slides != expected:
warnings.append(f"页数 {n_slides} 与 spec 期望 {expected} 不符")
spec_colors = set(spec.get("colors", []))
seen_colors: set[str] = set()
for idx, slide in enumerate(prs.slides, 1):
title_text = None
small_font_count = 0
bullet_xs: list = [] # 每个 bullet 项的 x 中心 —— 末尾按列分组判 ≤5
content_shapes: list = [] # (l, t, w, h, label, head) — 有文字 / 图片的形状
for s_i, shape in enumerate(slide.shapes):
# ---- 形状越界检查 (任何 shape) ----
try:
left_in = shape.left / 914400 if shape.left is not None else 0
top_in = shape.top / 914400 if shape.top is not None else 0
w_in = shape.width / 914400 if shape.width is not None else 0
h_in = shape.height / 914400 if shape.height is not None else 0
except (AttributeError, TypeError):
left_in = top_in = w_in = h_in = 0
tol = 0.02 # 0.02 in 容忍 (约 0.5mm)
shape_label = (
shape.name if hasattr(shape, "name") and shape.name
else f"shape#{s_i}"
)
if left_in < -tol or top_in < -tol:
warnings.append(
f"{idx}{shape_label} 起点为负: "
f"({left_in:.2f}, {top_in:.2f})"
)
if left_in + w_in > slide_w_in + tol:
overflow = left_in + w_in - slide_w_in
warnings.append(
f"{idx}{shape_label} 右越界 {overflow:.2f}in "
f"(画布 {slide_w_in:.2f},shape 右 {left_in + w_in:.2f})"
)
if top_in + h_in > slide_h_in + tol:
overflow = top_in + h_in - slide_h_in
warnings.append(
f"{idx}{shape_label} 下越界 {overflow:.2f}in "
f"(画布 {slide_h_in:.2f},shape 底 {top_in + h_in:.2f})"
)
# ---- 形状填充色 (品牌条/徽章/圆点/标签/底块) ----
fill_hex = _shape_fill_hex(shape)
if fill_hex:
seen_colors.add(fill_hex)
# ---- 收集"内容形状"供重叠检测 (有文字 / 图片) ----
try:
is_pic = shape.shape_type == MSO_SHAPE_TYPE.PICTURE
except (AttributeError, ValueError):
is_pic = False
head = ""
if shape.has_text_frame:
head = (shape.text_frame.text or "").strip()
# 全幅背景图(覆盖 ≥85% 画布)是混合方案的背景层,文字本就叠其上,
# 不算"内容碰撞",排除出重叠检测,否则误报"图片压住所有文字"。
is_full_bg = (is_pic and w_in * h_in >= 0.85 * slide_w_in * slide_h_in)
if (head or is_pic) and w_in > 0.05 and h_in > 0.05 and not is_full_bg:
content_shapes.append(
(left_in, top_in, w_in, h_in, shape_label,
head[:18] if head else "[图片]")
)
if not shape.has_text_frame:
continue
tf = shape.text_frame
text = (tf.text or "").strip()
if not text:
continue
if title_text is None and len(text) <= 40 and "\n" not in text:
title_text = text
# ---- 文本溢出估算 ----
# 估算:中文字号 N pt 在框宽 W in 下,每行约 W*72/N 个中文字
# 非空段落数 + 长段落折行数 ≈ 实际行数
# 行数 × (size_pt * 1.4 / 72) > 框高 → 溢出
try:
first_size_pt = None
for para in tf.paragraphs:
for run in para.runs:
if run.font.size:
first_size_pt = run.font.size.pt
break
if first_size_pt:
break
# 大号展示字(标题/KPI 大数字/章节编号 ≥ 40pt)单行短文本,
# 按"每行字数"估折行会假阳(每行才 1-2 字),跳过 —— 标题长度另有
# ≤30 字检查兜底。
if (first_size_pt and first_size_pt < 40
and w_in > 0.5 and h_in > 0.2):
chars_per_line = max(1, int(w_in * 72 / first_size_pt))
est_lines = 0
for para in tf.paragraphs:
ptxt = (para.text or "").strip()
if not ptxt:
continue
est_lines += max(
1,
(len(ptxt) + chars_per_line - 1) // chars_per_line
)
line_height_in = first_size_pt * 1.4 / 72
needed_h = est_lines * line_height_in
if needed_h > h_in + 0.1:
warnings.append(
f"{idx}{shape_label} 文本可能溢出 "
f"(估 {est_lines} 行,需 {needed_h:.2f}in,"
f"框高 {h_in:.2f}in): {text[:25]}..."
)
except (AttributeError, TypeError, ValueError):
pass
is_label = bool(_LABEL_NAME_RE.search(shape_label))
is_bullet_shape = bool(_BULLET_NAME_RE.search(shape_label))
nonempty_paras = [
p for p in tf.paragraphs if (p.text or "").strip()
and (p.text or "").strip() != title_text
]
# bullet 只统计"真要点列表":名字像 bullet 的,或一个框里 ≥2 段的列表。
# KPI 卡 / 卡片标题 / 胶囊这类结构化短文本(单段、非 bullet 名)不算 bullet,
# 否则一页 4 张 KPI 卡会被误报成 "12 条 bullet"。
if not is_label and (is_bullet_shape or len(nonempty_paras) >= 2):
cx = left_in + w_in / 2 # x 中心,供按列分组
bullet_xs.extend([cx] * len(nonempty_paras))
for para in tf.paragraphs:
ptxt = (para.text or "").strip()
if not ptxt:
continue
for run in para.runs:
# 标签类(eyebrow/胶囊/页脚/小注)天然小字,不算"投影看不清"
if run.font.size and not is_label:
if run.font.size < Pt(14):
small_font_count += 1
if run.font.color and run.font.color.type:
try:
rgb = run.font.color.rgb
if rgb is not None:
seen_colors.add(str(rgb))
except (AttributeError, KeyError, ValueError):
pass
if title_text is None:
warnings.append(f"{idx} 页缺标题")
elif len(title_text) > 30:
warnings.append(
f"{idx} 页标题过长 ({len(title_text)} 字): {title_text[:20]}..."
)
# bullet ≤5 按"列"判:双栏对比天生左 3 + 右 3,不该当整页 6 条报。
# 按 slide 中线把 bullet 分左右两列,任一列 > 5 才警告(单列列表也走这条)。
mid = slide_w_in / 2
left_n = sum(1 for x in bullet_xs if x < mid)
right_n = len(bullet_xs) - left_n
max_col = max(left_n, right_n)
if max_col > 5:
warnings.append(
f"{idx} 页单列 bullet {max_col} 条 (上限 5),建议拆页或转图表"
)
if small_font_count > 0:
warnings.append(
f"{idx} 页有 {small_font_count} 处字号 < 14pt,投影看不清"
)
# ---- 内容形状两两重叠 (文字压文字 / 文字压图标 / 图标压图标) ----
for i in range(len(content_shapes)):
ax, ay, aw, ah, alab, ahead = content_shapes[i]
for j in range(i + 1, len(content_shapes)):
bx, by, bw, bh, blab, bhead = content_shapes[j]
ix = min(ax + aw, bx + bw) - max(ax, bx)
iy = min(ay + ah, by + bh) - max(ay, by)
if ix <= _OVERLAP_MIN_DIM or iy <= _OVERLAP_MIN_DIM:
continue
min_area = min(aw * ah, bw * bh)
if min_area <= 0:
continue
ratio = (ix * iy) / min_area
if ratio >= _OVERLAP_MIN_RATIO:
warnings.append(
f"{idx} 页 内容重叠 {ratio * 100:.0f}%: "
f'{alab}("{ahead}") × {blab}("{bhead}")'
)
# 三色制按"色系数"判定:同色系深浅(主色/深红渐变/浅红卡片底)收敛成一桶,
# 低饱和浅色/灰阶不计。这样卡片式设计的派生色阶不会被误报超 3 色。
chromatic = {c for c in seen_colors
if _is_chromatic(c) and not _is_semantic_status(c)}
families = {_hue_family(c) for c in chromatic}
if len(families) > 3:
warnings.append(
f"彩色色系 {len(families)} 个 (三色制上限 3): "
f"{', '.join('#' + c for c in sorted(chromatic))};收敛到主/辅/强调三色系"
)
if spec_colors:
spec_families = {_hue_family(c) for c in spec_colors if _is_chromatic(c)}
extra = {c for c in chromatic if _hue_family(c) not in spec_families}
if extra:
spec_chromatic = {c for c in spec_colors if _is_chromatic(c)}
warnings.append(
f"出现 spec 之外的色系 {', '.join('#' + c for c in sorted(extra))};"
f"擅自换色 / 非主题色 (spec 定的是 "
f"{', '.join('#' + c for c in sorted(spec_chromatic))})"
)
return errors, warnings
def main():
ap = argparse.ArgumentParser()
ap.add_argument("pptx", type=Path)
ap.add_argument("--spec", type=Path, default=None,
help="spec.md 路径")
args = ap.parse_args()
spec = parse_spec(args.spec) if args.spec else {}
if spec:
print(f"[info] spec 已加载: {spec}")
errors, warnings = check_pptx(args.pptx, spec)
if errors:
print("\n[errors]")
for e in errors:
print(f"{e}")
if warnings:
print("\n[warnings]")
for w in warnings:
print(f" ! {w}")
if not errors and not warnings:
print("\n[ok] 全部通过")
sys.exit(0)
sys.exit(2 if errors else 1)
if __name__ == "__main__":
main()

View File

@ -1,135 +0,0 @@
"""render_bg.py: 用无头 Chrome/Edge 把主题化 HTML 背景渲成高清 PNG。
混合方案专用 封面/章节页:先用本脚本渲一张杂志级背景图,build_deck
`P.add_picture_bg(slide, png)` 整页铺,再叠原生可编辑文字背景不可改但文字能改,
editable 前提下能拿到的最高观感(DrawingML 渐变做不出 mesh 渐变 + 模糊光晕)
用法:
python render_bg.py --out cover.png --kind cover --primary C00000
python render_bg.py --out sec.png --kind section --primary C00000 --accent FFC107
python render_bg.py --out x.png --html mybg.html # 渲任意 HTML
依赖:本机装了 Chrome Edge(无需 pip )两者都没有则报错退出
产物默认 2560x1440(16:9 高清,2x 超采样),嵌进 13.33in 画布够清晰
"""
from __future__ import annotations
import argparse
import subprocess
import sys
import tempfile
from pathlib import Path
_CHROME_CANDIDATES = [
r"C:\Program Files\Google\Chrome\Application\chrome.exe",
r"C:\Program Files (x86)\Google\Chrome\Application\chrome.exe",
r"C:\Program Files\Microsoft\Edge\Application\msedge.exe",
r"C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe",
]
def find_browser() -> str:
for c in _CHROME_CANDIDATES:
if Path(c).exists():
return c
# PATH 兜底
import shutil
for name in ("chrome", "chrome.exe", "msedge", "msedge.exe"):
p = shutil.which(name)
if p:
return p
raise SystemExit("[fatal] 未找到 Chrome / Edge,无法渲染背景图。改用 DrawingML 渐变背景(apply_brand)。")
def _hex(h: str) -> tuple[int, int, int]:
h = h.lstrip("#")
return int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16)
def _mix(c, d, t):
return tuple(round(a + (b - a) * t) for a, b in zip(c, d))
def _css(c) -> str:
return f"rgb({c[0]},{c[1]},{c[2]})"
def build_html(kind: str, primary: str, accent: str) -> str:
p = _hex(primary)
a = _hex(accent)
dark = _mix(p, (0, 0, 0), 0.55) # 深端
deep = _mix(p, (0, 0, 0), 0.30)
glow = _mix(p, (255, 255, 255), 0.25) # 亮红光晕
pc, dc, dpc, gc, ac = _css(p), _css(dark), _css(deep), _css(glow), _css(a)
# 公共:mesh 渐变(多点径向叠加)+ 模糊光斑 + 细点纹理。文字由 build_deck 叠。
# cover:左侧加暗罩,让左置白字更稳;section:整页深,中心略亮。
overlay = (
"radial-gradient(1200px 900px at 18% 50%, rgba(0,0,0,.34), transparent 60%),"
if kind == "cover" else
"radial-gradient(1000px 800px at 50% 42%, rgba(255,255,255,.06), transparent 60%),"
)
return f"""<!doctype html><html><head><meta charset="utf-8"><style>
html,body{{margin:0;padding:0}}
.bg{{width:1280px;height:720px;position:relative;overflow:hidden;
background:
{overlay}
radial-gradient(700px 520px at 82% 16%, {gc}, transparent 58%),
radial-gradient(900px 700px at 92% 96%, {dc}, transparent 55%),
radial-gradient(620px 620px at 12% 8%, rgba(255,255,255,.10), transparent 60%),
linear-gradient(135deg, {pc} 0%, {dpc} 58%, {dc} 100%);
}}
.blob{{position:absolute;border-radius:50%;filter:blur(64px)}}
.b1{{width:420px;height:420px;right:-60px;top:-110px;background:{ac};opacity:.30}}
.b2{{width:360px;height:360px;right:160px;bottom:-130px;background:{gc};opacity:.40}}
.grid{{position:absolute;inset:0;opacity:.07;
background-image:linear-gradient(rgba(255,255,255,.6) 1px,transparent 1px),
linear-gradient(90deg,rgba(255,255,255,.6) 1px,transparent 1px);
background-size:54px 54px}}
.bar{{position:absolute;left:0;top:0;width:8px;height:720px;background:{ac};opacity:.9}}
</style></head><body><div class="bg">
<div class="grid"></div>
<div class="blob b1"></div>
<div class="blob b2"></div>
<div class="bar"></div>
</div></body></html>"""
def render(html: str, out: Path, w: int, h: int) -> None:
browser = find_browser()
with tempfile.TemporaryDirectory() as td:
hp = Path(td) / "bg.html"
hp.write_text(html, encoding="utf-8")
url = hp.resolve().as_uri()
# 用 1/2 窗口 + 2x 缩放 = 超采样,边缘/模糊更干净
cmd = [
browser, "--headless", "--disable-gpu", "--hide-scrollbars",
"--default-background-color=00000000",
f"--force-device-scale-factor=2",
f"--window-size={w // 2},{h // 2}",
f"--screenshot={out}", url,
]
subprocess.run(cmd, check=False,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
if not out.exists():
raise SystemExit(f"[fatal] 渲染失败,未生成 {out}(浏览器: {browser})")
print(f"[ok] {out} ({out.stat().st_size // 1024} KB)")
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--out", type=Path, required=True)
ap.add_argument("--kind", choices=["cover", "section"], default="cover")
ap.add_argument("--primary", default="C00000")
ap.add_argument("--accent", default="FFC107")
ap.add_argument("--html", type=Path, default=None, help="渲任意 HTML 文件(忽略 kind)")
ap.add_argument("--w", type=int, default=2560)
ap.add_argument("--h", type=int, default=1440)
args = ap.parse_args()
args.out.parent.mkdir(parents=True, exist_ok=True)
html = (args.html.read_text(encoding="utf-8") if args.html
else build_html(args.kind, args.primary, args.accent))
render(html, args.out, args.w, args.h)
if __name__ == "__main__":
main()

View File

@ -1,129 +0,0 @@
"""render_icon.py: unicode 字形 → 透明背景 PNG。
MSO_SHAPE 覆盖不到的图标 (齿轮放大镜文件夹等),用字形渲染兜底
首选 MSO_SHAPE, references/icons.md
用法:
python render_icon.py "" --color "#38B2AC" --size 96 -o check.png
python render_icon.py "" --color "#FFC000" --size 128 -o star.png
python render_icon.py "" --color "#1F4E79" --size 64 -o arrow.png
退出码:
0 = 成功
1 = Pillow 缺失
2 = 字体找不到
"""
from __future__ import annotations
import argparse
import sys
from pathlib import Path
def find_font(preferred: list[str]) -> str | None:
"""按顺序找系统字体。返回字体路径或 None。"""
candidates = []
# Windows
candidates += [
rf"C:\Windows\Fonts\{name}" for name in [
"seguisym.ttf", # Segoe UI Symbol
"seguiemj.ttf", # Segoe UI Emoji (彩色,慎用)
"msyh.ttc", "msyh.ttf", # 微软雅黑
"simsun.ttc", # 宋体
"arial.ttf",
]
]
# macOS
candidates += [
"/System/Library/Fonts/Apple Symbols.ttf",
"/System/Library/Fonts/PingFang.ttc",
"/Library/Fonts/Arial Unicode.ttf",
]
# Linux
candidates += [
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
"/usr/share/fonts/truetype/wqy/wqy-microhei.ttc",
"/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc",
]
if preferred:
candidates = preferred + candidates
for c in candidates:
if Path(c).exists():
return c
return None
def hex_to_rgba(hex_str: str) -> tuple[int, int, int, int]:
h = hex_str.lstrip("#")
if len(h) == 6:
return int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16), 255
if len(h) == 8:
return int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16), int(h[6:8], 16)
raise ValueError(f"bad hex color: {hex_str}")
def render(glyph: str, color: str, size_px: int, output: Path,
font_path: str | None, padding: int) -> None:
try:
from PIL import Image, ImageDraw, ImageFont
except ImportError:
print("[fatal] pip install Pillow", file=sys.stderr)
sys.exit(1)
font_path = font_path or find_font([])
if not font_path:
print("[fatal] no symbol font found; pass --font /path/to/font.ttf",
file=sys.stderr)
sys.exit(2)
rgba = hex_to_rgba(color)
# 字体载入,用 size_px 的 0.85 做实际字号让字形不顶格
font_size = int(size_px * 0.85)
font = ImageFont.truetype(font_path, font_size)
# 测量字形真实包围盒
tmp = Image.new("RGBA", (size_px * 2, size_px * 2), (0, 0, 0, 0))
draw = ImageDraw.Draw(tmp)
bbox = draw.textbbox((0, 0), glyph, font=font)
tw = bbox[2] - bbox[0]
th = bbox[3] - bbox[1]
# 输出画布:正方形,边长 = size_px,加 padding
canvas_size = size_px + 2 * padding
img = Image.new("RGBA", (canvas_size, canvas_size), (0, 0, 0, 0))
draw = ImageDraw.Draw(img)
# 居中绘制 (考虑 bbox 偏移)
x = (canvas_size - tw) // 2 - bbox[0]
y = (canvas_size - th) // 2 - bbox[1]
draw.text((x, y), glyph, font=font, fill=rgba)
output.parent.mkdir(parents=True, exist_ok=True)
img.save(output, "PNG")
def main():
ap = argparse.ArgumentParser()
ap.add_argument("glyph", help="unicode 字符,如 ✓ ★ →")
ap.add_argument("--color", default="#1F4E79", help="hex,默认 #1F4E79")
ap.add_argument("--size", type=int, default=96,
help="像素边长 (字形主体),默认 96")
ap.add_argument("--padding", type=int, default=8,
help="周围透明边距像素,默认 8")
ap.add_argument("--font", default=None,
help="自定义字体路径 (.ttf/.ttc/.otf)")
ap.add_argument("-o", "--output", type=Path, required=True,
help="输出 PNG 路径")
args = ap.parse_args()
render(args.glyph, args.color, args.size, args.output,
args.font, args.padding)
size_kb = args.output.stat().st_size / 1024
print(f"[ok] {args.output} ({size_kb:.1f} KB)")
if __name__ == "__main__":
main()

View File

@ -0,0 +1,12 @@
"""svg_finalize — shared self-containment + DrawingML-compat utilities.
Used by two consumers:
1. finalize_svg.py writes svg_output/ svg_final/ on disk
2. svg_to_pptx (use_expander, tspan_flattener) reuses these modules
in memory during native pptx conversion
Deleting any module here is likely to break native pptx output, not just
svg_final/. See docs/technical-design.md "Post-Processing Pipeline" for
the full per-module consumer table.
"""

View File

@ -0,0 +1,463 @@
#!/usr/bin/env python3
"""PPT Master — single-pass image alignment + Base64 embedding.
Replaces the previous three independent finalize_svg steps:
crop-images for each <image preserveAspectRatio="… slice"/>, crop the
source bitmap to the target aspect ratio at the given
anchor and write to ``images/cropped/`` so the SVG
reference points to a pre-cropped asset.
fix-aspect for each <image>, read the source bitmap dimensions and
adjust x/y/width/height so the rendered box matches the
image aspect ratio (PowerPoint's "Convert to Shape"
ignores preserveAspectRatio and stretches otherwise).
embed-images Base64-inline every external image reference so the
legacy/preview pptx (which packages the SVG verbatim)
can resolve them pptx-internal SVG cannot follow
``../images/`` relative URIs.
Why merge: each step independently parsed + serialized the SVG, each step
re-read the same bitmap from disk, and the two spatial transforms (crop and
fit-box) are mutually exclusive yet were sequenced one after the other.
The fix-aspect default ``preserveAspectRatio = "xMidYMid meet"`` could
also kick in on rects already cropped by crop-images (whose par was
already removed), with the only thing keeping it from corrupting the
geometry being that crop and fix-aspect happened to produce numerically
equal box dimensions a brittle accident.
The merged pipeline:
for image in svg:
if href starts with data: skip (already inline)
if href is unresolvable / external URL skip
if href points to EMF/WMF skip (native PPTX passthrough only)
if missing preserveAspectRatio just embed (do not assume meet)
if align == none just embed (no spatial transform)
if mode == slice crop in memory, embed cropped bytes
if mode == meet adjust x/y/w/h, embed original bytes
write SVG once
Bonus: the cropped bitmap is base64-inlined directly without going through
``images/cropped/``, so that intermediate directory disappears and stale
crops can no longer accumulate across re-runs.
"""
from __future__ import annotations
import base64
import io
import os
import re
import sys
from pathlib import Path
from typing import TYPE_CHECKING
from urllib.parse import unquote
from xml.etree import ElementTree as ET
if __package__ in {None, ''}:
import types
package = types.ModuleType('svg_finalize')
package.__path__ = [str(Path(__file__).resolve().parent)] # type: ignore[attr-defined]
sys.modules.setdefault('svg_finalize', package)
__package__ = 'svg_finalize'
# Reuse helpers from the previous standalone modules.
from .crop_images import crop_image_to_size, get_crop_anchor, parse_preserve_aspect_ratio
from .embed_images import _optimize_image_bytes, get_mime_type
from .fix_image_aspect import calculate_fitted_dimensions
if TYPE_CHECKING: # pragma: no cover
from PIL import Image as PILImage # noqa: F401
SVG_NS = 'http://www.w3.org/2000/svg'
XLINK_NS = 'http://www.w3.org/1999/xlink'
# PIL save format is named slightly differently from the file extension /
# MIME type set we expose elsewhere; this map covers the formats we accept.
_PIL_FORMAT_BY_MIME = {
'image/png': 'PNG',
'image/jpeg': 'JPEG',
'image/gif': 'GIF',
'image/webp': 'WEBP',
}
_OFFICE_VECTOR_EXTENSIONS = {'.emf', '.wmf'}
def _parse_float(val: str | None, default: float = 0.0) -> float:
"""Best-effort float parse, tolerating trailing ``px`` etc."""
if val is None or val == '':
return default
try:
return float(re.sub(r'(px|pt|em|%|rem)$', '', val.strip()))
except (ValueError, AttributeError):
return default
def _format_number(n: float) -> str:
"""Format a float for compact SVG attribute output."""
if abs(n - round(n)) < 1e-6:
return str(int(round(n)))
s = f"{n:.2f}".rstrip('0').rstrip('.')
return s or '0'
def _resolve_image_path(href: str, svg_dir: Path) -> Path | None:
"""Resolve an <image> href to a local filesystem path.
Returns None for unresolvable references (http/https/etc.) so callers
can leave those refs untouched.
"""
if not href:
return None
decoded = unquote(href)
if decoded.startswith(('http://', 'https://', 'file://')):
return None
if os.path.isabs(decoded):
candidate = Path(decoded)
else:
candidate = (svg_dir / decoded).resolve()
return candidate if candidate.exists() else None
def _load_pil_image(img_path: Path) -> 'PILImage' | None:
"""Open an image with PIL, returning None on any failure."""
try:
from PIL import Image
except ImportError:
return None
try:
return Image.open(img_path)
except (OSError, ValueError):
return None
def _normalize_for_save(img: 'PILImage', mime_type: str) -> 'PILImage':
"""Coerce a PIL image into a mode that the target format can save.
JPEG cannot store alpha flatten to white background. Other formats
keep alpha when present.
"""
if mime_type == 'image/jpeg':
if img.mode in ('RGBA', 'LA'):
from PIL import Image
background = Image.new('RGB', img.size, (255, 255, 255))
alpha = img.getchannel('A') if img.mode == 'RGBA' else None
background.paste(img.convert('RGB'), mask=alpha)
return background
if img.mode != 'RGB':
return img.convert('RGB')
return img
# PNG / GIF / WEBP — preserve alpha if present
if img.mode == 'P':
return img.convert('RGBA' if 'A' in img.getbands() else 'RGB')
return img
def _encode_pil_to_data_uri(
img: 'PILImage',
src_path: Path,
*,
compress: bool,
max_dimension: int | None,
fallback_bytes: bytes | None,
) -> tuple[str, int] | None:
"""Serialize *img* to a base64 data URI.
If the image hasn't been transformed (slice crop or meet fit), prefer
re-encoding the original file bytes so we don't risk mutating an
already-optimized asset. *fallback_bytes* carries the raw on-disk
bytes for that path.
"""
mime_type = get_mime_type(src_path.name, fallback_bytes)
pil_format = _PIL_FORMAT_BY_MIME.get(mime_type, 'PNG')
# Encode current PIL image
try:
prepared = _normalize_for_save(img, mime_type)
buf = io.BytesIO()
save_kwargs: dict = {'format': pil_format}
if pil_format == 'JPEG':
save_kwargs['quality'] = 95
save_kwargs['optimize'] = True
elif pil_format == 'PNG':
save_kwargs['optimize'] = True
prepared.save(buf, **save_kwargs)
encoded_bytes = buf.getvalue()
except (OSError, ValueError):
return None
# If caller passed the original bytes and they're smaller (because PIL
# round-tripping an asset that was already well-compressed inflates it),
# fall back to those.
chosen = encoded_bytes
if fallback_bytes and len(fallback_bytes) < len(encoded_bytes):
chosen = fallback_bytes
chosen = _optimize_image_bytes(
chosen, mime_type, compress=compress, max_dimension=max_dimension,
)
b64 = base64.b64encode(chosen).decode('ascii')
return f'data:{mime_type};base64,{b64}', len(chosen)
def _iter_image_elements(root: ET.Element):
"""Yield every <image> in the tree regardless of namespace prefix."""
for image in root.iter(f'{{{SVG_NS}}}image'):
yield image
# Also catch namespace-stripped trees just in case
for image in root.iter('image'):
yield image
def _get_href(image: ET.Element) -> str | None:
"""Return the image href, supporting both ``href`` and ``xlink:href``."""
return image.get('href') or image.get(f'{{{XLINK_NS}}}href')
def _set_href(image: ET.Element, value: str) -> None:
"""Write the data URI back to whichever href attribute the image used."""
if image.get(f'{{{XLINK_NS}}}href') is not None:
image.set(f'{{{XLINK_NS}}}href', value)
else:
image.set('href', value)
def _process_one_image(
image: ET.Element,
svg_dir: Path,
*,
compress: bool,
max_dimension: int | None,
verbose: bool,
) -> tuple[bool, str | None]:
"""Align (slice/meet) and embed a single <image>.
Returns ``(processed, error)`` where *processed* is True iff the image
was rewritten and *error* is a short message when something went wrong
(the image is left untouched in that case).
"""
href = _get_href(image)
if not href:
return False, None
if href.startswith('data:'):
return False, None # already inline
img_path = _resolve_image_path(href, svg_dir)
if img_path is None:
return False, f'unresolved href: {href[:60]}'
try:
with open(img_path, 'rb') as fh:
raw_bytes = fh.read()
except OSError as exc:
return False, f'read failed: {exc}'
if img_path.suffix.lower() in _OFFICE_VECTOR_EXTENSIONS:
if verbose:
print(f' [INFO] {img_path.name}: Office vector left external for native PPTX passthrough')
return False, None
img = _load_pil_image(img_path)
if img is None:
return False, 'PIL open failed'
box_x = _parse_float(image.get('x'))
box_y = _parse_float(image.get('y'))
box_w = _parse_float(image.get('width'))
box_h = _parse_float(image.get('height'))
if box_w <= 0 or box_h <= 0:
return False, 'zero-sized box'
par_attr = image.get('preserveAspectRatio') or ''
par_attr = par_attr.strip()
# ------------------------------------------------------------------
# Decide the spatial transform
# ------------------------------------------------------------------
final_img: 'PILImage' = img
new_x, new_y, new_w, new_h = box_x, box_y, box_w, box_h
transformed = False # True iff bitmap content changed (crop happened)
if not par_attr:
# No preserveAspectRatio at all. The previous pipeline's fix-aspect
# step assumed "xMidYMid meet" here, which silently re-fit images
# that crop-images had already shaped. Treat absence as "leave it
# alone": embed bytes, keep box.
pass
else:
align, mode = parse_preserve_aspect_ratio(par_attr)
if align == 'none':
# Author wants stretch-to-box; preserve geometry, embed bytes.
pass
elif mode == 'slice':
x_anchor, y_anchor = get_crop_anchor(align)
cropped = crop_image_to_size(img, int(box_w), int(box_h),
x_anchor, y_anchor)
final_img = cropped
transformed = True
else: # meet (or any other mode → treat as meet)
new_w_calc, new_h_calc, off_x, off_y = calculate_fitted_dimensions(
img.size[0], img.size[1], box_w, box_h, mode='meet',
)
new_x = box_x + off_x
new_y = box_y + off_y
new_w = new_w_calc
new_h = new_h_calc
# ------------------------------------------------------------------
# Encode and rewrite
# ------------------------------------------------------------------
encoded = _encode_pil_to_data_uri(
final_img,
img_path,
compress=compress,
max_dimension=max_dimension,
fallback_bytes=raw_bytes if not transformed else None,
)
if encoded is None:
return False, 'encode failed'
data_uri, _ = encoded
_set_href(image, data_uri)
image.set('x', _format_number(new_x))
image.set('y', _format_number(new_y))
image.set('width', _format_number(new_w))
image.set('height', _format_number(new_h))
if 'preserveAspectRatio' in image.attrib:
del image.attrib['preserveAspectRatio']
if verbose:
suffix = ' (cropped)' if transformed else ''
print(f' [OK] {img_path.name}{suffix}')
return True, None
def count_office_vector_refs_in_svg(svg_path: str | Path) -> int:
"""Count local EMF/WMF image refs that the embed pass intentionally skips."""
svg_path = Path(svg_path)
svg_dir = svg_path.parent.resolve()
try:
tree = ET.parse(svg_path)
except ET.ParseError:
return 0
count = 0
seen: set[int] = set()
for image in _iter_image_elements(tree.getroot()):
ident = id(image)
if ident in seen:
continue
seen.add(ident)
href = _get_href(image)
if not href or href.startswith('data:'):
continue
img_path = _resolve_image_path(href, svg_dir)
if img_path and img_path.suffix.lower() in _OFFICE_VECTOR_EXTENSIONS:
count += 1
return count
def align_and_embed_images_in_svg(
svg_path: str | Path,
*,
dry_run: bool = False,
verbose: bool = False,
compress: bool = False,
max_dimension: int | None = None,
) -> tuple[int, int]:
"""Run the merged align + embed pass on a single SVG file.
Returns ``(processed_count, error_count)``.
"""
svg_path = Path(svg_path)
svg_dir = svg_path.parent.resolve()
# Register namespaces for clean serialization
ET.register_namespace('', SVG_NS)
ET.register_namespace('xlink', XLINK_NS)
try:
tree = ET.parse(svg_path)
except ET.ParseError as exc:
if verbose:
print(f' [ERROR] {svg_path.name}: parse failed ({exc})')
return (0, 1)
root = tree.getroot()
# Avoid double-iteration if an element matches both namespaced and
# bare-tag iteration paths.
seen: set[int] = set()
processed = 0
errors = 0
for image in _iter_image_elements(root):
ident = id(image)
if ident in seen:
continue
seen.add(ident)
if dry_run:
processed += 1
continue
ok, err = _process_one_image(
image, svg_dir,
compress=compress, max_dimension=max_dimension, verbose=verbose,
)
if ok:
processed += 1
elif err:
errors += 1
if verbose:
print(f' [WARN] {svg_path.name}: {err}')
if processed > 0 and not dry_run:
tree.write(svg_path, encoding='utf-8', xml_declaration=False)
return (processed, errors)
# ---------------------------------------------------------------------------
# Standalone CLI (rare; the main entry point is finalize_svg.py)
# ---------------------------------------------------------------------------
def build_parser() -> argparse.ArgumentParser:
"""Build the standalone diagnostic parser."""
import argparse
parser = argparse.ArgumentParser(
description='Align (slice/meet) and Base64-embed all <image> refs in an SVG.',
)
parser.add_argument('svg', type=Path, help='SVG file to process in place')
parser.add_argument('-n', '--dry-run', action='store_true')
parser.add_argument('-v', '--verbose', action='store_true')
parser.add_argument('--compress', action='store_true',
help='Compress images before embedding')
parser.add_argument('--max-dimension', type=int, default=None,
help='Downscale images larger than this on either axis')
return parser
def main(argv: list[str] | None = None) -> int:
"""Run the standalone diagnostic CLI."""
parser = build_parser()
args = parser.parse_args(argv)
if not args.svg.exists():
print(f'Error: file not found: {args.svg}', file=sys.stderr)
return 1
proc, err = align_and_embed_images_in_svg(
args.svg,
dry_run=args.dry_run,
verbose=args.verbose,
compress=args.compress,
max_dimension=args.max_dimension,
)
print(f'Processed {proc} image(s), {err} error(s)')
return 1 if err else 0
if __name__ == '__main__':
raise SystemExit(main())

View File

@ -0,0 +1,358 @@
#!/usr/bin/env python3
"""
PPT Master - Smart Image Cropping Tool
Smartly crops images based on the preserveAspectRatio attribute of <image> elements in SVG:
- slice: Crop to fill (similar to CSS object-fit: cover)
- meet: Display fully without cropping (similar to CSS object-fit: contain)
Supports 9 alignment modes:
- xMinYMin / xMidYMin / xMaxYMin (top alignment)
- xMinYMid / xMidYMid / xMaxYMid (vertical center)
- xMinYMax / xMidYMax / xMaxYMax (bottom alignment)
Usage:
python3 scripts/svg_finalize/crop_images.py <SVG file or directory> [--dry-run]
"""
import os
import re
import hashlib
import sys
import argparse
from pathlib import Path
from xml.etree import ElementTree as ET
from urllib.parse import unquote
try:
from PIL import Image
except ImportError:
print("Error: PIL (Pillow) is required. Run: pip install Pillow")
exit(1)
def parse_preserve_aspect_ratio(attr: str) -> tuple[str, str]:
"""
Parse the preserveAspectRatio attribute.
Returns: (align, meet_or_slice)
align: e.g. 'xMidYMid'
meet_or_slice: 'meet' or 'slice'
"""
if not attr:
return ('xMidYMid', 'meet') # Default value
parts = attr.strip().split()
align = parts[0] if parts else 'xMidYMid'
meet_or_slice = parts[1] if len(parts) > 1 else 'meet'
return (align, meet_or_slice)
def get_crop_anchor(align: str) -> tuple[float, float]:
"""
Return the crop anchor point based on the align value.
Returns: (x_anchor, y_anchor)
x_anchor: 0.0 (left), 0.5 (center), 1.0 (right)
y_anchor: 0.0 (top), 0.5 (center), 1.0 (bottom)
"""
x_map = {'xMin': 0.0, 'xMid': 0.5, 'xMax': 1.0}
y_map = {'YMin': 0.0, 'YMid': 0.5, 'YMax': 1.0}
x_anchor = 0.5
y_anchor = 0.5
for key, val in x_map.items():
if key in align:
x_anchor = val
break
for key, val in y_map.items():
if key in align:
y_anchor = val
break
return (x_anchor, y_anchor)
def crop_image_to_size(
img: Image.Image,
target_width: int,
target_height: int,
x_anchor: float = 0.5,
y_anchor: float = 0.5,
) -> Image.Image:
"""
Crop an image to the target aspect ratio, preserving original resolution (no scaling).
New logic: Only crops the original image to the target aspect ratio without any scaling,
thus preserving the original resolution and clarity.
Args:
img: PIL Image object
target_width: Target width (used to calculate ratio)
target_height: Target height (used to calculate ratio)
x_anchor: Horizontal anchor (0=left, 0.5=center, 1=right)
y_anchor: Vertical anchor (0=top, 0.5=center, 1=bottom)
Returns:
Cropped PIL Image object (preserving original resolution)
"""
img_width, img_height = img.size
# Calculate target aspect ratio
target_ratio = target_width / target_height
img_ratio = img_width / img_height
# Calculate crop region on the original image based on ratio (no scaling)
if img_ratio > target_ratio:
# Original image is wider; crop left and right sides
crop_height = img_height
crop_width = int(img_height * target_ratio)
else:
# Original image is taller; crop top and bottom sides
crop_width = img_width
crop_height = int(img_width / target_ratio)
# Calculate crop position based on anchor point
extra_width = img_width - crop_width
extra_height = img_height - crop_height
left = int(extra_width * x_anchor)
top = int(extra_height * y_anchor)
right = left + crop_width
bottom = top + crop_height
# Crop only, no scaling
return img.crop((left, top, right, bottom))
def process_svg_images(
svg_file: str,
output_dir: str | Path | None = None,
dry_run: bool = False,
verbose: bool = True,
) -> tuple[int, int]:
"""
Process images in an SVG file, cropping based on the preserveAspectRatio attribute.
Args:
svg_file: SVG file path
output_dir: Output directory for cropped images (default: images/cropped/)
dry_run: Preview only, no actual processing
verbose: Verbose output
Returns:
(processed_count, error_count)
"""
svg_path = Path(svg_file)
svg_dir = svg_path.parent
# Default output directory
if output_dir is None:
# Find the project's images directory
# Parent directory of svg_output or svg_final, under images
project_dir = svg_dir.parent
output_dir = project_dir / 'images' / 'cropped'
else:
output_dir = Path(output_dir)
# Parse SVG
try:
ET.register_namespace('', 'http://www.w3.org/2000/svg')
ET.register_namespace('xlink', 'http://www.w3.org/1999/xlink')
tree = ET.parse(str(svg_path))
root = tree.getroot()
except Exception as e:
if verbose:
print(f" [ERROR] Failed to parse SVG: {e}")
return (0, 1)
ns = {'svg': 'http://www.w3.org/2000/svg', 'xlink': 'http://www.w3.org/1999/xlink'}
processed_count = 0
error_count = 0
modified = False
# Find all image elements
for image in root.iter('{http://www.w3.org/2000/svg}image'):
# Get href attribute
href = image.get('{http://www.w3.org/1999/xlink}href') or image.get('href')
if not href:
continue
# Skip Base64 inline images
if href.startswith('data:'):
continue
# Get preserveAspectRatio attribute
par = image.get('preserveAspectRatio', '')
align, mode = parse_preserve_aspect_ratio(par)
# Only process slice mode
if mode != 'slice':
continue
# Get target dimensions
try:
target_width = int(float(image.get('width', 0)))
target_height = int(float(image.get('height', 0)))
except (ValueError, TypeError):
continue
if target_width <= 0 or target_height <= 0:
continue
# Parse image path
href_decoded = unquote(href)
if href_decoded.startswith('../'):
img_path = (svg_dir / href_decoded).resolve()
else:
img_path = (svg_dir / href_decoded).resolve()
if not img_path.exists():
if verbose:
print(f" [SKIP] Image not found: {href}")
continue
# Get crop anchor point
x_anchor, y_anchor = get_crop_anchor(align)
if dry_run:
if verbose:
print(f" [DRY] {img_path.name} -> {target_width}x{target_height} "
f"(align: {align}, anchor: {x_anchor},{y_anchor})")
processed_count += 1
continue
# Create output directory
output_dir.mkdir(parents=True, exist_ok=True)
try:
# Open and process image
img = Image.open(img_path)
output_is_png = img_path.suffix.lower() == '.png'
# Preserve alpha for PNG assets such as translucent overlays.
if output_is_png:
if img.mode == 'P':
img = img.convert('RGBA')
elif img.mode not in ('RGBA', 'LA', 'RGB', 'L'):
img = img.convert('RGBA' if 'A' in img.getbands() else 'RGB')
else:
if img.mode in ('RGBA', 'LA'):
background = Image.new('RGB', img.size, (255, 255, 255))
alpha = img.getchannel('A')
background.paste(img.convert('RGB'), mask=alpha)
img = background
elif img.mode == 'P':
img = img.convert('RGB')
elif img.mode not in ('RGB', 'L'):
img = img.convert('RGB')
# Crop
cropped = crop_image_to_size(img, target_width, target_height, x_anchor, y_anchor)
# Generate output filename (keep original name, place in cropped directory)
output_filename = img_path.name
output_path = output_dir / output_filename
# Save
if output_is_png:
cropped.save(output_path, 'PNG', optimize=True)
else:
cropped.save(output_path, 'JPEG', quality=90, optimize=True)
if verbose:
print(f" [OK] {img_path.name}: {img.size} -> {target_width}x{target_height} "
f"({align})")
# Update image path in SVG
new_href = f"../images/cropped/{output_filename}"
if image.get('{http://www.w3.org/1999/xlink}href'):
image.set('{http://www.w3.org/1999/xlink}href', new_href)
else:
image.set('href', new_href)
# Remove preserveAspectRatio (image is now correctly sized)
if 'preserveAspectRatio' in image.attrib:
del image.attrib['preserveAspectRatio']
modified = True
processed_count += 1
except Exception as e:
if verbose:
print(f" [ERROR] {img_path.name}: {e}")
error_count += 1
# Save modified SVG
if modified and not dry_run:
tree.write(str(svg_path), encoding='unicode', xml_declaration=False)
return (processed_count, error_count)
def process_directory(directory: str, dry_run: bool = False, verbose: bool = True) -> tuple[int, int]:
"""Process all SVG files in a directory."""
directory_path = Path(directory)
total_processed = 0
total_errors = 0
for svg_file in directory_path.glob('*.svg'):
if verbose:
print(f" Processing: {svg_file.name}")
processed, errors = process_svg_images(str(svg_file), dry_run=dry_run, verbose=verbose)
total_processed += processed
total_errors += errors
return (total_processed, total_errors)
def main() -> None:
"""Run the CLI entry point."""
parser = argparse.ArgumentParser(
description='PPT Master - Smart Image Cropping Tool',
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog='''
Examples:
%(prog)s projects/my_project/svg_output
%(prog)s page_01.svg --dry-run
preserveAspectRatio usage:
xMidYMid slice Center crop (default)
xMidYMin slice Keep top
xMidYMax slice Keep bottom
xMinYMid slice Keep left
xMaxYMid slice Keep right
xMidYMid meet Display fully, no cropping
'''
)
parser.add_argument('path', type=Path, help='SVG file or directory')
parser.add_argument('--dry-run', '-n', action='store_true', help='Preview only, no actual processing')
parser.add_argument('--quiet', '-q', action='store_true', help='Quiet mode')
args = parser.parse_args()
if not args.path.exists():
print(f"[ERROR] Path not found: {args.path}")
sys.exit(1)
print("PPT Master - Smart Image Cropping")
print("=" * 50)
if args.path.is_file():
processed, errors = process_svg_images(str(args.path), dry_run=args.dry_run,
verbose=not args.quiet)
else:
processed, errors = process_directory(str(args.path), dry_run=args.dry_run,
verbose=not args.quiet)
print()
print(f"Done: {processed} image(s) cropped, {errors} error(s)")
if __name__ == '__main__':
main()

View File

@ -0,0 +1,479 @@
#!/usr/bin/env python3
"""
SVG Icon Embedding Tool
Replaces icon placeholders in SVG files with actual icon code.
Placeholder syntax (new SVGs must include a library prefix):
<use data-icon="chunk-filled/rocket" x="100" y="200" width="48" height="48" fill="#0076A8"/>
<use data-icon="tabler-filled/home" x="100" y="200" width="48" height="48" fill="#0076A8"/>
<use data-icon="tabler-outline/home" x="100" y="200" width="48" height="48" fill="#0076A8"/>
<use data-icon="tabler-outline/home" x="100" y="200" width="48" height="48" fill="#0076A8" stroke-width="3"/>
<use data-icon="layered_slide_06_ill01"/>
Legacy compatibility accepted by the resolver:
<use data-icon="rocket" .../> -> chunk-filled/rocket
<use data-icon="chunk/rocket" .../> -> chunk-filled/rocket
Optional `stroke-width` (stroke-style libraries only e.g. tabler-outline):
Default 2 (matches the source). Pass 1.5 for thin, 3 for bold.
Ignored on fill-style libraries.
After replacement:
<g transform="translate(100, 200) scale(3)" fill="#0076A8">
<path d="..."/>
</g>
Icon libraries (subdirectories of templates/icons/):
chunk-filled/ - 640+ fill icons, 16x16 viewBox (use prefix: chunk-filled/name; legacy 'chunk/' also accepted)
tabler-filled/ - 1000+ fill icons, 24x24 viewBox (use prefix: tabler-filled/name)
tabler-outline/ - 5000+ stroke icons, 24x24 viewBox (use prefix: tabler-outline/name)
phosphor-duotone/ - 1200+ duotone icons, 256x256 viewBox (single color + 0.2-opacity backplate)
simple-icons/ - 3400+ brand logos, 24x24 viewBox (brand-inset library used alongside the chosen primary library, NOT as a standalone library for generic icons)
<asset_id>.svg - project-local extracted vector illustrations with data-icon-style="preserve-color"; preserve source colors and natural viewBox aspect ratio
Usage:
python3 scripts/svg_finalize/embed_icons.py <svg_file> [svg_file2] ...
python3 scripts/svg_finalize/embed_icons.py svg_output/*.svg
Options:
--icons-dir <path> Icon directory path (default: templates/icons/)
--dry-run Only show what would be replaced, without modifying files
--verbose Show detailed information
"""
from __future__ import annotations
import os
import re
import sys
import argparse
from pathlib import Path
from xml.etree import ElementTree as ET
# Default icon directory
DEFAULT_ICONS_DIR = Path(__file__).parent.parent.parent / 'templates' / 'icons'
# Icon base size per library
ICON_BASE_SIZES = {
'chunk-filled': 16,
'chunk': 16, # backward compat alias → chunk-filled/
'tabler-filled': 24,
'tabler-outline': 24,
'phosphor-duotone': 256,
'simple-icons': 24,
}
DEFAULT_ICON_BASE_SIZE = 24
BaseGeometry = float | tuple[float, float, float, float]
def _get_viewbox_size(content: str) -> float:
"""Extract the width from viewBox attribute (assumed square). Returns 0 if not found."""
m = re.search(r'viewBox=["\']0 0 ([\d.]+)', content)
if m:
return float(m.group(1))
return 0
def _get_viewbox_geometry(content: str) -> tuple[float, float, float, float] | None:
"""Extract full viewBox geometry as (min_x, min_y, width, height)."""
match = re.search(r'viewBox=["\']([^"\']+)["\']', content)
if not match:
return None
parts = re.split(r'[\s,]+', match.group(1).strip())
if len(parts) < 4:
return None
try:
min_x, min_y, width, height = [float(part) for part in parts[:4]]
except ValueError:
return None
if width <= 0 or height <= 0:
return None
return min_x, min_y, width, height
def _format_number(value: object) -> str:
"""Format SVG numeric values compactly without losing meaningful precision."""
if isinstance(value, float):
return f'{value:g}'
return str(value)
def _base_geometry(base_size: BaseGeometry) -> tuple[float, float, float, float]:
"""Normalize legacy square icon size and full viewBox geometry."""
if isinstance(base_size, tuple):
return base_size
return 0.0, 0.0, float(base_size), float(base_size)
def _is_preserve_color_asset(content: str) -> bool:
"""Project illustrations are vector assets, not recolorable monochrome icons.
The `data-icon-style="preserve-color"` marker is stamped by
extract_svg_assets.py and is the single source of truth hand-authored
multi-color assets must carry it to keep their colors and aspect ratio.
"""
return 'data-icon-style="preserve-color"' in content
def _detect_icon_style(content: str) -> str:
"""Detect whether an icon is fill-based or stroke-based."""
# stroke="currentColor" with fill="none" → stroke style
if 'stroke="currentColor"' in content and 'fill="none"' in content:
return 'stroke'
return 'fill'
def _extract_svg_body(content: str) -> list[str]:
"""Return the root SVG body for preserve-color assets without editing attrs."""
match = re.search(r'<svg\b[^>]*>(.*)</svg>\s*$', content, re.DOTALL)
if not match:
return []
body = match.group(1).strip()
return [body] if body else []
def _extract_shape_elements(content: str, color: str) -> list[str]:
"""
Extract all drawable shape elements from an icon SVG, replacing
fill/stroke color references (currentColor or #xxxxxx) with the target color.
Supports: <path>, <circle>, <rect>, <line>, <polyline>, <polygon>, <ellipse>
"""
shape_tags = ('path', 'circle', 'rect', 'line', 'polyline', 'polygon', 'ellipse')
pattern = r'<(' + '|'.join(shape_tags) + r')(\s[^>]*)?(?:/>|></\1>)'
matches = re.findall(pattern, content, re.DOTALL)
elements = []
for tag, attrs in matches:
# Remove standalone fill/stroke color attrs so outer <g> controls color.
# Also strip stroke-width so the outer <g> can override it (otherwise the
# icon's source stroke-width="2" would shadow any caller-specified value).
attrs_clean = re.sub(r'\s*fill="(?:currentColor|#[0-9a-fA-F]{3,6}|none)"', '', attrs)
attrs_clean = re.sub(r'\s*stroke="(?:currentColor|#[0-9a-fA-F]{3,6}|none)"', '', attrs_clean)
attrs_clean = re.sub(r'\s*stroke-width="[^"]*"', '', attrs_clean)
elements.append(f'<{tag}{attrs_clean}/>')
return elements
def _resolve_in_dir(icon_name: str, icons_dir: Path) -> tuple[Path, float]:
"""Resolve `icon_name` against a single icons dir (no fallback)."""
# Backward compat: 'chunk/name' → 'chunk-filled/name'
_LIB_ALIASES = {'chunk': 'chunk-filled'}
if '/' in icon_name:
lib, name = icon_name.split('/', 1)
lib = _LIB_ALIASES.get(lib, lib) # resolve aliases
icon_path = icons_dir / lib / f'{name}.svg'
base_size = ICON_BASE_SIZES.get(lib, 24)
else:
# Backward compatibility: un-prefixed names fall back to legacy chunk-filled/ library
icon_path = icons_dir / 'chunk-filled' / f'{icon_name}.svg'
base_size = 16
if not icon_path.exists():
icon_path = icons_dir / f'{icon_name}.svg' # legacy flat layout
base_size = 16
return icon_path, base_size
def resolve_icon_path(icon_name: str, icons_dir: Path, fallback_dir: Path | None = None) -> tuple[Path, float]:
"""
Resolve icon name to file path and base size, e.g. "chunk-filled/home"
icons_dir/chunk-filled/home.svg. "chunk/" is a backward-compat alias; an
un-prefixed name falls back to chunk-filled/ then a legacy flat layout.
Resolution is project-first: if the icon is absent under ``icons_dir`` and a
``fallback_dir`` (the global library) is given, the fallback's path is
returned instead. Returns (path, base_size); the path may not exist when
neither dir has the icon.
"""
icon_path, base_size = _resolve_in_dir(icon_name, icons_dir)
if fallback_dir is not None and not icon_path.exists():
fb_path, fb_size = _resolve_in_dir(icon_name, fallback_dir)
if fb_path.exists():
return fb_path, fb_size
return icon_path, base_size
def extract_paths_from_icon(icon_path: Path, target_color: str = '#000000') -> tuple[list[str], str, BaseGeometry]:
"""
Extract drawable elements from an icon SVG file.
Returns:
(elements, style, base_size)
style: 'fill', 'stroke', or 'preserve'
base_size: square icon size, or full viewBox geometry for preserve assets
"""
if not icon_path.exists():
return [], 'fill', 16
content = icon_path.read_text(encoding='utf-8')
if _is_preserve_color_asset(content):
geometry = _get_viewbox_geometry(content) or (0.0, 0.0, DEFAULT_ICON_BASE_SIZE, DEFAULT_ICON_BASE_SIZE)
elements = _extract_svg_body(content)
return elements, 'preserve', geometry
style = _detect_icon_style(content)
base_size = _get_viewbox_size(content) or 16
elements = _extract_shape_elements(content, target_color)
return elements, style, base_size
def parse_use_element(use_match: str) -> dict[str, str | float]:
"""
Parse attributes of a use element.
Args:
use_match: Complete string of the use element
Returns:
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)
# 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))
# Extract fill color
fill_match = re.search(r'fill="([^"]+)"', use_match)
if fill_match:
attrs['fill'] = fill_match.group(1)
# 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)
# 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)
# 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)
return attrs
def resolve_icon_color(attrs: dict[str, str | float], style: str) -> str:
"""Resolve the caller-provided color for fill or stroke icon libraries."""
if style == 'preserve':
return 'preserve'
fill = str(attrs.get('fill', '')).strip()
stroke = str(attrs.get('stroke', '')).strip()
if style == 'stroke':
if fill and fill != 'none':
return fill
if stroke and stroke != 'none':
return stroke
return '#000000'
if fill:
return fill
if stroke and stroke != 'none':
return stroke
return '#000000'
def generate_icon_group(attrs: dict[str, str | float], elements: list[str], style: str, base_size: BaseGeometry) -> str:
"""
Generate the icon's <g> element.
Args:
attrs: Attributes of the use element
elements: List of drawable SVG elements
style: 'fill', 'stroke', or 'preserve'
base_size: Icon's natural size, or full viewBox geometry for preserve assets
Returns:
Complete <g> element string
"""
min_x, min_y, base_width, base_height = _base_geometry(base_size)
x = attrs.get('x', 0)
y = attrs.get('y', 0)
width = attrs.get('width', base_width)
height = attrs.get('height', base_height)
color = resolve_icon_color(attrs, style)
icon_name = attrs.get('icon', 'unknown')
scale_x = float(width) / base_width
scale_y = float(height) / base_height
if attrs.get('transform'):
# This transform is authoritative: the editor computes it from the
# expanded <g>, so composing it with x/y would apply placement twice.
transform = str(attrs['transform'])
elif abs(scale_x - 1) < 1e-6 and abs(scale_y - 1) < 1e-6:
transform = f'translate({_format_number(x)}, {_format_number(y)})'
elif abs(scale_x - scale_y) < 1e-6:
transform = f'translate({_format_number(x)}, {_format_number(y)}) scale({_format_number(scale_x)})'
else:
transform = (
f'translate({_format_number(x)}, {_format_number(y)}) '
f'scale({_format_number(scale_x)}, {_format_number(scale_y)})'
)
elements_str = '\n '.join(elements)
if style == 'preserve':
if min_x or min_y:
inner_transform = f'translate({_format_number(-min_x)}, {_format_number(-min_y)})'
elements_str = f'<g transform="{inner_transform}">\n {elements_str}\n </g>'
return f'''<!-- icon: {icon_name} -->
<g transform="{transform}">
{elements_str}
</g>'''
if style == 'stroke':
# Default to 2 — matches the source stroke-width baked into tabler-outline
# (and any other stroke library) so omitting the attribute reproduces
# pre-change visual output.
stroke_width = attrs.get('stroke-width', '2')
color_attrs = f'fill="none" stroke="{color}" stroke-width="{stroke_width}"'
else:
color_attrs = f'fill="{color}"'
return f'''<!-- icon: {icon_name} -->
<g transform="{transform}" {color_attrs}>
{elements_str}
</g>'''
def process_svg_file(svg_path: Path, icons_dir: Path, dry_run: bool = False, verbose: bool = False, fallback_dir: Path | None = None) -> int:
"""
Process a single SVG file, replacing all icon placeholders.
Args:
svg_path: SVG file path
icons_dir: Icon directory path
dry_run: Whether to only preview without modifying
verbose: Whether to show detailed information
Returns:
Number of icons replaced
"""
if not svg_path.exists():
print(f"[ERROR] File not found: {svg_path}")
return 0
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))
if not matches:
if verbose:
print(f"[SKIP] No icon placeholders: {svg_path}")
return 0
replaced_count = 0
new_content = content
# Replace from back to front to avoid position offset
for match in reversed(matches):
use_str = match.group(0)
attrs = parse_use_element(use_str)
icon_name = attrs.get('icon')
if not icon_name:
continue
icon_path, _ = resolve_icon_path(str(icon_name), icons_dir, fallback_dir)
elements, style, base_size = extract_paths_from_icon(icon_path)
color = resolve_icon_color(attrs, style)
if not elements:
print(f"[WARN] Icon not found: {icon_name} (in {svg_path.name})")
continue
replacement = generate_icon_group(attrs, elements, style, base_size)
if verbose or dry_run:
print(f" [*] {icon_name}: x={attrs.get('x', 0)}, y={attrs.get('y', 0)}, "
f"size={attrs.get('width', base_size)}, fill={color}, style={style}")
new_content = new_content[:match.start()] + replacement + new_content[match.end():]
replaced_count += 1
if not dry_run and replaced_count > 0:
svg_path.write_text(new_content, encoding='utf-8')
status = "[PREVIEW]" if dry_run else "[OK]"
print(f"{status} {svg_path.name} ({replaced_count} icons)")
return replaced_count
def main() -> None:
"""Run the CLI entry point."""
parser = argparse.ArgumentParser(
description='Replace icon placeholders in SVG files with actual icon code',
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog='''
Examples:
python3 scripts/svg_finalize/embed_icons.py svg_output/01_cover.svg
python3 scripts/svg_finalize/embed_icons.py svg_output/*.svg
python3 scripts/svg_finalize/embed_icons.py --dry-run svg_output/*.svg
python3 scripts/svg_finalize/embed_icons.py --icons-dir my_icons/ output.svg
'''
)
parser.add_argument('files', nargs='+', help='SVG files to process')
parser.add_argument('--icons-dir', type=Path, default=DEFAULT_ICONS_DIR,
help=f'Icon directory path (default: {DEFAULT_ICONS_DIR})')
parser.add_argument('--dry-run', action='store_true',
help='Only show what would be replaced, without modifying files')
parser.add_argument('--verbose', '-v', action='store_true',
help='Show detailed information')
args = parser.parse_args()
# Validate icon directory
if not args.icons_dir.exists():
print(f"[ERROR] Icon directory not found: {args.icons_dir}")
sys.exit(1)
print(f"[DIR] Icon directory: {args.icons_dir}")
if args.dry_run:
print("[PREVIEW] Preview mode (no files will be modified)")
print()
total_replaced = 0
total_files = 0
for file_pattern in args.files:
svg_path = Path(file_pattern)
if svg_path.exists():
count = process_svg_file(svg_path, args.icons_dir, args.dry_run, args.verbose)
total_replaced += count
if count > 0:
total_files += 1
print()
print(f"[Summary] Total: {total_files} file(s), {total_replaced} icon(s)" +
(" (preview)" if args.dry_run else " replaced"))
if __name__ == '__main__':
main()

View File

@ -0,0 +1,265 @@
#!/usr/bin/env python3
"""
SVG Image Embedding Tool
Converts externally referenced images in SVG files to Base64 inline format.
Usage:
python3 scripts/svg_finalize/embed_images.py <svg_file> [svg_file2] ...
python3 scripts/svg_finalize/embed_images.py *.svg
Examples:
python3 scripts/svg_finalize/embed_images.py examples/ppt169_demo/svg_output/01_cover.svg
python3 scripts/svg_finalize/embed_images.py examples/ppt169_demo/svg_output/*.svg
"""
import os
import base64
import re
import sys
import argparse
def get_mime_type(filename: str, file_bytes: bytes | None = None) -> str:
"""Return the MIME type based on file bytes first, then extension."""
if file_bytes:
if file_bytes.startswith(b"\x89PNG\r\n\x1a\n"):
return 'image/png'
if file_bytes.startswith(b"\xff\xd8\xff"):
return 'image/jpeg'
if file_bytes.startswith((b"GIF87a", b"GIF89a")):
return 'image/gif'
if file_bytes.startswith(b"RIFF") and file_bytes[8:12] == b"WEBP":
return 'image/webp'
if file_bytes.lstrip().startswith(b"<svg"):
return 'image/svg+xml'
ext = filename.lower().split('.')[-1]
mime_map = {
'png': 'image/png',
'jpg': 'image/jpeg',
'jpeg': 'image/jpeg',
'gif': 'image/gif',
'webp': 'image/webp',
'svg': 'image/svg+xml',
}
return mime_map.get(ext, 'application/octet-stream')
def get_file_size_str(size_bytes: int) -> str:
"""Convert byte count to a human-readable file size string."""
if size_bytes < 1024:
return f"{size_bytes} B"
elif size_bytes < 1024 * 1024:
return f"{size_bytes / 1024:.1f} KB"
else:
return f"{size_bytes / (1024 * 1024):.1f} MB"
def _optimize_image_bytes(img_bytes: bytes, mime_type: str,
compress: bool = False,
max_dimension: int | None = None) -> bytes:
"""Optionally compress and/or downscale image bytes.
Returns the (possibly optimized) image bytes. Falls back to the
original bytes if PIL is not available or optimization fails.
"""
if not compress and not max_dimension:
return img_bytes
try:
from PIL import Image as PILImage
import io
except ImportError:
return img_bytes
try:
img = PILImage.open(io.BytesIO(img_bytes))
except Exception:
return img_bytes
changed = False
# Downscale if exceeding max_dimension
if max_dimension:
w, h = img.size
if w > max_dimension or h > max_dimension:
ratio = min(max_dimension / w, max_dimension / h)
new_w, new_h = int(w * ratio), int(h * ratio)
img = img.resize((new_w, new_h), PILImage.LANCZOS)
changed = True
# Compress
if compress or changed:
buf = io.BytesIO()
if mime_type == 'image/jpeg':
if img.mode in ('RGBA', 'P'):
img = img.convert('RGB')
img.save(buf, format='JPEG', quality=85, optimize=True)
elif mime_type == 'image/png':
img.save(buf, format='PNG', optimize=True)
else:
# For other formats, just re-save
fmt = img.format or 'PNG'
img.save(buf, format=fmt)
optimized = buf.getvalue()
# Only use optimized version if it's actually smaller
if len(optimized) < len(img_bytes):
return optimized
return img_bytes
def embed_images_in_svg(svg_path: str, dry_run: bool = False,
compress: bool = False,
max_dimension: int | None = None) -> tuple[int, int]:
"""
Convert externally referenced images in an SVG file to Base64 inline format.
Args:
svg_path: SVG file path
dry_run: If True, only show which images would be processed without modifying the file
compress: If True, compress images before embedding (JPEG quality=85, PNG optimize)
max_dimension: If set, downscale images exceeding this dimension on either axis
Returns:
tuple: (number of images processed, file size after embedding)
"""
svg_dir = os.path.dirname(os.path.abspath(svg_path))
with open(svg_path, 'r', encoding='utf-8') as f:
content = f.read()
original_size = len(content.encode('utf-8'))
# Match href="xxx.png" or href="xxx.jpg" etc. (exclude those already using data:)
pattern = r'href="(?!data:)([^"]+\.(png|jpg|jpeg|gif|webp))"'
images_found = []
images_embedded = 0
def replace_with_base64(match):
nonlocal images_embedded
img_path = match.group(1)
# Decode XML/HTML entities (e.g., &amp; -> &)
import html
img_path_decoded = html.unescape(img_path)
# Handle relative paths
if not os.path.isabs(img_path_decoded):
full_path = os.path.join(svg_dir, img_path_decoded)
else:
full_path = img_path_decoded
if not os.path.exists(full_path):
print(f" [WARN] Image not found: {img_path}")
images_found.append((img_path, "NOT FOUND", 0, None))
return match.group(0)
img_size = os.path.getsize(full_path)
if dry_run:
images_found.append((img_path, "WILL EMBED", img_size, None))
return match.group(0)
with open(full_path, 'rb') as img_file:
img_bytes = img_file.read()
mime_type = get_mime_type(img_path, img_bytes)
optimized_bytes = _optimize_image_bytes(
img_bytes, mime_type, compress=compress, max_dimension=max_dimension)
b64_data = base64.b64encode(optimized_bytes).decode('utf-8')
images_embedded += 1
saved = len(img_bytes) - len(optimized_bytes)
if saved > 0 and (compress or max_dimension):
pct = saved / len(img_bytes) * 100
images_found.append((img_path, "EMBEDDED", img_size,
f"{get_file_size_str(len(img_bytes))}{get_file_size_str(len(optimized_bytes))}, saved {pct:.0f}%"))
else:
images_found.append((img_path, "EMBEDDED", img_size, None))
return f'href="data:{mime_type};base64,{b64_data}"'
new_content = re.sub(pattern, replace_with_base64, content)
new_size = len(new_content.encode('utf-8'))
# Print processed images
if images_found:
print(f"\n[FILE] {os.path.basename(svg_path)}")
for img_path, status, size, opt_info in images_found:
size_str = get_file_size_str(size) if size > 0 else ""
if status == "EMBEDDED":
if opt_info:
print(f" [OK] {img_path} ({opt_info})")
else:
print(f" [OK] {img_path} ({size_str})")
elif status == "WILL EMBED":
print(f" [PREVIEW] {img_path} ({size_str}) [dry-run]")
else:
print(f" [FAIL] {img_path} ({status})")
print(f" [SIZE] {get_file_size_str(original_size)} -> {get_file_size_str(new_size)}")
if not dry_run and images_embedded > 0:
with open(svg_path, 'w', encoding='utf-8') as f:
f.write(new_content)
return (images_embedded, new_size)
def main() -> None:
"""Run the CLI entry point."""
parser = argparse.ArgumentParser(
description='Convert externally referenced images in SVG files to Base64 inline format',
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog='''
Examples:
%(prog)s 01_cover.svg # Process a single file
%(prog)s *.svg # Process all SVGs in current directory
%(prog)s --dry-run *.svg # Preview files to be processed
'''
)
parser.add_argument('files', nargs='+', help='SVG files to process')
parser.add_argument('--dry-run', '-n', action='store_true',
help='Only show which images would be processed, without modifying files')
parser.add_argument('--compress', action='store_true',
help='Compress images before embedding (JPEG quality=85, PNG optimize)')
parser.add_argument('--max-dimension', type=int, default=None,
help='Downscale images exceeding this dimension on either axis (e.g., 2560)')
args = parser.parse_args()
if args.dry_run:
print("[INFO] Dry-run mode: only preview, no modification\n")
if args.compress:
print("[INFO] Compression enabled: JPEG quality=85, PNG optimize")
if args.max_dimension:
print(f"[INFO] Max dimension: {args.max_dimension}px")
total_images = 0
total_files = 0
for svg_file in args.files:
if not os.path.exists(svg_file):
print(f"[ERROR] File not found: {svg_file}")
continue
if not svg_file.endswith('.svg'):
print(f"[SKIP] Skipping non-SVG file: {svg_file}")
continue
images, _ = embed_images_in_svg(svg_file, dry_run=args.dry_run,
compress=args.compress,
max_dimension=args.max_dimension)
if images > 0:
total_images += images
total_files += 1
print(f"\n{'=' * 50}")
if args.dry_run:
print(f"[PREVIEW] Will process {total_images} images in {total_files} files")
else:
print(f"[DONE] Embedded {total_images} images in {total_files} files")
if __name__ == '__main__':
main()

View File

@ -0,0 +1,380 @@
#!/usr/bin/env python3
"""
SVG Image Aspect Ratio Fix Tool
Fixes the dimensions of <image> elements in SVG to match the original image aspect ratio.
This prevents images from being stretched when PowerPoint converts SVG to editable shapes.
Principle:
When PowerPoint converts SVG to editable shapes, it ignores the preserveAspectRatio attribute
and directly stretches the image to fill the area specified by width/height.
This tool reads the actual image aspect ratio and recalculates the x, y, width, height of
<image> elements so that images are centered and maintain their original aspect ratio.
Usage:
python3 scripts/svg_finalize/fix_image_aspect.py <svg_file> [svg_file2] ...
python3 scripts/svg_finalize/fix_image_aspect.py projects/xxx/svg_output/*.svg
# Preview mode
python3 scripts/svg_finalize/fix_image_aspect.py --dry-run projects/xxx/svg_output/*.svg
Examples:
python3 scripts/svg_finalize/fix_image_aspect.py projects/demo/svg_output/slide_06_current_overview.svg
"""
import os
import re
import sys
import base64
import argparse
from pathlib import Path
from xml.etree import ElementTree as ET
# Try to import PIL for getting image dimensions
try:
from PIL import Image
HAS_PIL = True
except ImportError:
HAS_PIL = False
print("[WARN] PIL not installed. Install with: pip install Pillow")
print(" Will try to use basic method for JPEG/PNG files.")
def get_image_dimensions_pil(image_path: str) -> tuple[int | None, int | None]:
"""Get image dimensions using PIL."""
try:
with Image.open(image_path) as img:
return img.width, img.height
except Exception as e:
print(f" [WARN] Cannot read image with PIL: {e}")
return None, None
def get_image_dimensions_basic(image_path: str) -> tuple[int | None, int | None]:
"""Get image dimensions using basic parsing without PIL."""
try:
with open(image_path, 'rb') as f:
data = f.read(64) # Read header information
# PNG
if data[:8] == b'\x89PNG\r\n\x1a\n':
w = int.from_bytes(data[16:20], 'big')
h = int.from_bytes(data[20:24], 'big')
return w, h
# JPEG
if data[:2] == b'\xff\xd8':
# Need to read full file to parse JPEG
with open(image_path, 'rb') as f:
f.seek(2)
while True:
marker = f.read(2)
if not marker or len(marker) < 2:
break
if marker[0] != 0xff:
break
m = marker[1]
# SOF0, SOF2 markers contain dimensions
if m in (0xC0, 0xC2):
f.read(3) # Skip length and precision
h = int.from_bytes(f.read(2), 'big')
w = int.from_bytes(f.read(2), 'big')
return w, h
elif m == 0xD9: # EOI
break
elif m == 0xD8: # SOI
continue
elif 0xD0 <= m <= 0xD7: # RST
continue
else:
length = int.from_bytes(f.read(2), 'big')
f.seek(length - 2, 1)
return None, None
except Exception as e:
print(f" [WARN] Cannot read image dimensions: {e}")
return None, None
def get_image_dimensions_from_base64(data_uri: str) -> tuple[int | None, int | None]:
"""Get image dimensions from a Base64 data URI."""
import io
try:
# Parse data URI
match = re.match(r'data:image/(\w+);base64,(.+)', data_uri)
if not match:
return None, None
img_format = match.group(1)
b64_data = match.group(2)
img_bytes = base64.b64decode(b64_data)
if HAS_PIL:
with Image.open(io.BytesIO(img_bytes)) as img:
return img.width, img.height
else:
# Use basic method
if img_bytes[:8] == b'\x89PNG\r\n\x1a\n':
w = int.from_bytes(img_bytes[16:20], 'big')
h = int.from_bytes(img_bytes[20:24], 'big')
return w, h
return None, None
except Exception as e:
print(f" [WARN] Cannot parse base64 image: {e}")
return None, None
def get_image_dimensions(href: str, svg_dir: str) -> tuple[int | None, int | None]:
"""Get image dimensions for either inline or external images."""
# Handle data URI
if href.startswith('data:'):
return get_image_dimensions_from_base64(href)
# Handle external files
if not os.path.isabs(href):
full_path = os.path.join(svg_dir, href)
else:
full_path = href
if not os.path.exists(full_path):
print(f" [WARN] Image not found: {href}")
return None, None
if HAS_PIL:
return get_image_dimensions_pil(full_path)
else:
return get_image_dimensions_basic(full_path)
def calculate_fitted_dimensions(
img_width: int,
img_height: int,
box_width: float,
box_height: float,
mode: str = 'meet',
) -> tuple[float, float, float, float]:
"""
Calculate the fitted dimensions for an image within a bounding box.
Args:
img_width, img_height: Original image dimensions
box_width, box_height: Container box dimensions
mode: 'meet' preserves aspect ratio and fully displays image (may have whitespace)
'slice' preserves aspect ratio and fully fills container (may crop)
Returns:
(new_width, new_height, offset_x, offset_y)
"""
img_ratio = img_width / img_height
box_ratio = box_width / box_height
if mode == 'meet':
# Fully display image, may have whitespace
if img_ratio > box_ratio:
# Image is wider, fit by width
new_width = box_width
new_height = box_width / img_ratio
else:
# Image is taller, fit by height
new_height = box_height
new_width = box_height * img_ratio
else: # slice
# Fully fill container, may crop
if img_ratio > box_ratio:
# Image is wider, fit by height
new_height = box_height
new_width = box_height * img_ratio
else:
# Image is taller, fit by width
new_width = box_width
new_height = box_width / img_ratio
# Center offset
offset_x = (box_width - new_width) / 2
offset_y = (box_height - new_height) / 2
return new_width, new_height, offset_x, offset_y
def fix_image_aspect_in_svg(svg_path: str, dry_run: bool = False, verbose: bool = True) -> int:
"""
Fix image aspect ratios in an SVG file.
Args:
svg_path: SVG file path
dry_run: Whether to only preview without modifying
verbose: Whether to output detailed information
Returns:
Number of images fixed
"""
svg_dir = os.path.dirname(os.path.abspath(svg_path))
with open(svg_path, 'r', encoding='utf-8') as f:
content = f.read()
# Register SVG namespaces
namespaces = {
'': 'http://www.w3.org/2000/svg',
'xlink': 'http://www.w3.org/1999/xlink',
'svg': 'http://www.w3.org/2000/svg',
'sodipodi': 'http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd',
'inkscape': 'http://www.inkscape.org/namespaces/inkscape',
}
for prefix, uri in namespaces.items():
if prefix:
ET.register_namespace(prefix, uri)
else:
ET.register_namespace('', uri)
try:
tree = ET.parse(svg_path)
root = tree.getroot()
except ET.ParseError as e:
print(f" [ERROR] Cannot parse SVG: {e}")
return 0
# Find all image elements
fixed_count = 0
# Check image elements with and without namespace
for ns_prefix in ['', '{http://www.w3.org/2000/svg}']:
for image_elem in root.iter(f'{ns_prefix}image'):
# Get href attribute (supports xlink:href and href)
href = image_elem.get('{http://www.w3.org/1999/xlink}href')
if href is None:
href = image_elem.get('href')
if href is None:
continue
# Get current dimensions and position
try:
x = float(image_elem.get('x', 0))
y = float(image_elem.get('y', 0))
width = float(image_elem.get('width', 0))
height = float(image_elem.get('height', 0))
except (ValueError, TypeError):
continue
if width <= 0 or height <= 0:
continue
# Get preserveAspectRatio
par = image_elem.get('preserveAspectRatio', 'xMidYMid meet')
# Parse preserveAspectRatio
# Format: <align> [<meetOrSlice>]
# e.g.: xMidYMid meet, xMidYMid slice, none
par_parts = par.split()
align = par_parts[0] if par_parts else 'xMidYMid'
meet_or_slice = par_parts[1] if len(par_parts) > 1 else 'meet'
if align == 'none':
# If none, no fix needed
continue
# Get original image dimensions
img_width, img_height = get_image_dimensions(href, svg_dir)
if img_width is None or img_height is None:
continue
# Calculate fitted dimensions
mode = 'slice' if meet_or_slice == 'slice' else 'meet'
new_width, new_height, offset_x, offset_y = calculate_fitted_dimensions(
img_width, img_height, width, height, mode
)
# Check if modification is needed
tolerance = 0.5 # Allowed tolerance
if (abs(new_width - width) < tolerance and
abs(new_height - height) < tolerance):
# Dimensions are already correct, no modification needed
continue
if verbose:
img_name = os.path.basename(href.split('?')[0][:50] if not href.startswith('data:') else '[base64]')
print(f" [FIX] {img_name}")
print(f" Original image: {img_width}x{img_height} (ratio: {img_width/img_height:.3f})")
print(f" Original box: {width}x{height} @ ({x}, {y})")
print(f" New box: {new_width:.1f}x{new_height:.1f} @ ({x + offset_x:.1f}, {y + offset_y:.1f})")
if not dry_run:
# Update attributes
image_elem.set('x', f'{x + offset_x:.1f}')
image_elem.set('y', f'{y + offset_y:.1f}')
image_elem.set('width', f'{new_width:.1f}')
image_elem.set('height', f'{new_height:.1f}')
# Remove preserveAspectRatio since dimensions are now correct
if 'preserveAspectRatio' in image_elem.attrib:
del image_elem.attrib['preserveAspectRatio']
fixed_count += 1
if not dry_run and fixed_count > 0:
# Save modifications
tree.write(svg_path, encoding='unicode', xml_declaration=True)
return fixed_count
def main() -> None:
"""Run the CLI entry point."""
parser = argparse.ArgumentParser(
description='Fix image aspect ratios in SVG to prevent stretching when PowerPoint converts to shapes',
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog='''
Examples:
%(prog)s slide_01.svg # Process a single file
%(prog)s *.svg # Process all SVGs in current directory
%(prog)s --dry-run *.svg # Preview files to be processed
%(prog)s projects/xxx/svg_output/*.svg # Process project directory
'''
)
parser.add_argument('files', nargs='+', help='SVG files to process')
parser.add_argument('--dry-run', '-n', action='store_true',
help='Only show which images would be fixed, without modifying files')
parser.add_argument('--quiet', '-q', action='store_true',
help='Quiet mode, reduce output')
args = parser.parse_args()
if args.dry_run:
print("[INFO] Preview mode: only showing what would be modified, no files will be changed\n")
total_fixed = 0
total_files = 0
for svg_file in args.files:
if not os.path.exists(svg_file):
if not args.quiet:
print(f"[ERROR] File not found: {svg_file}")
continue
if not svg_file.endswith('.svg'):
if not args.quiet:
print(f"[SKIP] Skipping non-SVG file: {svg_file}")
continue
if not args.quiet:
print(f"\n[FILE] {os.path.basename(svg_file)}")
fixed = fix_image_aspect_in_svg(svg_file, dry_run=args.dry_run, verbose=not args.quiet)
if fixed > 0:
total_fixed += fixed
total_files += 1
elif not args.quiet:
print(" No fix needed")
print(f"\n{'=' * 50}")
if args.dry_run:
print(f"[PREVIEW] Will fix {total_fixed} image(s) in {total_files} file(s)")
else:
print(f"[DONE] Fixed {total_fixed} image(s) in {total_files} file(s)")
if __name__ == '__main__':
main()

View File

@ -0,0 +1,740 @@
import os
import sys
import re
import argparse
from xml.etree import ElementTree as ET
SVG_NS = "http://www.w3.org/2000/svg"
NSMAP = {"svg": SVG_NS}
# Ensure pretty element names without ns0 prefix on write
ET.register_namespace("", SVG_NS)
TEXT_STYLE_ATTRS = {
# common text styling
"font-family",
"font-size",
"font-weight",
"font-style",
"font-variant",
"font-stretch",
"letter-spacing",
"word-spacing",
"kerning",
"text-anchor",
"text-decoration",
"dominant-baseline",
"writing-mode",
"direction",
# color/paint
"fill",
"fill-opacity",
"stroke",
"stroke-width",
"stroke-opacity",
"opacity",
"paint-order",
# transforms/filters
"transform",
"clip-path",
"filter",
}
num_re = re.compile(r"^[\s,]*([+-]?(?:\d+\.?\d*|\d*\.\d+))")
def parse_first_number(val: str | None) -> float | None:
"""Parse the first numeric token from an SVG attribute value."""
if val is None:
return None
m = num_re.match(val)
if not m:
return None
try:
return float(m.group(1))
except ValueError:
return None
def format_number(n: float | None) -> str | None:
"""Format a float for compact SVG attribute output."""
if n is None:
return None
if abs(n - round(n)) < 1e-6:
return str(int(round(n)))
# Trim trailing zeros
s = f"{n:.6f}".rstrip("0").rstrip(".")
return s
def parse_style(style_str: str | None) -> dict[str, str]:
"""Parse an inline SVG style string into a mapping."""
out: dict[str, str] = {}
if not style_str:
return out
# split by ; and then :
for chunk in style_str.split(";"):
if not chunk.strip():
continue
if ":" in chunk:
k, v = chunk.split(":", 1)
out[k.strip()] = v.strip()
return out
def style_to_string(style_map: dict[str, str]) -> str:
"""Serialize a style mapping back into an inline SVG style string."""
if not style_map:
return ""
return ";".join(f"{k}:{v}" for k, v in style_map.items())
def merge_styles(parent_style: str | None, child_style: str | None) -> str:
"""Merge parent and child inline styles, preferring child values."""
p = parse_style(parent_style)
c = parse_style(child_style)
p.update(c) # child overrides
return style_to_string(p)
def get_attr(elem: ET.Element | None, name: str, default: str | None = None) -> str | None:
"""Read an attribute from an element with a default fallback."""
return elem.get(name) if elem is not None and name in elem.attrib else default
def compute_line_positions(
text_el: ET.Element,
tspan_el: ET.Element,
cur_x: float | None,
cur_y: float | None,
) -> tuple[float | None, float | None]:
"""
Compute absolute x,y for a tspan based on parent <text> current baseline and tspan's x/y/dx/dy.
Returns (new_x, new_y).
"""
del text_el
# Prefer explicit x/y on tspan
t_x_attr = get_attr(tspan_el, "x")
t_y_attr = get_attr(tspan_el, "y")
t_dx_attr = get_attr(tspan_el, "dx")
t_dy_attr = get_attr(tspan_el, "dy")
if t_x_attr is not None:
nx = parse_first_number(t_x_attr)
elif t_dx_attr is not None:
dx = parse_first_number(t_dx_attr) or 0.0
nx = (cur_x or 0.0) + dx
else:
nx = cur_x
if t_y_attr is not None:
ny = parse_first_number(t_y_attr)
elif t_dy_attr is not None:
dy = parse_first_number(t_dy_attr) or 0.0
ny = (cur_y or 0.0) + dy
else:
ny = cur_y
return nx, ny
def collect_text_content(el: ET.Element) -> str:
"""Collect all text content from an element subtree."""
# Gather all text within the element (flatten nested tspans if any)
parts = []
for s in el.itertext():
if s:
parts.append(s)
return "".join(parts)
def copy_text_attrs(
src_el: ET.Element,
dst_el: ET.Element,
exclude: set[str] | None = None,
) -> None:
"""Copy shared text styling attributes between SVG text elements."""
exclude = exclude or set()
# Copy style string first
if "style" in src_el.attrib and "style" not in exclude:
dst_el.set("style", src_el.attrib["style"])
for k in TEXT_STYLE_ATTRS:
if k in exclude:
continue
v = src_el.get(k)
if v is not None:
dst_el.set(k, v)
# xml:space preservation
xml_space = src_el.get("{http://www.w3.org/XML/1998/namespace}space")
if xml_space is not None and "{http://www.w3.org/XML/1998/namespace}space" not in exclude:
dst_el.set("{http://www.w3.org/XML/1998/namespace}space", xml_space)
PARAGRAPH_MARK_ATTR = "data-paragraph-line-height"
PARAGRAPH_SPACE_BEFORE_ATTR = "data-paragraph-space-before"
# Marks a line-break tspan as a SOFT break inside the current paragraph
# (SVG used dy to simulate text wrapping; the downstream converter should
# merge its runs into the previous <a:p> rather than start a new one).
PARAGRAPH_SOFT_BREAK_ATTR = "data-paragraph-soft-break"
# Tolerance for detecting "base line-height" vs "paragraph gap": dy values
# within ±DY_TOLERANCE_PX of each other are considered the same line-height.
DY_TOLERANCE_PX = 0.5
# Cap on dy / base ratio. Anything beyond this (e.g. a 5x gap) is rejected
# as a real section break that shouldn't merge into one text frame.
MAX_DY_MULTIPLIER = 3.0
def _tspan_has_positional_descendant(tspan: ET.Element) -> bool:
"""Return True if any nested tspan inside this one carries x/y/dy."""
for child in list(tspan):
if child.tag != f"{{{SVG_NS}}}tspan":
continue
for k in ("x", "y", "dy"):
if child.get(k) is not None:
return True
if _tspan_has_positional_descendant(child):
return True
return False
def _classify_paragraph_block(
text_el: ET.Element,
is_svg_tag,
is_new_line_tspan,
) -> tuple[float, list[float], list[bool], list[list[ET.Element]]] | None:
"""Detect a mergeable paragraph block.
Returns ``(base_line_height_px, extra_space_before_px_per_line,
is_soft_break_per_line, line_groups)`` if the children form a mergeable paragraph.
Each list has one entry per direct-child tspan (line):
- extra_space_before_px_per_line[i]: extra px above base line-height,
used as <a:spcBef> on the downstream <a:p>. First entry is 0.
- is_soft_break_per_line[i]: True if this line should merge into the
previous <a:p> (SVG dy was simulating word-wrap); False if it starts
a fresh <a:p>. First entry is always False (paragraph head).
Conditions (all must hold):
- No leading text directly under <text>.
- Every direct child is a <tspan>.
- Every logical line starts with a new-line tspan.
- Direct-child inline formatting tspans without x/y/dy are allowed only
after a line starts; they are normalized into the previous line.
- First line-break tspan has dy == 0 (or no dy).
- All subsequent line-break tspans use positive dy (no <y>).
- dy values cluster around a single minimum "base line-height";
any larger dy must be MAX_DY_MULTIPLIER × base. Anything larger
is treated as a section break and rejected.
- Every line-break tspan that sets x repeats the parent <text>'s x.
- No nested tspan inside any line carries x/y/dy.
"""
base_x = parse_first_number(get_attr(text_el, "x"))
if (text_el.text or "").strip():
return None
direct_tspans = [c for c in list(text_el) if is_svg_tag(c, "tspan")]
direct_children_all = [c for c in list(text_el)]
if len(direct_tspans) < 2:
return None
if len(direct_tspans) != len(direct_children_all):
return None
line_groups: list[list[ET.Element]] = []
for tspan in direct_tspans:
if is_new_line_tspan(tspan):
line_groups.append([tspan])
else:
if not line_groups:
return None
if _tspan_has_positional_descendant(tspan):
return None
line_groups[-1].append(tspan)
if len(line_groups) < 2:
return None
# First pass: validate per-line structural rules and collect dy values.
dy_values: list[float] = [] # one per line (0 for first)
for idx, group in enumerate(line_groups):
tspan = group[0]
t_y = get_attr(tspan, "y")
if t_y is not None:
return None
t_x_raw = get_attr(tspan, "x")
if t_x_raw is not None:
t_x = parse_first_number(t_x_raw)
if base_x is None or t_x is None or abs(t_x - base_x) > 1e-6:
return None
t_dy_raw = get_attr(tspan, "dy")
t_dy = parse_first_number(t_dy_raw) if t_dy_raw is not None else None
if idx == 0:
if t_dy is not None and abs(t_dy) > 1e-6:
return None
dy_values.append(0.0)
else:
if t_dy is None or t_dy <= 0:
return None
dy_values.append(t_dy)
if _tspan_has_positional_descendant(tspan):
return None
# Second pass: pick the base line-height as the minimum positive dy and
# express each line's dy as base + extra space-before.
positive_dys = [d for d in dy_values[1:] if d > 0]
if not positive_dys:
return None
base = min(positive_dys)
extras: list[float] = [0.0] # first line never has space-before
soft_breaks: list[bool] = [False] # first line starts a paragraph
for d in dy_values[1:]:
if d + DY_TOLERANCE_PX < base:
return None # below base — line overlap, not a paragraph
if d > base * MAX_DY_MULTIPLIER + DY_TOLERANCE_PX:
return None # gap too large — treat as section break
extra = d - base
if extra < 0:
extra = 0.0
# dy at the base line-height = soft break (SVG was simulating wrap);
# dy strictly greater than base = hard paragraph break.
is_soft = abs(extra) <= DY_TOLERANCE_PX
extras.append(0.0 if is_soft else extra)
soft_breaks.append(is_soft)
return base, extras, soft_breaks, line_groups
def _emit_mergeable_paragraph(
text_el: ET.Element,
base_dy: float,
extras: list[float],
soft_breaks: list[bool],
line_groups: list[list[ET.Element]],
) -> None:
"""Rewrite text_el in place so it stays a single <text> with paragraph rows.
The base line-height goes on the parent <text> via PARAGRAPH_MARK_ATTR.
Each direct-child tspan is normalized: x/y/dy stripped; inline-run
styling and nested tspans are preserved. Per-tspan attrs:
- PARAGRAPH_SOFT_BREAK_ATTR="1" on tspans that should be appended to
the previous <a:p> downstream (SVG used dy to simulate wrap)
- PARAGRAPH_SPACE_BEFORE_ATTR on tspans that open a new paragraph
with an extra gap (omitted when 0)
"""
text_el.set(PARAGRAPH_MARK_ATTR, format_number(base_dy))
# Normalize authoring variants before the downstream converter reads the
# paragraph: a line-break tspan may be followed by direct-child inline
# formatting tspans. Move those inline runs under the line-break tspan so
# every direct child of <text> is one logical visual line.
normalized_lines: list[ET.Element] = []
for group in line_groups:
line = group[0]
for inline_tspan in group[1:]:
try:
text_el.remove(inline_tspan)
except ValueError:
pass
if inline_tspan.tail and not inline_tspan.tail.strip():
inline_tspan.tail = None
line.append(inline_tspan)
normalized_lines.append(line)
for child in list(text_el):
if child not in normalized_lines:
text_el.remove(child)
extras_iter = iter(extras)
soft_iter = iter(soft_breaks)
for tspan in normalized_lines:
for k in ("x", "y", "dy"):
if k in tspan.attrib:
del tspan.attrib[k]
try:
extra = next(extras_iter)
soft = next(soft_iter)
except StopIteration:
extra = 0.0
soft = False
if soft:
tspan.set(PARAGRAPH_SOFT_BREAK_ATTR, "1")
elif extra > 1e-6:
tspan.set(PARAGRAPH_SPACE_BEFORE_ATTR, format_number(extra))
def flatten_text_with_tspans(
tree: ET.ElementTree,
merge_paragraphs: bool = False,
) -> bool:
"""Flatten multi-line tspan text into independent text nodes when needed.
When ``merge_paragraphs`` is True, mergeable paragraph blocks (same x,
dy clustered around one base line-height) are kept as a single <text>
so downstream conversion emits one editable PowerPoint text frame
with multiple <a:p>. Default False preserves the original behavior:
every line-break tspan becomes its own <text>, matching the SVG's
pixel-fidelity contract.
"""
root = tree.getroot()
parent_map = {c: p for p in root.iter() for c in p}
changed = False
def is_svg_tag(el: ET.Element, name: str) -> bool:
return el.tag == f"{{{SVG_NS}}}{name}"
def is_new_line_tspan(tspan: ET.Element) -> bool:
"""Determine whether a tspan represents a new line (has its own y or non-zero dy)."""
t_dy_attr = get_attr(tspan, "dy")
t_y_attr = get_attr(tspan, "y")
t_x_attr = get_attr(tspan, "x")
dy_val = parse_first_number(t_dy_attr) if t_dy_attr is not None else None
# Has its own y attribute, or has non-zero dy, or has its own x attribute (indicating a new line)
if t_y_attr is not None:
return True
if dy_val is not None and dy_val != 0:
return True
# If tspan has an x attribute and there are preceding sibling tspans, treat it as a new line
if t_x_attr is not None:
return True
return False
# Collect candidates first to avoid modifying while iterating
candidates = []
for el in root.iter():
if is_svg_tag(el, "text"):
has_tspan_child = any(is_svg_tag(c, "tspan") for c in list(el))
if has_tspan_child:
candidates.append(el)
for text_el in candidates:
parent = parent_map.get(text_el)
if parent is None:
continue
# First check whether any tspan needs flattening (dy != 0 or has its own y attribute)
needs_flatten = False
for child in list(text_el):
if not is_svg_tag(child, "tspan"):
continue
if is_new_line_tspan(child):
needs_flatten = True
break
# If no tspan needs a line break, skip the entire text element
if not needs_flatten:
continue
# Paragraph fast-path (opt-in via merge_paragraphs=True): if the
# children form a mergeable paragraph (same x, dy clustered around
# one base line-height with optional paragraph gaps, no nested
# positional tspans), keep as one <text> and let the downstream
# converter emit multiple <a:p> runs. When disabled, every tspan
# gets its own independent <text> so the SVG's exact line layout
# is preserved in PowerPoint.
if merge_paragraphs:
paragraph = _classify_paragraph_block(text_el, is_svg_tag, is_new_line_tspan)
if paragraph is not None:
base_dy, extras, soft_breaks, line_groups = paragraph
_emit_mergeable_paragraph(text_el, base_dy, extras, soft_breaks, line_groups)
changed = True
continue
base_x = parse_first_number(get_attr(text_el, "x")) or 0.0
base_y = parse_first_number(get_attr(text_el, "y")) or 0.0
cur_x, cur_y = base_x, base_y
new_texts = []
# Collect tspan elements belonging to the same line
current_line_tspans = []
current_line_lead_text = None
# Leading text directly under <text>
lead_text = (text_el.text or "").strip()
if lead_text:
current_line_lead_text = lead_text
for idx, child in enumerate(list(text_el)):
if not is_svg_tag(child, "tspan"):
continue
content = collect_text_content(child)
# Check whether this tspan starts a new line
if is_new_line_tspan(child):
# Save previously accumulated same-line tspans first
if current_line_tspans or current_line_lead_text:
ne = _create_text_element_from_line(
text_el, current_line_lead_text, current_line_tspans, cur_x, cur_y
)
new_texts.append(ne)
current_line_tspans = []
current_line_lead_text = None
# Update position
nx, ny = compute_line_positions(text_el, child, cur_x, cur_y)
cur_x, cur_y = nx, ny
# If content is not empty, add to the current line
if content.strip():
current_line_tspans.append(child)
# Process the last line
if current_line_tspans or current_line_lead_text:
ne = _create_text_element_from_line(
text_el, current_line_lead_text, current_line_tspans, cur_x, cur_y
)
new_texts.append(ne)
if new_texts:
# Replace original <text> with the list of new <text> nodes
try:
idx = list(parent).index(text_el)
except ValueError:
idx = None
# Insert in place to preserve drawing order
for i, ne in enumerate(new_texts):
if idx is not None:
parent.insert(idx + i, ne)
else:
parent.append(ne)
# Remove the original <text>
parent.remove(text_el)
changed = True
return changed
def _has_tspan_children(elem: ET.Element) -> bool:
"""Return True if elem contains any nested <tspan> children (inline runs)."""
return any(c.tag == f"{{{SVG_NS}}}tspan" for c in list(elem))
def _copy_inline_tspan(src: ET.Element, strip_line_attrs: bool) -> ET.Element:
"""Deep-copy a tspan as an inline run, preserving nested tspan structure, head text, and tail text.
When strip_line_attrs is True, x/y/dy on the copied tspan are dropped because the
enclosing <text> now positions the line. dx is preserved (safe inline kerning).
Nested tspans are copied recursively without stripping (they are already inline-only).
"""
new = ET.Element(f"{{{SVG_NS}}}tspan")
for k, v in src.attrib.items():
if strip_line_attrs and k in ("x", "y", "dy"):
continue
new.set(k, v)
new.text = src.text
for child in list(src):
if child.tag == f"{{{SVG_NS}}}tspan":
new.append(_copy_inline_tspan(child, strip_line_attrs=False))
new.tail = src.tail
return new
def _create_text_element_from_line(
text_el: ET.Element,
lead_text: str | None,
tspans: list[ET.Element],
x: float | None,
y: float | None,
) -> ET.Element:
"""
Create a text element from a line's content (may contain leading text and multiple tspans).
If there is only one tspan with no nested tspan children and no leading text, the line
collapses to a plain <text>...</text>. Otherwise the tspan structure (including any
nested inline tspans) is preserved so per-run formatting survives the flatten step.
"""
ne = ET.Element(f"{{{SVG_NS}}}text")
# Copy attrs from parent <text>
copy_text_attrs(text_el, ne, exclude={"x", "y"})
ne.set("x", format_number(x))
ne.set("y", format_number(y))
# Transform
p_tf = text_el.get("transform")
if p_tf:
ne.set("transform", p_tf)
# Compact path: a single tspan with no nested inline runs collapses to <text>text</text>
if not lead_text and len(tspans) == 1 and not _has_tspan_children(tspans[0]):
tspan = tspans[0]
content = collect_text_content(tspan)
# Merge style
merged_style = merge_styles(text_el.get("style"), tspan.get("style"))
if merged_style:
ne.set("style", merged_style)
# Override specific attributes from tspan
for attr in TEXT_STYLE_ATTRS:
cv = tspan.get(attr)
if cv is not None:
ne.set(attr, cv)
# Combine transform
c_tf = tspan.get("transform")
if p_tf and c_tf:
ne.set("transform", f"{p_tf} {c_tf}")
elif c_tf:
ne.set("transform", c_tf)
ne.text = content
else:
# Preserve tspan structure, including nested inline tspans and tail text
if lead_text:
ne.text = lead_text
for tspan in tspans:
ne.append(_copy_inline_tspan(tspan, strip_line_attrs=True))
return ne
def process_svg_file(
src_path: str,
dst_path: str,
merge_paragraphs: bool = False,
) -> bool:
"""Flatten eligible tspan lines in one SVG file."""
try:
tree = ET.parse(src_path)
except ET.ParseError as e:
print(f"[WARN] Failed to parse {src_path}: {e}")
return False
changed = flatten_text_with_tspans(tree, merge_paragraphs=merge_paragraphs)
# Ensure destination directory exists
os.makedirs(os.path.dirname(dst_path), exist_ok=True)
# Write out XML without XML declaration to mimic input style
tree.write(dst_path, encoding="utf-8", xml_declaration=False, method="xml")
return changed
def _compute_default_out_base(inp: str) -> str:
"""Compute default output path for directory or file input."""
if os.path.isdir(inp):
# Default: if input ends with svg_output, use sibling svg_output_flattext;
# otherwise append _flattext to the directory name at the same level.
head, tail = os.path.split(os.path.normpath(inp))
if tail == "svg_output":
return os.path.join(head, "svg_output_flattext")
return inp.rstrip("/\\") + "_flattext"
else:
base, ext = os.path.splitext(inp)
return base + "_flattext" + ext
def _interactive_get_paths() -> tuple[str | None, str | None]:
"""
Interactive mode: prompt the user for input path (SVG file or directory)
and optional output path. Returns (inp, out_base) or (None, None) if cancelled.
"""
print("[Interactive mode] No arguments provided; running interactively.")
print("Please enter the path to process (SVG file or directory containing SVGs).")
print("Enter q to quit.\n")
while True:
raw = input("Input path (file/dir): ").strip()
if raw.lower() in {"q", "quit", "exit"} or raw == "":
return None, None
inp = os.path.expanduser(raw)
if os.path.exists(inp):
break
print("Path does not exist. Please re-enter or enter q to quit.")
default_out = _compute_default_out_base(inp)
if os.path.isdir(inp):
prompt = f"Output directory [default: {default_out}]: "
else:
prompt = f"Output file [default: {default_out}]: "
raw_out = input(prompt).strip()
out_base = os.path.expanduser(raw_out) if raw_out else default_out
return inp, out_base
def main() -> None:
"""Run the CLI entry point."""
# CLI parsing with optional interactive mode
parser = argparse.ArgumentParser(
description="Flatten <tspan> lines into multiple <text> nodes for better compatibility.",
add_help=True,
)
parser.add_argument("input", nargs="?", help="Input path: SVG file or directory")
parser.add_argument("output", nargs="?", help="Optional output file/dir")
parser.add_argument(
"-i",
"--interactive",
action="store_true",
help="Run in interactive prompt mode to input paths",
)
parser.add_argument(
"--merge-paragraphs",
action="store_true",
default=False,
help=(
"Opt-in: merge mergeable paragraph blocks (same x, dy clustered "
"around one base line-height) into a single <text> annotated for "
"downstream multi-<a:p> conversion. Default off — every line-break "
"tspan becomes its own <text>, preserving SVG pixel fidelity."
),
)
args = parser.parse_args()
if args.interactive or not args.input:
inp, out_base = _interactive_get_paths()
if not inp:
print("Cancelled. Usage: python3 scripts/svg_finalize/flatten_tspan.py <input_dir_or_svg> [output_dir]")
sys.exit(0)
else:
inp = args.input
out_base = args.output
if os.path.isdir(inp):
# If output base not provided, create a sibling folder named svg_output_flattext for svg_output
if out_base is None:
out_base = _compute_default_out_base(inp)
total = 0
changed_count = 0
out_base_abs = os.path.abspath(out_base)
for root, dirs, files in os.walk(inp):
# Avoid recursing into the output directory when it lives under input
dirs[:] = [d for d in dirs if os.path.abspath(os.path.join(root, d)) != out_base_abs]
rel_root = os.path.relpath(root, inp)
for f in files:
if not f.lower().endswith(".svg"):
continue
src = os.path.join(root, f)
dst = os.path.join(out_base, rel_root, f) if rel_root != "." else os.path.join(out_base, f)
total += 1
changed = process_svg_file(src, dst, merge_paragraphs=args.merge_paragraphs)
if changed:
changed_count += 1
print(f"Processed {total} SVG(s). With <tspan> flattened: {changed_count}.")
print(f"Output written to: {out_base}")
else:
src = inp
if out_base is None:
out_base = _compute_default_out_base(src)
changed = process_svg_file(src, out_base, merge_paragraphs=args.merge_paragraphs)
print(f"Written: {out_base} (flattened: {changed})")
if __name__ == "__main__":
main()

View File

@ -0,0 +1,337 @@
#!/usr/bin/env python3
"""
PPT Master - SVG Rounded Rectangle to Path Tool
Solves the issue of rounded corners being lost when using "Convert to Shape" in PowerPoint:
Converts <rect> elements with rx/ry to equivalent <path> elements.
Usage:
python3 scripts/svg_finalize/svg_rect_to_path.py <SVG file or directory>
python3 scripts/svg_finalize/svg_rect_to_path.py <project_path> -s output
python3 scripts/svg_finalize/svg_rect_to_path.py <project_path> -s final -o svg_rounded
Examples:
python3 scripts/svg_finalize/svg_rect_to_path.py examples/ppt169_demo
python3 scripts/svg_finalize/svg_rect_to_path.py examples/ppt169_demo/svg_output/01_cover.svg
Output:
- Directory mode: outputs to svg_rounded/ subdirectory
- File mode: outputs to <filename>_rounded.svg
"""
import sys
import re
import argparse
from pathlib import Path
from typing import Any, Tuple
from xml.etree import ElementTree as ET
def rect_to_rounded_path(
x: float,
y: float,
width: float,
height: float,
rx: float,
ry: float,
) -> str:
"""
Convert a rounded rectangle to an SVG path string.
Uses elliptical arc commands to draw rounded corners.
"""
# Limit corner radius to half of width/height
rx = min(rx, width / 2)
ry = min(ry, height / 2)
# Calculate key points
x1 = x + rx
x2 = x + width - rx
y1 = y + ry
y2 = y + height - ry
# Build path
path = (
f"M{x1:.2f},{y:.2f} "
f"H{x2:.2f} "
f"A{rx:.2f},{ry:.2f} 0 0 1 {x + width:.2f},{y1:.2f} "
f"V{y2:.2f} "
f"A{rx:.2f},{ry:.2f} 0 0 1 {x2:.2f},{y + height:.2f} "
f"H{x1:.2f} "
f"A{rx:.2f},{ry:.2f} 0 0 1 {x:.2f},{y2:.2f} "
f"V{y1:.2f} "
f"A{rx:.2f},{ry:.2f} 0 0 1 {x1:.2f},{y:.2f} "
f"Z"
)
# Clean up excess decimals
path = re.sub(r'\.00(?=\s|,|[A-Za-z]|$)', '', path)
return path
def parse_float(val: str, default: float = 0.0) -> float:
"""Safely parse a float value."""
if not val:
return default
try:
# Remove units
val = re.sub(r'(px|pt|em|%|rem)$', '', val.strip())
return float(val)
except ValueError:
return default
def process_svg(content: str, verbose: bool = False) -> Tuple[str, int]:
"""
Process SVG content, converting rounded rectangles to paths.
Returns (processed content, conversion count).
"""
converted_count = 0
# Save original XML declaration
xml_declaration = ''
if content.strip().startswith('<?xml'):
match = re.match(r'(<\?xml[^?]*\?>)', content)
if match:
xml_declaration = match.group(1) + '\n'
# Register SVG namespaces
ET.register_namespace('', 'http://www.w3.org/2000/svg')
ET.register_namespace('xlink', 'http://www.w3.org/1999/xlink')
try:
root = ET.fromstring(content)
except ET.ParseError as e:
if verbose:
print(f" XML parse error: {e}")
return content, 0
# Get default namespace
ns = ''
if root.tag.startswith('{'):
ns = root.tag.split('}')[0] + '}'
def get_tag_name(tag: str) -> str:
"""Get tag name without namespace."""
if tag.startswith('{'):
return tag.split('}')[1]
return tag
def process_element(elem: ET.Element) -> None:
"""Process a single element."""
nonlocal converted_count
tag_name = get_tag_name(elem.tag)
# Process rounded rectangles
if tag_name == 'rect':
rx = parse_float(elem.get('rx', '0'))
ry = parse_float(elem.get('ry', '0'))
# If only one is specified, the other takes the same value
if rx == 0 and ry > 0:
rx = ry
elif ry == 0 and rx > 0:
ry = rx
if rx > 0 or ry > 0:
x = parse_float(elem.get('x', '0'))
y = parse_float(elem.get('y', '0'))
width = parse_float(elem.get('width', '0'))
height = parse_float(elem.get('height', '0'))
if width > 0 and height > 0:
# Generate path
path_d = rect_to_rounded_path(x, y, width, height, rx, ry)
# rect-specific attributes
rect_attrs = {'x', 'y', 'width', 'height', 'rx', 'ry'}
# Change element to path
elem.tag = ns + 'path' if ns else 'path'
elem.set('d', path_d)
# Remove rect-specific attributes
for attr in rect_attrs:
if attr in elem.attrib:
del elem.attrib[attr]
converted_count += 1
if verbose:
print(f" Converted rounded rect: rx={rx}, ry={ry}")
# Recursively process child elements
for child in elem:
process_element(child)
# Process all elements
process_element(root)
# Convert back to string
result = ET.tostring(root, encoding='unicode')
# Add XML declaration (if originally present)
if xml_declaration:
result = xml_declaration + result
return result, converted_count
def process_svg_file(input_path: Path, output_path: Path, verbose: bool = False) -> tuple[bool, int]:
"""Process a single SVG file."""
try:
with open(input_path, 'r', encoding='utf-8') as f:
content = f.read()
processed, count = process_svg(content, verbose)
# Ensure output directory exists
output_path.parent.mkdir(parents=True, exist_ok=True)
with open(output_path, 'w', encoding='utf-8') as f:
f.write(processed)
return True, count
except Exception as e:
if verbose:
print(f" Error: {e}")
return False, 0
def find_svg_files(project_path: Path, source: str = 'output') -> tuple[list[Path], str]:
"""Find SVG files in a project."""
dir_map = {
'output': 'svg_output',
'final': 'svg_final',
'flat': 'svg_output_flattext',
'final_flat': 'svg_final_flattext',
}
dir_name = dir_map.get(source, source)
svg_dir = project_path / dir_name
if not svg_dir.exists():
if (project_path / 'svg_output').exists():
dir_name = 'svg_output'
svg_dir = project_path / dir_name
elif project_path.is_dir():
svg_dir = project_path
dir_name = project_path.name
if not svg_dir.exists():
return [], ''
return sorted(svg_dir.glob('*.svg')), dir_name
def main() -> None:
"""Run the CLI entry point."""
parser = argparse.ArgumentParser(
description='PPT Master - SVG Rounded Rectangle to Path Tool',
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog='''
Examples:
%(prog)s examples/ppt169_demo
%(prog)s examples/ppt169_demo -s final
%(prog)s examples/ppt169_demo/svg_output/01_cover.svg
What it does:
Converts <rect> elements with rx/ry to equivalent <path> elements.
Processed SVGs preserve rounded corners when using "Convert to Shape" in PowerPoint.
'''
)
parser.add_argument('path', type=str, help='SVG file or project directory path')
parser.add_argument('-s', '--source', type=str, default='output',
help='SVG source: output/final/flat/final_flat or subdirectory name (default: output)')
parser.add_argument('-o', '--output', type=str, default='svg_rounded',
help='Output directory name (default: svg_rounded)')
parser.add_argument('-v', '--verbose', action='store_true', help='Verbose output')
parser.add_argument('-q', '--quiet', action='store_true', help='Quiet mode')
args = parser.parse_args()
input_path = Path(args.path)
if not input_path.exists():
print(f"Error: Path not found: {input_path}")
sys.exit(1)
verbose = args.verbose and not args.quiet
quiet = args.quiet
if not quiet:
print("PPT Master - SVG Rounded Rectangle to Path Tool")
print("=" * 50)
total_converted = 0
if input_path.is_file() and input_path.suffix.lower() == '.svg':
# Single file mode
output_path = input_path.with_stem(input_path.stem + '_rounded')
if not quiet:
print(f" Input: {input_path}")
print(f" Output: {output_path}")
print()
success, count = process_svg_file(input_path, output_path, verbose)
total_converted = count
if success:
if not quiet:
print(f"[DONE] Saved: {output_path}")
else:
print(f"[FAIL] Processing failed")
sys.exit(1)
else:
# Directory/project mode
svg_files, source_dir = find_svg_files(input_path, args.source)
if not svg_files:
print("Error: No SVG files found")
sys.exit(1)
output_dir = input_path / args.output
if not quiet:
print(f" Project path: {input_path}")
print(f" SVG source: {source_dir}")
print(f" Output directory: {args.output}")
print(f" File count: {len(svg_files)}")
print()
success_count = 0
for i, svg_file in enumerate(svg_files, 1):
output_path = output_dir / svg_file.name
if verbose:
print(f" [{i}/{len(svg_files)}] {svg_file.name}")
success, count = process_svg_file(svg_file, output_path, verbose)
if success:
success_count += 1
total_converted += count
if not verbose and not quiet:
print(f" [{i}/{len(svg_files)}] {svg_file.name} OK")
else:
if not quiet:
print(f" [{i}/{len(svg_files)}] {svg_file.name} FAILED")
if not quiet:
print()
print(f"[DONE] Succeeded: {success_count}/{len(svg_files)}")
print(f" Output directory: {output_dir}")
# Show statistics
if not quiet:
print()
print(f"Conversion stats: rounded rect -> path: {total_converted}")
sys.exit(0)
if __name__ == '__main__':
main()

View File

@ -0,0 +1,168 @@
"""svg_preview.py: 用无头 Chrome/Edge 把 SVG 页渲成 PNG,供交付前肉眼/vision 验收。
SVG-first 管线里 SVG 就是视觉真相(导出的 pptx 与之 1:1),所以验收直接渲 SVG 最忠实
比渲最终 pptx 更早更准地暴露"标题层级 / 卡片过挤过空 / 文字掉色 / 节奏单调"这类观感问题
用法:
python svg_preview.py <project_dir> # 渲 <project_dir>/svg_output 全部页
python svg_preview.py <project_dir> --pages 1,3,5 # 只渲第 1/3/5 页(按文件排序)
python svg_preview.py <project_dir> -o <out_dir> # 指定 PNG 输出目录(默认 <project_dir>/preview)
python svg_preview.py <svg_dir_or_file> # 直接渲某个目录/单文件
约定:优先渲 <project_dir>/svg_output;没有则退而渲 <project_dir> 本身
依赖:本机装了 Chrome Edge(无需 pip )两者都没有则报错退出
产物默认 2x 超采样,够清晰看版面
"""
from __future__ import annotations
import argparse
import re
import subprocess
import sys
try: # zcbot: Windows GBK 控制台兼容,避免 emoji/© 等触发 UnicodeEncodeError
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
sys.stderr.reconfigure(encoding="utf-8", errors="replace")
except Exception:
pass
import tempfile
from pathlib import Path
_CHROME_CANDIDATES = [
r"C:\Program Files\Google\Chrome\Application\chrome.exe",
r"C:\Program Files (x86)\Google\Chrome\Application\chrome.exe",
r"C:\Program Files\Microsoft\Edge\Application\msedge.exe",
r"C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe",
]
def find_browser() -> str:
for c in _CHROME_CANDIDATES:
if Path(c).exists():
return c
import shutil
for name in ("chrome", "chrome.exe", "msedge", "msedge.exe"):
p = shutil.which(name)
if p:
return p
raise SystemExit(
"[fatal] 未找到 Chrome / Edge,无法渲染 SVG 预览。请安装其一,或用浏览器手动打开 svg_output/*.svg 验收。"
)
_VIEWBOX_RE = re.compile(r'viewBox\s*=\s*["\']\s*([\d.]+)\s+([\d.]+)\s+([\d.]+)\s+([\d.]+)\s*["\']')
_WH_RE = re.compile(r'\b(width|height)\s*=\s*["\']\s*([\d.]+)')
def _dims(svg_text: str) -> tuple[float, float]:
m = _VIEWBOX_RE.search(svg_text)
if m:
return float(m.group(3)), float(m.group(4))
w = h = None
for name, val in _WH_RE.findall(svg_text):
if name == "width":
w = float(val)
elif name == "height":
h = float(val)
return (w or 1280.0), (h or 720.0)
def _wrap_html(svg_path: Path, w: float, h: float) -> str:
# 内联引用本地 svg,固定画布尺寸、去边距,Chrome 截图即得整页
uri = svg_path.resolve().as_uri()
return (
"<!doctype html><html><head><meta charset='utf-8'><style>"
"html,body{margin:0;padding:0;background:#fff}"
f"img{{display:block;width:{w}px;height:{h}px}}"
"</style></head><body>"
f"<img src='{uri}'>"
"</body></html>"
)
def render(browser: str, svg_path: Path, out_png: Path, scale: float = 2.0) -> None:
svg_text = svg_path.read_text(encoding="utf-8", errors="ignore")
w, h = _dims(svg_text)
html = _wrap_html(svg_path, w, h)
with tempfile.NamedTemporaryFile("w", suffix=".html", delete=False, encoding="utf-8") as f:
f.write(html)
html_path = Path(f.name)
try:
out_png.parent.mkdir(parents=True, exist_ok=True)
subprocess.run(
[
browser, "--headless", "--disable-gpu", "--no-sandbox",
"--hide-scrollbars", "--force-device-scale-factor=%s" % scale,
"--window-size=%d,%d" % (round(w), round(h)),
"--default-background-color=FFFFFFFF",
"--screenshot=%s" % str(out_png),
html_path.as_uri(),
],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=60,
)
finally:
try:
html_path.unlink()
except OSError:
pass
def _collect(target: Path) -> tuple[list[Path], Path]:
"""返回 (svg 文件列表, 默认输出目录)。"""
if target.is_file() and target.suffix.lower() == ".svg":
return [target], target.parent / "preview"
# 目录:优先 svg_final(finalize 后图标/配图已内嵌,渲出来最忠实);
# 没 svg_final 就退而渲 svg_output(生成中验收,此时图标仍是占位符不显示)
if (target / "svg_final").is_dir() and any((target / "svg_final").glob("*.svg")):
svg_dir = target / "svg_final"
elif (target / "svg_output").is_dir():
svg_dir = target / "svg_output"
else:
svg_dir = target
files = sorted(svg_dir.glob("*.svg"))
default_out = target / "preview"
return files, default_out
def _select(files: list[Path], pages: str | None) -> list[Path]:
if not pages:
return files
idxs = []
for tok in pages.split(","):
tok = tok.strip()
if tok.isdigit():
idxs.append(int(tok) - 1)
return [files[i] for i in idxs if 0 <= i < len(files)]
def main() -> None:
ap = argparse.ArgumentParser(description="把 SVG 页渲成 PNG 供肉眼/vision 验收")
ap.add_argument("target", type=Path, help="project_dir / svg 目录 / 单个 .svg 文件")
ap.add_argument("--pages", default=None, help="只渲指定页,如 1,3,5(按文件排序)")
ap.add_argument("-o", "--out", type=Path, default=None, help="PNG 输出目录")
ap.add_argument("--scale", type=float, default=2.0, help="超采样倍数,默认 2")
args = ap.parse_args()
files, default_out = _collect(args.target)
if not files:
raise SystemExit(f"[fatal] 没找到 SVG:{args.target}")
files = _select(files, args.pages)
if not files:
raise SystemExit(f"[fatal] --pages {args.pages} 没选中任何页(共 {len(_collect(args.target)[0])} 页)")
out_dir = args.out or default_out
browser = find_browser()
print(f"[svg_preview] browser={browser}")
done = []
for svg in files:
png = out_dir / (svg.stem + ".png")
render(browser, svg, png, scale=args.scale)
if png.exists():
done.append(png)
print(f" [ok] {svg.name} -> {png}")
else:
print(f" [FAIL] {svg.name} 未生成 PNG")
print(f"[svg_preview] {len(done)}/{len(files)} 页渲好,输出目录:{out_dir}")
if __name__ == "__main__":
main()

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,22 @@
#!/usr/bin/env python3
"""PPT Master - SVG to PPTX Tool (thin wrapper).
Delegates to the svg_to_pptx package. Kept for CLI backward compatibility:
python3 scripts/svg_to_pptx.py <project_path> -s final
"""
import sys
try: # zcbot: Windows GBK 控制台兼容,避免 emoji/© 等触发 UnicodeEncodeError
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
sys.stderr.reconfigure(encoding="utf-8", errors="replace")
except Exception:
pass
from pathlib import Path
# Ensure the scripts directory is on sys.path so the package can be found
sys.path.insert(0, str(Path(__file__).resolve().parent))
from svg_to_pptx import main
if __name__ == '__main__':
main()

View File

@ -0,0 +1,17 @@
"""svg_to_pptx — SVG to PPTX conversion package.
Public API:
- main(): CLI entry point
- convert_svg_to_slide_shapes(): SVG -> DrawingML slide XML
- 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
__all__ = [
'main',
'convert_svg_to_slide_shapes',
'create_pptx_with_native_svg',
]

View File

@ -0,0 +1,275 @@
"""Animation sidecar loading, SVG target scanning, and validation."""
from __future__ import annotations
import json
import re
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from xml.etree import ElementTree as ET
from .drawingml_utils import SVG_NS
try:
from pptx_animations import ANIMATIONS, TRANSITIONS
except ImportError:
ANIMATIONS = {}
TRANSITIONS = {}
_NON_VISUAL_TAGS = frozenset(('defs', 'title', 'desc', 'metadata', 'style'))
_CHROME_ID_TOKENS = frozenset({
'background', 'bg',
'decoration', 'decorations', 'decor',
'header', 'footer',
'chrome', 'watermark',
'pagenumber', 'pagenum',
'page-number',
})
@dataclass(frozen=True)
class GroupTarget:
"""Top-level SVG group available for PowerPoint animation anchoring."""
slide: str
group_id: str
order: int
chrome: bool = False
def _tag_name(elem: ET.Element) -> str:
return elem.tag.replace(f'{{{SVG_NS}}}', '')
def is_chrome_id(elem_id: str | None) -> bool:
"""Return whether a group id represents static slide chrome."""
if not elem_id:
return False
lower = elem_id.lower()
compact = lower.replace('-', '').replace('_', '')
if compact in _CHROME_ID_TOKENS:
return True
tokens = re.split(r'[-_]', lower)
return any(t in _CHROME_ID_TOKENS for t in tokens if t)
def scan_svg_targets(svg_path: Path) -> tuple[list[GroupTarget], list[str]]:
"""Scan one SVG for top-level visible group ids and anonymous groups."""
root = ET.parse(str(svg_path)).getroot()
targets: list[GroupTarget] = []
anonymous_groups: list[str] = []
visual_index = 0
for child in root:
tag = _tag_name(child)
if tag in _NON_VISUAL_TAGS:
continue
visual_index += 1
if tag != 'g':
continue
group_id = child.get('id')
if not group_id:
anonymous_groups.append(f'{svg_path.stem}: top-level group #{visual_index}')
continue
targets.append(
GroupTarget(
slide=svg_path.stem,
group_id=group_id,
order=visual_index,
chrome=is_chrome_id(group_id),
)
)
return targets, anonymous_groups
def scan_project_targets(project_path: Path) -> tuple[dict[str, list[GroupTarget]], list[str]]:
"""Scan ``svg_output/*.svg`` for animation targets."""
svg_dir = project_path / 'svg_output'
targets_by_slide: dict[str, list[GroupTarget]] = {}
anonymous_groups: list[str] = []
if not svg_dir.is_dir():
return targets_by_slide, [f'svg_output directory not found: {svg_dir}']
for svg_path in sorted(svg_dir.glob('*.svg')):
targets, anonymous = scan_svg_targets(svg_path)
targets_by_slide[svg_path.stem] = targets
anonymous_groups.extend(anonymous)
return targets_by_slide, anonymous_groups
def default_config_path(project_path: Path) -> Path:
return project_path / 'animations.json'
def load_animation_config(project_path: Path, config_path: str | None = None) -> dict[str, Any] | None:
"""Load optional animation config; return ``None`` when absent."""
if config_path:
path = Path(config_path)
else:
path = default_config_path(project_path)
if config_path and not path.is_absolute():
path = project_path / path
if not path.exists():
return None
with open(path, 'r', encoding='utf-8') as f:
data = json.load(f)
if not isinstance(data, dict):
raise ValueError(f'Animation config must be a JSON object: {path}')
if data.get('version', 1) != 1:
raise ValueError(f'Unsupported animation config version: {data.get("version")}')
return data
def _valid_animation_effect(effect: str) -> bool:
return effect == 'none' or effect in ANIMATIONS or effect in ('auto', 'mixed', 'random')
def _valid_transition_effect(effect: str) -> bool:
return effect == 'none' or effect in TRANSITIONS
def validate_animation_config(
project_path: Path,
config: dict[str, Any] | None = None,
config_path: str | None = None,
) -> list[str]:
"""Validate sidecar references against current ``svg_output``."""
if config is None:
config = load_animation_config(project_path, config_path)
if not config:
return []
warnings: list[str] = []
targets_by_slide, anonymous_groups = scan_project_targets(project_path)
for item in anonymous_groups:
warnings.append(f'{item} has no id and cannot be customized in animations.json')
known_slides = set(targets_by_slide)
slides = config.get('slides', {})
if slides and not isinstance(slides, dict):
return ['animations.json field "slides" must be an object']
defaults = config.get('defaults', {})
if isinstance(defaults, dict):
_validate_scope_effects(defaults, 'defaults', warnings)
for slide_name, slide_cfg in (slides or {}).items():
if slide_name not in known_slides:
warnings.append(f'animations.json references missing slide: {slide_name}')
continue
if not isinstance(slide_cfg, dict):
warnings.append(f'animations.json slide "{slide_name}" must be an object')
continue
_validate_scope_effects(slide_cfg, f'slide "{slide_name}"', warnings)
known_groups = {target.group_id for target in targets_by_slide[slide_name]}
groups = slide_cfg.get('groups', {})
if groups and not isinstance(groups, dict):
warnings.append(f'animations.json slide "{slide_name}" field "groups" must be an object')
continue
for group_id, group_cfg in (groups or {}).items():
if group_id not in known_groups:
warnings.append(
f'animations.json references missing group: {slide_name}/{group_id}'
)
if not isinstance(group_cfg, dict):
warnings.append(f'animations.json group "{slide_name}/{group_id}" must be an object')
continue
effect = group_cfg.get('effect')
if effect is not None and not _valid_animation_effect(str(effect)):
warnings.append(
f'animations.json group "{slide_name}/{group_id}" has unknown effect: {effect}'
)
return warnings
def _validate_scope_effects(scope: dict[str, Any], label: str, warnings: list[str]) -> None:
transition = scope.get('transition', {})
if isinstance(transition, dict):
effect = transition.get('effect')
if effect is not None and not _valid_transition_effect(str(effect)):
warnings.append(f'animations.json {label} has unknown transition effect: {effect}')
animation = scope.get('animation', {})
if isinstance(animation, dict):
effect = animation.get('effect')
if effect is not None and not _valid_animation_effect(str(effect)):
warnings.append(f'animations.json {label} has unknown animation effect: {effect}')
def build_scaffold(project_path: Path) -> dict[str, Any]:
"""Build an editable animation override scaffold from current SVGs.
Chrome groups are omitted exporter auto-detects them as ``none`` via
``is_chrome_id`` at render time, so listing them in the scaffold is pure
noise. A ``defaults`` stub is emitted up front to remind the editor that
deck-wide overrides exist and most pages should inherit them.
"""
targets_by_slide, _anonymous = scan_project_targets(project_path)
slides: dict[str, Any] = {}
for slide_name, targets in targets_by_slide.items():
groups: dict[str, Any] = {}
for target in targets:
if target.chrome:
continue
groups[target.group_id] = {}
slides[slide_name] = {'groups': groups}
return {
'version': 1,
'defaults': {
'transition': {'effect': 'fade', 'duration': 0.4},
'animation': {
'effect': 'auto',
'duration': 0.4,
'stagger': 0.5,
'trigger': 'after-previous',
},
},
'slides': slides,
}
def build_group_listing(project_path: Path) -> tuple[list[str], list[str]]:
"""Return one compact line per slide: ``<slide>: id1, id2, id3``.
Chrome groups are excluded matches ``build_scaffold``'s policy so the
listing reflects exactly what an editor can override. Returns
``(lines, anonymous_warnings)``.
"""
targets_by_slide, anonymous = scan_project_targets(project_path)
lines: list[str] = []
for slide_name, targets in targets_by_slide.items():
ids = [t.group_id for t in targets if not t.chrome]
if not ids:
lines.append(f'{slide_name}: (no animatable groups)')
else:
lines.append(f'{slide_name}: {", ".join(ids)}')
return lines, anonymous
def write_scaffold(
project_path: Path,
output_path: str | None = None,
*,
force: bool = False,
) -> Path:
"""Write ``animations.json`` scaffold and return its path."""
if output_path:
path = Path(output_path)
else:
path = default_config_path(project_path)
if output_path and not path.is_absolute():
path = project_path / path
if path.exists() and not force:
raise FileExistsError(f'Animation config already exists: {path}')
scaffold = build_scaffold(project_path)
path.write_text(
json.dumps(scaffold, ensure_ascii=False, indent=2) + '\n',
encoding='utf-8',
)
return path

View File

@ -0,0 +1,162 @@
"""ConvertContext — shared state passed through the SVG → DrawingML pipeline."""
from __future__ import annotations
from pathlib import Path
from typing import Any
from xml.etree import ElementTree as ET
from dataclasses import dataclass, field
AffineMatrix = tuple[float, float, float, float, float, float]
IDENTITY_MATRIX: AffineMatrix = (1.0, 0.0, 0.0, 1.0, 0.0, 0.0)
@dataclass
class ShapeResult:
"""Internal conversion result carrying XML plus resolved EMU bounds."""
xml: str
bounds_emu: tuple[int, int, int, int] | None = None
@dataclass
class ConvertContext:
"""Shared context passed through the SVG → DrawingML conversion pipeline.
Derived via child() during recursive SVG tree traversal to accumulate
translate / scale / inherited style information.
"""
defs: dict[str, ET.Element] = field(default_factory=dict)
id_counter: int = 2 # 1 is reserved for spTree root
slide_num: int = 1
translate_x: float = 0.0
translate_y: float = 0.0
scale_x: float = 1.0
scale_y: float = 1.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)
rel_id_counter: int = 2 # rId1 reserved for slideLayout
svg_dir: Path | None = None
inherited_styles: dict[str, str] = field(default_factory=dict)
# Recursion depth — only the depth==0 (root) context records anim targets.
depth: int = 0
# Top-level <g id="..."> groups, recorded as (shape_id, svg_id) in z-order.
# Used by the PPTX builder to emit per-element entrance timing.
anim_targets: list = field(default_factory=list)
# 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
# Optional per-element conversion diagnostics. Shared by child contexts so
# callers can inspect native / skipped / unsupported decisions per slide.
trace_events: list[dict[str, Any]] | None = None
def next_id(self) -> int:
"""Allocate the next shape ID."""
cid = self.id_counter
self.id_counter += 1
return cid
def next_rel_id(self) -> str:
"""Allocate the next relationship ID (rIdN)."""
rid = f'rId{self.rel_id_counter}'
self.rel_id_counter += 1
return rid
def child(
self,
dx: float = 0,
dy: float = 0,
sx: float = 1.0,
sy: float = 1.0,
transform_matrix: AffineMatrix | None = None,
filter_id: str | None = None,
style_overrides: dict[str, str] | None = None,
) -> ConvertContext:
"""Create a child context with accumulated translate / scale / styles.
Args:
dx: X translation delta.
dy: Y translation delta.
sx: X scale factor.
sy: Y scale factor.
transform_matrix: Full affine transform to accumulate for
converters that can faithfully map it to DrawingML.
filter_id: Override filter ID.
style_overrides: Style attribute overrides from child element.
"""
local_matrix = transform_matrix or IDENTITY_MATRIX
# When first crossing from scalar to matrix mode, fold accumulated
# translate_x/y and scale_x/y into the matrix base. Otherwise the
# ancestor's scalar transform — which matrix-path readers (e.g.
# <image>) never look at — is silently lost, and the descendant
# lands at raw SVG coordinates (typically near (0,0)).
if transform_matrix is not None and not self.use_transform_matrix:
base_matrix: AffineMatrix = (
self.scale_x, 0.0,
0.0, self.scale_y,
self.translate_x, self.translate_y,
)
else:
base_matrix = self.transform_matrix
a1, b1, c1, d1, e1, f1 = base_matrix
a2, b2, c2, d2, e2, f2 = local_matrix
combined_matrix: AffineMatrix = (
a1 * a2 + c1 * b2,
b1 * a2 + d1 * b2,
a1 * c2 + c1 * d2,
b1 * c2 + d1 * d2,
a1 * e2 + c1 * f2 + e1,
b1 * e2 + d1 * f2 + f1,
)
merged = dict(self.inherited_styles)
if style_overrides:
# Opacity is multiplicative, not a simple override
_OPACITY_KEYS = ('opacity', 'fill-opacity', 'stroke-opacity')
for op_key in _OPACITY_KEYS:
if op_key in style_overrides and op_key in merged:
try:
merged[op_key] = str(
float(merged[op_key]) * float(style_overrides[op_key])
)
except ValueError:
merged[op_key] = style_overrides[op_key]
elif op_key in style_overrides:
merged[op_key] = style_overrides[op_key]
for k, v in style_overrides.items():
if k not in _OPACITY_KEYS:
merged[k] = v
return ConvertContext(
defs=self.defs,
id_counter=self.id_counter,
slide_num=self.slide_num,
translate_x=self.translate_x + dx,
translate_y=self.translate_y + dy,
scale_x=self.scale_x * sx,
scale_y=self.scale_y * sy,
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,
rel_id_counter=self.rel_id_counter,
svg_dir=self.svg_dir,
inherited_styles=merged,
depth=self.depth + 1,
# 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,
trace_events=self.trace_events,
)
def sync_from_child(self, child_ctx: ConvertContext) -> None:
"""Sync counters back from a child context."""
self.id_counter = child_ctx.id_counter
self.rel_id_counter = child_ctx.rel_id_counter

View File

@ -0,0 +1,573 @@
"""Core SVG -> DrawingML dispatcher, group handling, and main entry point."""
from __future__ import annotations
import math
import re
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 (
SVG_NS, EMU_PER_PX,
_extract_inheritable_styles, parse_transform_matrix, resolve_url_id,
)
from .drawingml_styles import build_effect_xml
from .drawingml_elements import (
convert_rect, convert_circle, convert_ellipse,
convert_line, convert_path,
convert_polygon, convert_polyline,
convert_text, convert_image, convert_nested_svg,
)
class SvgNativeConversionError(RuntimeError):
"""Raised when an SVG cannot be faithfully converted to native DrawingML."""
# ---------------------------------------------------------------------------
# Animation anchor selection
# ---------------------------------------------------------------------------
# Tokens that mark a top-level <g id="..."> as page chrome rather than animated
# content. When any token (after splitting id on '-' and '_') matches, the group
# is excluded from the per-element entrance animation cascade so background,
# header/footer, decorations etc. appear together with the slide instead of
# requiring presenter clicks.
_CHROME_ID_TOKENS = frozenset({
'background', 'bg',
'decoration', 'decorations', 'decor',
'header', 'footer',
'chrome', 'watermark',
'pagenumber', 'pagenum',
'nav', 'logo', 'rule',
})
def _is_chrome_id(elem_id: str | None) -> bool:
if not elem_id:
return False
lower = elem_id.lower()
if lower.replace('-', '').replace('_', '') in _CHROME_ID_TOKENS:
return True
tokens = re.split(r'[-_]', lower)
return any(t in _CHROME_ID_TOKENS for t in tokens if t)
# ---------------------------------------------------------------------------
# Transform & layout helpers
# ---------------------------------------------------------------------------
def parse_transform(transform_str: str) -> tuple[float, float, float, float, float]:
"""Parse an SVG transform list into (dx, dy, sx, sy, angle_deg).
Composes every translate/scale/rotate/matrix operation rather than picking
the first occurrence needed for idioms like
``translate(cx cy) scale(-1 -1) translate(-cx -cy)`` which encode a flip
around a non-origin pivot.
When the composed matrix has no shear and no rotation, the decomposition is
exact (sx/sy may be negative to represent flips). When rotation is present
without shear, sx/sy default to the column magnitudes and angle_deg is the
rotation. Shear is not representable in this 5-tuple and silently
collapses; callers that need exact fidelity should consume the full matrix
via ``parse_transform_matrix``.
"""
if not transform_str:
return 0.0, 0.0, 1.0, 1.0, 0.0
a, b, c, d, e, f = parse_transform_matrix(transform_str)
# No shear / rotation: direct decomposition preserves the original signs of
# sx / sy. ctx_x / ctx_y use the simple ``val * sx + tx`` formula, so this
# is the only form that survives flip-around-pivot composites without
# collapsing them into a rotation that the consumer can't honour.
if abs(b) < 1e-9 and abs(c) < 1e-9:
sx = a if a != 0 else 1.0
sy = d if d != 0 else 1.0
return e, f, sx, sy, 0.0
sx = math.hypot(a, b)
sy = math.hypot(c, d)
if sx == 0:
sx = 1.0
if sy == 0:
sy = 1.0
angle_deg = math.degrees(math.atan2(b, a))
return e, f, sx, sy, angle_deg
# ``rotate(angle)`` defaults to pivot (0,0); ``rotate(angle, cx, cy)`` rotates
# around (cx, cy). DrawingML grpSp ``rot`` always rotates around the group's
# own bounding-box centre — we need the SVG pivot so ``convert_g`` can
# compensate for the offset between those two centres.
_ROTATE_RE = re.compile(
r'rotate\(\s*([-\d.eE+]+)(?:[\s,]+([-\d.eE+]+)[\s,]+([-\d.eE+]+))?\s*\)'
)
def _extract_rotate_pivot(transform_str: str) -> tuple[float, float] | None:
"""Return the (cx, cy) pivot of a sole ``rotate(...)`` in *transform_str*.
Returns ``None`` when the transform list contains anything other than one
rotate (other ops compose with rotate in a way the pivot-compensation
fallback can't express). A bare ``rotate(angle)`` returns (0, 0).
"""
if not transform_str:
return None
ops = [op for op in re.findall(r'(\w+)\s*\(', transform_str) if op]
if ops != ['rotate']:
return None
match = _ROTATE_RE.search(transform_str)
if not match:
return None
cx = float(match.group(2)) if match.group(2) is not None else 0.0
cy = float(match.group(3)) if match.group(3) is not None else 0.0
return cx, cy
# ---------------------------------------------------------------------------
# Group handling
# ---------------------------------------------------------------------------
def convert_g(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None:
"""Convert SVG <g> to DrawingML group shape <p:grpSp>.
Preserves group structure so elements can be selected and moved together
in PowerPoint. Single-child groups are flattened to avoid unnecessary nesting.
Uses identity coordinate mapping (chOff/chExt == off/ext) so child shapes
keep their absolute slide coordinates unchanged.
"""
transform = elem.get('transform', '')
dx, dy, sx, sy, angle_deg = parse_transform(transform)
filter_id = resolve_url_id(elem.get('filter', ''))
style_overrides = _extract_inheritable_styles(elem)
elem_id = elem.get('id')
should_animate_group = ctx.depth == 0 and elem_id and not _is_chrome_id(elem_id)
visual_children = [
child for child in elem
if child.tag.replace(f'{{{SVG_NS}}}', '') not in _NON_VISUAL_TAGS
]
matrix_supported = bool(transform) and visual_children and all(
_supports_matrix_transform(child) for child in visual_children
)
# A pure ``rotate(angle [cx cy])`` falls through to the fallback path
# below (children are rect/text/path/etc. that don't consume a full
# matrix). Decomposing the matrix produces translation components
# (e, f) that encode the pivot — handing those to children would
# *double-translate* them because grpSp's own ``rot`` already
# rotates around the group's bounding-box centre. Skip the child
# translation here and apply pivot-centre compensation to ``a:off``
# below instead.
rotate_pivot = _extract_rotate_pivot(transform) if not matrix_supported else None
if matrix_supported:
child_ctx = ctx.child(
0, 0, 1.0, 1.0,
transform_matrix=parse_transform_matrix(transform),
filter_id=filter_id,
style_overrides=style_overrides,
)
elif rotate_pivot is not None:
child_ctx = ctx.child(
0, 0, 1.0, 1.0,
filter_id=filter_id,
style_overrides=style_overrides,
)
else:
child_ctx = ctx.child(dx, dy, sx, sy, filter_id=filter_id, style_overrides=style_overrides)
child_results: list[ShapeResult] = []
for child in elem:
result = convert_element(child, child_ctx)
if result:
child_results.append(result)
ctx.sync_from_child(child_ctx)
if not child_results:
return None
# Single-child non-semantic groups are flattened to reduce nesting. Top-level
# semantic groups are preserved so animations target the group, not its
# individual child shapes.
if len(child_results) == 1 and not should_animate_group:
return child_results[0]
# Multiple children, or a top-level semantic one-child group: wrap in
# <p:grpSp> so PowerPoint can animate the group as one unit.
min_x = min_y = float('inf')
max_x = max_y = float('-inf')
for child_result in child_results:
bounds = child_result.bounds_emu
if bounds is None:
continue
min_x = min(min_x, bounds[0])
min_y = min(min_y, bounds[1])
max_x = max(max_x, bounds[2])
max_y = max(max_y, bounds[3])
if min_x == float('inf'):
return ShapeResult(xml='\n'.join(result.xml for result in child_results))
group_x = int(min_x)
group_y = int(min_y)
group_w = max(int(max_x - min_x), 1)
group_h = max(int(max_y - min_y), 1)
# ``rotate(angle, cx, cy)`` rotates around the SVG pivot, but DrawingML
# grpSp ``rot`` always rotates around the group's own bbox centre. When
# those centres differ, the visual position drifts by exactly the
# translation a rotate-around-pivot equals. Compensate by offsetting the
# outer <a:off> only; <a:chOff> stays on the unshifted bbox so children
# (still at their original SVG positions because rotate_pivot suppressed
# the dx/dy translation above) remain aligned inside the group.
off_x = group_x
off_y = group_y
if rotate_pivot is not None and angle_deg:
cx_svg, cy_svg = rotate_pivot
pivot_ex = (cx_svg + ctx.translate_x) * EMU_PER_PX
pivot_ey = (cy_svg + ctx.translate_y) * EMU_PER_PX
bbox_cx = group_x + group_w / 2
bbox_cy = group_y + group_h / 2
theta = math.radians(angle_deg)
cos_t = math.cos(theta)
sin_t = math.sin(theta)
# Where the bbox centre lands after rotating around the pivot, minus
# where DrawingML's grpSp rot would leave it (i.e. unchanged).
delta_x = (bbox_cx - pivot_ex) * cos_t - (bbox_cy - pivot_ey) * sin_t + pivot_ex - bbox_cx
delta_y = (bbox_cx - pivot_ex) * sin_t + (bbox_cy - pivot_ey) * cos_t + pivot_ey - bbox_cy
off_x = int(round(group_x + delta_x))
off_y = int(round(group_y + delta_y))
shapes_xml = '\n'.join(result.xml for result in child_results)
group_id = ctx.next_id()
# Record top-level semantic groups (e.g. <g id="p02-title">) so the
# PPTX builder can emit per-element entrance timing. Only the outermost
# multi-child wrapper qualifies — flattened single-child groups have no
# <p:grpSp> to anchor a timing target on, and nested groups are
# ignored to keep the animation budget at ~per-section granularity.
if should_animate_group:
ctx.anim_targets.append((group_id, elem_id))
group_effect = ''
if filter_id and filter_id in ctx.defs:
group_effect = build_effect_xml(ctx.defs[filter_id])
rot_emu = 0 if matrix_supported else int(angle_deg * 60000)
rot_attr = f' rot="{rot_emu}"' if rot_emu else ''
return ShapeResult(xml=f'''<p:grpSp>
<p:nvGrpSpPr>
<p:cNvPr id="{group_id}" name="Group {group_id}"/>
<p:cNvGrpSpPr/>
<p:nvPr/>
</p:nvGrpSpPr>
<p:grpSpPr>
<a:xfrm{rot_attr}>
<a:off x="{off_x}" y="{off_y}"/>
<a:ext cx="{group_w}" cy="{group_h}"/>
<a:chOff x="{group_x}" y="{group_y}"/>
<a:chExt cx="{group_w}" cy="{group_h}"/>
</a:xfrm>
{group_effect}
</p:grpSpPr>
{shapes_xml}
</p:grpSp>''', bounds_emu=(group_x, group_y, group_x + group_w, group_y + group_h))
# ---------------------------------------------------------------------------
# Defs collection & element dispatch
# ---------------------------------------------------------------------------
_NON_VISUAL_TAGS = frozenset(('defs', 'title', 'desc', 'metadata', 'style'))
def _supports_matrix_transform(elem: ET.Element) -> bool:
"""Return whether this subtree can consume a full affine matrix directly."""
tag = elem.tag.replace(f'{{{SVG_NS}}}', '')
if tag == 'image':
return True
if tag == 'svg':
visual_children = [
child for child in elem
if child.tag.replace(f'{{{SVG_NS}}}', '') not in _NON_VISUAL_TAGS
]
return len(visual_children) == 1 and (
visual_children[0].tag.replace(f'{{{SVG_NS}}}', '') == 'image'
)
if tag == 'g':
visual_children = [
child for child in elem
if child.tag.replace(f'{{{SVG_NS}}}', '') not in _NON_VISUAL_TAGS
]
return bool(visual_children) and all(
_supports_matrix_transform(child) for child in visual_children
)
return False
_CONVERTERS = {
'rect': convert_rect,
'circle': convert_circle,
'ellipse': convert_ellipse,
'line': convert_line,
'path': convert_path,
'polygon': convert_polygon,
'polyline': convert_polyline,
'text': convert_text,
'image': convert_image,
'g': convert_g,
'svg': convert_nested_svg,
}
_SUPPORTED_VISUAL_CHILD_TAGS = frozenset(('tspan',))
def collect_defs(root: ET.Element) -> dict[str, ET.Element]:
"""Collect all <defs> children into an {id: element} dictionary."""
defs: dict[str, ET.Element] = {}
for defs_elem in root.iter(f'{{{SVG_NS}}}defs'):
for child in defs_elem:
elem_id = child.get('id')
if elem_id:
defs[elem_id] = child
# Also check for defs without namespace
for defs_elem in root.iter('defs'):
for child in defs_elem:
elem_id = child.get('id')
if elem_id:
defs[elem_id] = child
return defs
def convert_element(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None:
"""Dispatch an SVG element to the appropriate converter."""
tag = elem.tag.replace(f'{{{SVG_NS}}}', '')
elem_id = elem.get('id')
def trace(decision: str, **metadata: Any) -> None:
if ctx.trace_events is None:
return
event: dict[str, Any] = {
'tag': tag,
'decision': decision,
}
if elem_id:
event['id'] = elem_id
event.update(metadata)
ctx.trace_events.append(event)
converter = _CONVERTERS.get(tag)
if converter:
try:
result = converter(elem, ctx)
except Exception as e:
trace('error', error=str(e))
raise SvgNativeConversionError(f'Failed to convert <{tag}>: {e}') from e
if result:
shape_match = re.search(r'<p:cNvPr id="(\d+)"', result.xml)
metadata: dict[str, Any] = {}
if shape_match:
metadata['shape_id'] = int(shape_match.group(1))
if result.bounds_emu is not None:
metadata['bounds_emu'] = list(result.bounds_emu)
trace('native', **metadata)
else:
trace('skip', reason='empty-or-non-rendering')
return result
if tag in _NON_VISUAL_TAGS:
trace('skip', reason='non-visual')
return None
trace('unsupported')
raise SvgNativeConversionError(f'Unsupported visual SVG element <{tag}>')
def _local_tag(elem: ET.Element) -> str:
return elem.tag.split('}', 1)[-1] if isinstance(elem.tag, str) and '}' in elem.tag else str(elem.tag)
def _collect_unsupported_visuals(root: ET.Element) -> list[str]:
issues: list[str] = []
def walk(elem: ET.Element, path: str, in_defs: bool = False) -> None:
tag = _local_tag(elem)
current = f'{path}/{tag}'
if in_defs:
return
if tag in _NON_VISUAL_TAGS:
return
if (tag not in _CONVERTERS
and tag not in _NON_VISUAL_TAGS
and tag not in _SUPPORTED_VISUAL_CHILD_TAGS):
issues.append(current)
for idx, child in enumerate(list(elem), start=1):
walk(child, f'{current}[{idx}]', in_defs=(tag == 'defs'))
for idx, child in enumerate(list(root), start=1):
walk(child, f'/svg[{idx}]')
return issues
def convert_svg_to_slide_shapes(
svg_path: Path,
slide_num: int = 1,
verbose: bool = False,
merge_paragraphs: bool = True,
trace_out: list[dict[str, Any]] | None = None,
) -> tuple[str, dict[str, bytes], list[dict[str, str]], list]:
"""Convert an SVG file to a complete DrawingML slide XML.
Args:
svg_path: Path to the SVG file.
slide_num: Slide number (for naming).
verbose: Print progress info.
merge_paragraphs: When True, mergeable paragraph blocks (same x,
dy clustered around one base line-height) become a single
editable text frame with multiple <a:p>. Disable it to preserve
the SVG's exact line layout (one textbox per line).
trace_out: Optional list populated with one per-slide trace dictionary.
Returns:
(slide_xml, media_files, rel_entries, anim_targets) 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.
"""
tree = ET.parse(str(svg_path))
root = tree.getroot()
trace_events: list[dict[str, Any]] | None = [] if trace_out is not None else None
trace_steps: list[dict[str, Any]] = []
# Expand <use data-icon="..."/> placeholders in-memory so this dispatcher
# can consume svg_output/ directly. Standard renderers and this converter
# 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'
if icons_dir.exists():
from .use_expander import expand_use_data_icons
expanded = expand_use_data_icons(root, icons_dir)
if expanded:
trace_steps.append({'action': 'expand-use-data-icons', 'count': expanded})
if verbose and expanded:
print(f' Expanded {expanded} <use data-icon="..."/> placeholder(s)')
# Flatten positional <tspan> (those with x/y/non-zero dy) into independent
# <text> elements. DrawingML runs cannot reposition mid-paragraph, so a
# dy-stacked block of tspans would otherwise collapse onto one baseline,
# and an x-anchored tspan would render in the wrong column. finalize_svg
# does the same flattening on disk; doing it here keeps native pptx output
# 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
flattened = flatten_positional_tspans(tree, merge_paragraphs=merge_paragraphs)
if flattened:
trace_steps.append({
'action': 'flatten-positional-tspans',
'merge_paragraphs': merge_paragraphs,
})
if verbose:
print(' Flattened positional <tspan> into independent <text>')
unsupported = _collect_unsupported_visuals(root)
if unsupported:
preview = '; '.join(unsupported[:8])
suffix = '' if len(unsupported) <= 8 else f'; +{len(unsupported) - 8} more'
raise SvgNativeConversionError(
f'{svg_path.name}: unsupported visual SVG element(s): {preview}{suffix}'
)
defs = collect_defs(root)
ctx = ConvertContext(
defs=defs,
slide_num=slide_num,
svg_dir=Path(svg_path).parent,
merge_paragraphs=merge_paragraphs,
trace_events=trace_events,
)
shapes: list[str] = []
converted = 0
skipped = 0
# Per-element shape ids of every top-level child, used as an animation
# fallback when no <g id="..."> groups are present at the root.
fallback_targets: list = []
for child in root:
tag = child.tag.replace(f'{{{SVG_NS}}}', '')
if tag == 'defs':
continue
result = convert_element(child, ctx)
if result:
shapes.append(result.xml)
converted += 1
m = re.search(r'<p:cNvPr id="(\d+)"', result.xml)
if m:
fallback_targets.append((int(m.group(1)), tag))
else:
if tag not in _NON_VISUAL_TAGS:
skipped += 1
# Animation target fallback. Semantic <g id="..."> groups are the
# preferred anchors (set inside convert_g). When the SVG has none
# at the root we fall back to top-level primitives, but only when
# the count is reasonable. Presenter-click animation should reveal
# semantic blocks, not atomized drawing primitives, so fallback is
# intentionally capped at a low count.
_ANIM_FALLBACK_CAP = 8
if not ctx.anim_targets and 0 < len(fallback_targets) <= _ANIM_FALLBACK_CAP:
ctx.anim_targets = fallback_targets
if verbose:
print(f' Converted {converted} elements, skipped {skipped}')
if trace_out is not None:
trace_out.append({
'slide_num': slide_num,
'svg': str(svg_path),
'summary': {
'converted': converted,
'skipped': skipped,
'media_files': len(ctx.media_files),
'relationships': len(ctx.rel_entries),
'animation_targets': len(ctx.anim_targets),
},
'preprocess': trace_steps,
'events': trace_events or [],
})
shapes_xml = '\n'.join(shapes)
slide_xml = f'''<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<p:sld xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"
xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"
xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main">
<p:cSld>
<p:spTree>
<p:nvGrpSpPr>
<p:cNvPr id="1" name=""/>
<p:cNvGrpSpPr/><p:nvPr/>
</p:nvGrpSpPr>
<p:grpSpPr>
<a:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/>
<a:chOff x="0" y="0"/><a:chExt cx="0" cy="0"/></a:xfrm>
</p:grpSpPr>
{shapes_xml}
</p:spTree>
</p:cSld>
<p:clrMapOvr><a:masterClrMapping/></p:clrMapOvr>
</p:sld>'''
return slide_xml, ctx.media_files, ctx.rel_entries, ctx.anim_targets

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,429 @@
"""SVG path parsing, normalization, and DrawingML path command generation."""
from __future__ import annotations
import math
import re
from dataclasses import dataclass, field
from .drawingml_utils import px_to_emu
@dataclass
class PathCommand:
"""A single SVG path command with its arguments."""
cmd: str # M, L, C, Z, etc. (uppercase = absolute)
args: list[float] = field(default_factory=list)
# Argument counts per SVG path command
_ARG_COUNTS = {
'M': 2, 'm': 2, 'L': 2, 'l': 2,
'H': 1, 'h': 1, 'V': 1, 'v': 1,
'C': 6, 'c': 6, 'S': 4, 's': 4,
'Q': 4, 'q': 4, 'T': 2, 't': 2,
'A': 7, 'a': 7, 'Z': 0, 'z': 0,
}
def parse_svg_path(d: str) -> list[PathCommand]:
"""Parse SVG path d attribute into a list of PathCommands."""
if not d:
return []
commands: list[PathCommand] = []
tokens = re.findall(
r'[MmLlHhVvCcSsQqTtAaZz]|[-+]?(?:\d+\.?\d*|\.\d+)(?:[eE][-+]?\d+)?', d
)
current_cmd: str | None = None
current_args: list[float] = []
def flush() -> None:
nonlocal current_cmd, current_args
if current_cmd is None:
return
n = _ARG_COUNTS.get(current_cmd, 0)
if n == 0:
commands.append(PathCommand(current_cmd, []))
elif n > 0 and len(current_args) >= n:
i = 0
while i + n <= len(current_args):
commands.append(PathCommand(current_cmd, current_args[i:i + n]))
# After first M, implicit commands become L
if current_cmd == 'M':
current_cmd = 'L'
elif current_cmd == 'm':
current_cmd = 'l'
i += n
current_args = []
for token in tokens:
if token in 'MmLlHhVvCcSsQqTtAaZz':
flush()
current_cmd = token
current_args = []
else:
try:
current_args.append(float(token))
except ValueError:
pass
flush()
return commands
def svg_path_to_absolute(commands: list[PathCommand]) -> list[PathCommand]:
"""Convert all relative path commands to absolute."""
result: list[PathCommand] = []
cx, cy = 0.0, 0.0 # current point
sx, sy = 0.0, 0.0 # subpath start
for cmd in commands:
a = cmd.args
if cmd.cmd == 'M':
cx, cy = a[0], a[1]
sx, sy = cx, cy
result.append(PathCommand('M', [cx, cy]))
elif cmd.cmd == 'm':
cx += a[0]; cy += a[1]
sx, sy = cx, cy
result.append(PathCommand('M', [cx, cy]))
elif cmd.cmd == 'L':
cx, cy = a[0], a[1]
result.append(PathCommand('L', [cx, cy]))
elif cmd.cmd == 'l':
cx += a[0]; cy += a[1]
result.append(PathCommand('L', [cx, cy]))
elif cmd.cmd == 'H':
cx = a[0]
result.append(PathCommand('L', [cx, cy]))
elif cmd.cmd == 'h':
cx += a[0]
result.append(PathCommand('L', [cx, cy]))
elif cmd.cmd == 'V':
cy = a[0]
result.append(PathCommand('L', [cx, cy]))
elif cmd.cmd == 'v':
cy += a[0]
result.append(PathCommand('L', [cx, cy]))
elif cmd.cmd == 'C':
result.append(PathCommand('C', list(a)))
cx, cy = a[4], a[5]
elif cmd.cmd == 'c':
abs_args = [
cx + a[0], cy + a[1],
cx + a[2], cy + a[3],
cx + a[4], cy + a[5],
]
result.append(PathCommand('C', abs_args))
cx, cy = abs_args[4], abs_args[5]
elif cmd.cmd == 'S':
result.append(PathCommand('S', list(a)))
cx, cy = a[2], a[3]
elif cmd.cmd == 's':
abs_args = [cx + a[0], cy + a[1], cx + a[2], cy + a[3]]
result.append(PathCommand('S', abs_args))
cx, cy = abs_args[2], abs_args[3]
elif cmd.cmd == 'Q':
result.append(PathCommand('Q', list(a)))
cx, cy = a[2], a[3]
elif cmd.cmd == 'q':
abs_args = [cx + a[0], cy + a[1], cx + a[2], cy + a[3]]
result.append(PathCommand('Q', abs_args))
cx, cy = abs_args[2], abs_args[3]
elif cmd.cmd == 'T':
result.append(PathCommand('T', list(a)))
cx, cy = a[0], a[1]
elif cmd.cmd == 't':
abs_args = [cx + a[0], cy + a[1]]
result.append(PathCommand('T', abs_args))
cx, cy = abs_args[0], abs_args[1]
elif cmd.cmd == 'A':
result.append(PathCommand('A', list(a)))
cx, cy = a[5], a[6]
elif cmd.cmd == 'a':
abs_args = [a[0], a[1], a[2], a[3], a[4], cx + a[5], cy + a[6]]
result.append(PathCommand('A', abs_args))
cx, cy = abs_args[5], abs_args[6]
elif cmd.cmd in ('Z', 'z'):
result.append(PathCommand('Z', []))
cx, cy = sx, sy
return result
def _reflect_control_point(
cp_x: float, cp_y: float,
cx: float, cy: float,
) -> tuple[float, float]:
"""Reflect a control point through the current point."""
return 2 * cx - cp_x, 2 * cy - cp_y
def _quad_to_cubic(
qp_x: float, qp_y: float,
p0_x: float, p0_y: float,
p3_x: float, p3_y: float,
) -> list[float]:
"""Convert quadratic bezier control point to cubic bezier control points."""
cp1_x = p0_x + 2.0 / 3.0 * (qp_x - p0_x)
cp1_y = p0_y + 2.0 / 3.0 * (qp_y - p0_y)
cp2_x = p3_x + 2.0 / 3.0 * (qp_x - p3_x)
cp2_y = p3_y + 2.0 / 3.0 * (qp_y - p3_y)
return [cp1_x, cp1_y, cp2_x, cp2_y, p3_x, p3_y]
def _arc_to_cubic_beziers(
cx_: float, cy_: float,
rx: float, ry: float,
phi: float,
large_arc: int, sweep: int,
x2: float, y2: float,
) -> list[PathCommand]:
"""Convert SVG arc (endpoint parameterization) to cubic bezier curves.
Uses the algorithm from the SVG spec (F.6.5) to convert endpoint to center
parameterization, then approximates each arc segment with cubic beziers.
"""
x1, y1 = cx_, cy_
if abs(x1 - x2) < 1e-10 and abs(y1 - y2) < 1e-10:
return []
rx = abs(rx)
ry = abs(ry)
if rx < 1e-10 or ry < 1e-10:
return [PathCommand('L', [x2, y2])]
phi_rad = math.radians(phi)
cos_phi = math.cos(phi_rad)
sin_phi = math.sin(phi_rad)
# Step 1: Compute (x1', y1')
dx = (x1 - x2) / 2.0
dy = (y1 - y2) / 2.0
x1p = cos_phi * dx + sin_phi * dy
y1p = -sin_phi * dx + cos_phi * dy
# Step 2: Compute (cx', cy')
x1p2 = x1p * x1p
y1p2 = y1p * y1p
rx2 = rx * rx
ry2 = ry * ry
# Ensure radii are large enough
lam = x1p2 / rx2 + y1p2 / ry2
if lam > 1:
lam_sqrt = math.sqrt(lam)
rx *= lam_sqrt
ry *= lam_sqrt
rx2 = rx * rx
ry2 = ry * ry
num = max(rx2 * ry2 - rx2 * y1p2 - ry2 * x1p2, 0)
den = rx2 * y1p2 + ry2 * x1p2
sq = math.sqrt(num / den) if den > 1e-10 else 0.0
if large_arc == sweep:
sq = -sq
cxp = sq * rx * y1p / ry
cyp = -sq * ry * x1p / rx
# Step 3: Compute (cx, cy)
arc_cx = cos_phi * cxp - sin_phi * cyp + (x1 + x2) / 2.0
arc_cy = sin_phi * cxp + cos_phi * cyp + (y1 + y2) / 2.0
# Step 4: Compute theta1 and dtheta
def angle_between(ux: float, uy: float, vx: float, vy: float) -> float:
n = math.sqrt((ux * ux + uy * uy) * (vx * vx + vy * vy))
if n < 1e-10:
return 0
c = max(-1, min(1, (ux * vx + uy * vy) / n))
a = math.acos(c)
if ux * vy - uy * vx < 0:
a = -a
return a
theta1 = angle_between(1, 0, (x1p - cxp) / rx, (y1p - cyp) / ry)
dtheta = angle_between(
(x1p - cxp) / rx, (y1p - cyp) / ry,
(-x1p - cxp) / rx, (-y1p - cyp) / ry,
)
if sweep == 0 and dtheta > 0:
dtheta -= 2 * math.pi
elif sweep == 1 and dtheta < 0:
dtheta += 2 * math.pi
# Split arc into segments of at most 90 degrees
n_segs = max(1, int(math.ceil(abs(dtheta) / (math.pi / 2))))
d_per_seg = dtheta / n_segs
result: list[PathCommand] = []
alpha = 4.0 / 3.0 * math.tan(d_per_seg / 4.0)
for i in range(n_segs):
t1 = theta1 + i * d_per_seg
t2 = theta1 + (i + 1) * d_per_seg
cos_t1 = math.cos(t1)
sin_t1 = math.sin(t1)
cos_t2 = math.cos(t2)
sin_t2 = math.sin(t2)
ep1_x = cos_t1 - alpha * sin_t1
ep1_y = sin_t1 + alpha * cos_t1
ep2_x = cos_t2 + alpha * sin_t2
ep2_y = sin_t2 - alpha * cos_t2
ep_x = cos_t2
ep_y = sin_t2
def transform_pt(px: float, py: float) -> tuple[float, float]:
x = rx * px
y = ry * py
xr = cos_phi * x - sin_phi * y + arc_cx
yr = sin_phi * x + cos_phi * y + arc_cy
return xr, yr
cp1 = transform_pt(ep1_x, ep1_y)
cp2 = transform_pt(ep2_x, ep2_y)
ep = transform_pt(ep_x, ep_y)
result.append(PathCommand('C', [cp1[0], cp1[1], cp2[0], cp2[1], ep[0], ep[1]]))
return result
def normalize_path_commands(commands: list[PathCommand]) -> list[PathCommand]:
"""Normalize path commands to M/L/C/Z only.
Converts S -> C, Q -> C, T -> C, A -> C sequences.
"""
result: list[PathCommand] = []
cx, cy = 0.0, 0.0
last_cp_x, last_cp_y = 0.0, 0.0
last_cmd = ''
for cmd in commands:
a = cmd.args
if cmd.cmd == 'M':
cx, cy = a[0], a[1]
last_cp_x, last_cp_y = cx, cy
result.append(cmd)
elif cmd.cmd == 'L':
cx, cy = a[0], a[1]
last_cp_x, last_cp_y = cx, cy
result.append(cmd)
elif cmd.cmd == 'C':
last_cp_x, last_cp_y = a[2], a[3]
cx, cy = a[4], a[5]
result.append(cmd)
elif cmd.cmd == 'S':
if last_cmd in ('C', 'S'):
rcp_x, rcp_y = _reflect_control_point(last_cp_x, last_cp_y, cx, cy)
else:
rcp_x, rcp_y = cx, cy
last_cp_x, last_cp_y = a[0], a[1]
new_cx, new_cy = a[2], a[3]
result.append(PathCommand('C', [rcp_x, rcp_y, a[0], a[1], new_cx, new_cy]))
cx, cy = new_cx, new_cy
elif cmd.cmd == 'Q':
cubic = _quad_to_cubic(a[0], a[1], cx, cy, a[2], a[3])
last_cp_x, last_cp_y = a[0], a[1]
result.append(PathCommand('C', cubic))
cx, cy = a[2], a[3]
elif cmd.cmd == 'T':
if last_cmd in ('Q', 'T'):
qp_x, qp_y = _reflect_control_point(last_cp_x, last_cp_y, cx, cy)
else:
qp_x, qp_y = cx, cy
last_cp_x, last_cp_y = qp_x, qp_y
cubic = _quad_to_cubic(qp_x, qp_y, cx, cy, a[0], a[1])
result.append(PathCommand('C', cubic))
cx, cy = a[0], a[1]
elif cmd.cmd == 'A':
arc_beziers = _arc_to_cubic_beziers(
cx, cy, a[0], a[1], a[2], int(a[3]), int(a[4]), a[5], a[6],
)
for bc in arc_beziers:
result.append(bc)
cx, cy = a[5], a[6]
last_cp_x, last_cp_y = cx, cy
elif cmd.cmd == 'Z':
result.append(cmd)
else:
result.append(cmd)
last_cmd = cmd.cmd
return result
def path_commands_to_drawingml(
commands: list[PathCommand],
offset_x: float = 0,
offset_y: float = 0,
scale_x: float = 1.0,
scale_y: float = 1.0,
) -> tuple[str, float, float, float, float]:
"""Convert normalized path commands to DrawingML <a:path> inner XML.
Returns:
(path_xml, min_x, min_y, width, height) in scaled+offset coordinates.
"""
if not commands:
return '', 0, 0, 0, 0
# First pass: calculate bounding box
points: list[tuple[float, float]] = []
for cmd in commands:
if cmd.cmd in ('M', 'L'):
points.append((
cmd.args[0] * scale_x + offset_x,
cmd.args[1] * scale_y + offset_y,
))
elif cmd.cmd == 'C':
for i in range(0, 6, 2):
points.append((
cmd.args[i] * scale_x + offset_x,
cmd.args[i + 1] * scale_y + offset_y,
))
if not points:
return '', 0, 0, 0, 0
min_x = min(p[0] for p in points)
min_y = min(p[1] for p in points)
max_x = max(p[0] for p in points)
max_y = max(p[1] for p in points)
width = max(max_x - min_x, 1)
height = max(max_y - min_y, 1)
# Second pass: generate DrawingML path commands (EMU, relative to shape)
parts: list[str] = []
for cmd in commands:
if cmd.cmd == 'M':
x_emu = px_to_emu(cmd.args[0] * scale_x + offset_x - min_x)
y_emu = px_to_emu(cmd.args[1] * scale_y + offset_y - min_y)
parts.append(f'<a:moveTo><a:pt x="{x_emu}" y="{y_emu}"/></a:moveTo>')
elif cmd.cmd == 'L':
x_emu = px_to_emu(cmd.args[0] * scale_x + offset_x - min_x)
y_emu = px_to_emu(cmd.args[1] * scale_y + offset_y - min_y)
parts.append(f'<a:lnTo><a:pt x="{x_emu}" y="{y_emu}"/></a:lnTo>')
elif cmd.cmd == 'C':
pts = []
for i in range(0, 6, 2):
x_emu = px_to_emu(cmd.args[i] * scale_x + offset_x - min_x)
y_emu = px_to_emu(cmd.args[i + 1] * scale_y + offset_y - min_y)
pts.append(f'<a:pt x="{x_emu}" y="{y_emu}"/>')
parts.append(f'<a:cubicBezTo>{"".join(pts)}</a:cubicBezTo>')
elif cmd.cmd == 'Z':
parts.append('<a:close/>')
path_inner = '\n'.join(parts)
return path_inner, min_x, min_y, width, height

View File

@ -0,0 +1,656 @@
"""Fill, stroke, and shadow XML builders for DrawingML conversion."""
from __future__ import annotations
import math
import re
from xml.etree import ElementTree as ET
from .drawingml_context import ConvertContext
from .drawingml_utils import (
SVG_NS, ANGLE_UNIT, DASH_PRESETS,
px_to_emu, _f, _get_attr,
parse_hex_color, parse_stop_style, resolve_url_id,
)
def build_solid_fill(color: str, opacity: float | None = None) -> str:
"""Build <a:solidFill> XML."""
alpha = ''
if opacity is not None and opacity < 1.0:
alpha = f'<a:alpha val="{int(opacity * 100000)}"/>'
return f'<a:solidFill><a:srgbClr val="{color}">{alpha}</a:srgbClr></a:solidFill>'
def build_gradient_fill(
grad_elem: ET.Element,
opacity: float | None = None,
) -> str:
"""Build <a:gradFill> from SVG linearGradient or radialGradient element."""
tag = grad_elem.tag.replace(f'{{{SVG_NS}}}', '')
stops_xml = []
for child in grad_elem:
child_tag = child.tag.replace(f'{{{SVG_NS}}}', '')
if child_tag != 'stop':
continue
offset_str = child.get('offset', '0').strip().rstrip('%')
try:
offset = float(offset_str)
if offset > 1.0:
offset = offset / 100.0
except ValueError:
offset = 0.0
pos = int(offset * 100000)
# Parse color from style attribute or direct attributes
style = child.get('style', '')
color, stop_opacity = parse_stop_style(style)
if not color:
color = parse_hex_color(child.get('stop-color', '#000000'))
if color is None:
color = '000000'
direct_stop_op = child.get('stop-opacity')
if direct_stop_op is not None:
try:
stop_opacity = float(direct_stop_op)
except ValueError:
pass
alpha_xml = ''
effective_opacity = stop_opacity
if opacity is not None:
effective_opacity *= opacity
if effective_opacity < 1.0:
alpha_xml = f'<a:alpha val="{int(effective_opacity * 100000)}"/>'
stops_xml.append(
f'<a:gs pos="{pos}"><a:srgbClr val="{color}">{alpha_xml}</a:srgbClr></a:gs>'
)
if not stops_xml:
return ''
gs_list = '\n'.join(stops_xml)
if tag == 'linearGradient':
def parse_grad_coord(val_str: str, default: float = 0.0) -> float:
val_str = val_str.strip()
if val_str.endswith('%'):
return float(val_str.rstrip('%')) / 100.0
v = float(val_str)
return v / 100.0 if v > 1.0 else v
x1 = parse_grad_coord(grad_elem.get('x1', '0'))
y1 = parse_grad_coord(grad_elem.get('y1', '0'))
x2 = parse_grad_coord(grad_elem.get('x2', '1'))
y2 = parse_grad_coord(grad_elem.get('y2', '1'))
angle_rad = math.atan2(y2 - y1, x2 - x1)
angle_deg = math.degrees(angle_rad)
dml_angle = int((angle_deg % 360) * ANGLE_UNIT)
return f'''<a:gradFill>
<a:gsLst>{gs_list}</a:gsLst>
<a:lin ang="{dml_angle}" scaled="1"/>
</a:gradFill>'''
elif tag == 'radialGradient':
return f'''<a:gradFill>
<a:gsLst>{gs_list}</a:gsLst>
<a:path path="circle">
<a:fillToRect l="50000" t="50000" r="50000" b="50000"/>
</a:path>
</a:gradFill>'''
return ''
def build_fill_xml(
elem: ET.Element,
ctx: ConvertContext,
opacity: float | None = None,
) -> str:
"""Build fill XML for a shape element, with inherited style support."""
fill = _get_attr(elem, 'fill', ctx)
if fill is None:
fill = '#000000' # SVG default fill is black
if fill == 'none':
return '<a:noFill/>'
ref_id = resolve_url_id(fill)
if ref_id and ref_id in ctx.defs:
ref_elem = ctx.defs[ref_id]
ref_tag = ref_elem.tag.replace(f'{{{SVG_NS}}}', '')
if ref_tag == 'pattern':
patt_xml = build_pattern_fill(ref_elem, opacity)
if patt_xml:
return patt_xml
return '<a:noFill/>'
return build_gradient_fill(ref_elem, opacity)
color = parse_hex_color(fill)
if color:
return build_solid_fill(color, opacity)
return '<a:noFill/>'
def build_pattern_fill(
pattern_elem: ET.Element,
opacity: float | None = None,
) -> str:
"""Build <a:pattFill> from an SVG <pattern> emitted by pptx_to_svg.
Reads the round-trip annotations (data-pptx-pattern / data-pptx-fg /
data-pptx-bg) when present. Falls back to inspecting the inner stroke /
rect colors when annotations are absent (hand-authored SVG).
"""
prst = pattern_elem.get('data-pptx-pattern') or 'ltUpDiag'
fg_color = pattern_elem.get('data-pptx-fg')
bg_color = pattern_elem.get('data-pptx-bg')
if not fg_color or not bg_color:
# Hand-authored fallback: derive from child elements.
for child in pattern_elem:
tag = child.tag.replace(f'{{{SVG_NS}}}', '')
if tag == 'rect' and not bg_color:
bg_color = child.get('fill')
elif tag == 'path' and not fg_color:
fg_color = child.get('stroke')
fg_hex = parse_hex_color(fg_color) if fg_color else None
bg_hex = parse_hex_color(bg_color) if bg_color else None
if not fg_hex:
return ''
alpha_xml = ''
if opacity is not None and opacity < 1.0:
alpha_xml = f'<a:alpha val="{int(opacity * 100000)}"/>'
fg_xml = f'<a:srgbClr val="{fg_hex}">{alpha_xml}</a:srgbClr>'
if bg_hex:
bg_xml = f'<a:srgbClr val="{bg_hex}"/>'
else:
bg_xml = '<a:srgbClr val="FFFFFF"/>'
return (
f'<a:pattFill prst="{prst}">'
f'<a:fgClr>{fg_xml}</a:fgClr>'
f'<a:bgClr>{bg_xml}</a:bgClr>'
f'</a:pattFill>'
)
# ---------------------------------------------------------------------------
# Marker (arrow-head) support
# ---------------------------------------------------------------------------
# Matches an (x, y) pair in a path "d" attribute: "M 10, 20" / "L -5 7.5" / etc.
_MARKER_POINT_RE = re.compile(
r'[MLml]\s*(-?\d+(?:\.\d+)?)\s*[,\s]\s*(-?\d+(?:\.\d+)?)'
)
_MARKER_POLY_POINT_RE = re.compile(
r'(-?\d+(?:\.\d+)?)\s*[,\s]\s*(-?\d+(?:\.\d+)?)'
)
def _marker_size_buckets(w_attr: float, h_attr: float) -> tuple[str, str]:
"""Map SVG markerWidth / markerHeight to DrawingML (w, len) buckets.
DrawingML arrow-end sizing is categorical: sm / med / lg.
Width (perpendicular to the line) maps from markerHeight;
length (along the line) maps from markerWidth.
"""
def bucket(v: float) -> str:
if v < 6:
return 'sm'
if v > 12:
return 'lg'
return 'med'
return bucket(h_attr), bucket(w_attr)
def _classify_marker(marker_elem: ET.Element) -> tuple[str, str, str] | None:
"""Classify an SVG <marker> into a DrawingML line-end preset.
Returns (type, w, len) where:
type in {'triangle', 'stealth', 'diamond', 'oval', 'arrow'}
w, len in {'sm', 'med', 'lg'}
or None if the marker cannot be classified.
Current coverage (80/20): triangles (3-vertex closed paths or polygons),
diamonds (4-vertex symmetric), and circles / ellipses. Anything else
returns None so the caller can warn and skip.
"""
mw = _f(marker_elem.get('markerWidth'), 3.0)
mh = _f(marker_elem.get('markerHeight'), 3.0)
w_bucket, len_bucket = _marker_size_buckets(mw, mh)
for child in marker_elem:
tag = child.tag.replace(f'{{{SVG_NS}}}', '')
if tag in ('circle', 'ellipse'):
return ('oval', w_bucket, len_bucket)
if tag == 'path':
d = child.get('d', '')
if not d:
continue
points = _MARKER_POINT_RE.findall(d)
n = len(points)
closed = bool(re.search(r'[Zz]\s*$', d.strip()))
if n == 3 and closed:
return ('triangle', w_bucket, len_bucket)
if n == 4 and closed:
return ('diamond', w_bucket, len_bucket)
continue
if tag in ('polygon', 'polyline'):
pts_attr = child.get('points', '')
pts = _MARKER_POLY_POINT_RE.findall(pts_attr)
n = len(pts)
if n == 3:
return ('triangle', w_bucket, len_bucket)
if n == 4:
return ('diamond', w_bucket, len_bucket)
continue
return None
def _emit_line_end(
elem: ET.Element,
ctx: ConvertContext,
which: str,
) -> str:
"""Build <a:headEnd> or <a:tailEnd> XML for an element's marker reference.
Args:
which: 'head' (SVG marker-start) or 'tail' (SVG marker-end).
Returns empty string if no marker, cannot resolve, or cannot classify.
"""
attr = 'marker-start' if which == 'head' else 'marker-end'
ref = _get_attr(elem, attr, ctx)
if not ref or ref == 'none':
return ''
marker_id = resolve_url_id(ref)
if not marker_id or marker_id not in ctx.defs:
return ''
marker_elem = ctx.defs[marker_id]
tag = marker_elem.tag.replace(f'{{{SVG_NS}}}', '')
if tag != 'marker':
# ID collision with non-marker defs entry; ignore.
return ''
cls = _classify_marker(marker_elem)
if cls is None:
print(
f' Warning: marker "{marker_id}" shape cannot be classified; '
f'skipping (supported: triangle, diamond, oval)'
)
return ''
typ, w_bucket, len_bucket = cls
# Reclassify size buckets based on markerUnits semantics:
#
# markerUnits="strokeWidth" (SVG default):
# markerWidth IS a ratio to stroke-width, and DrawingML headEnd/tailEnd
# also scale proportionally with line width. We should compare the ratio
# (markerWidth) directly against ratio-based thresholds — do NOT multiply
# by stroke-width, because that double-counts the scaling.
# Empirical DrawingML arrow ratios:
# sm ≈ 1.5× stroke-width → markerWidth ≤ 2.0
# med ≈ 2.5× stroke-width → markerWidth 2.0 3.5
# lg ≈ 3.5× stroke-width → markerWidth ≥ 3.5
#
# markerUnits="userSpaceOnUse":
# markerWidth/Height are absolute pixel values keep the existing
# absolute-pixel thresholds from _marker_size_buckets (6 / 12).
marker_units = marker_elem.get('markerUnits', 'strokeWidth')
if marker_units != 'userSpaceOnUse':
mw = _f(marker_elem.get('markerWidth'), 3.0)
mh = _f(marker_elem.get('markerHeight'), 3.0)
def _ratio_bucket(v: float) -> str:
if v <= 2.0:
return 'sm'
if v >= 3.5:
return 'lg'
return 'med'
w_bucket = _ratio_bucket(mh) # h → perpendicular width
len_bucket = _ratio_bucket(mw) # w → length along line
dml_tag = 'headEnd' if which == 'head' else 'tailEnd'
return f'<a:{dml_tag} type="{typ}" w="{w_bucket}" len="{len_bucket}"/>'
def build_stroke_xml(
elem: ET.Element,
ctx: ConvertContext,
opacity: float | None = None,
) -> str:
"""Build <a:ln> XML for stroke, with inherited style support."""
stroke = _get_attr(elem, 'stroke', ctx)
if not stroke or stroke == 'none':
return '<a:ln><a:noFill/></a:ln>'
width = _f(_get_attr(elem, 'stroke-width', ctx), 1.0)
width_emu = px_to_emu(width)
# Dash pattern
dash_xml = ''
dasharray = _get_attr(elem, 'stroke-dasharray', ctx)
if dasharray and dasharray != 'none':
preset = DASH_PRESETS.get(dasharray.strip())
if preset:
dash_xml = f'<a:prstDash val="{preset}"/>'
else:
# Unknown pattern → build custDash proportional to stroke width
try:
parts = re.split(r'[\s,]+', dasharray.strip())
d_raw = float(parts[0])
sp_raw = float(parts[1]) if len(parts) > 1 else d_raw
sw = max(width, 0.001)
d_pct = int(d_raw / sw * 100000)
sp_pct = int(sp_raw / sw * 100000)
dash_xml = f'<a:custDash><a:ds d="{d_pct}" sp="{sp_pct}"/></a:custDash>'
except (ValueError, IndexError):
dash_xml = '<a:prstDash val="sysDash"/>'
# Line cap
cap_map = {'round': 'rnd', 'square': 'sq', 'butt': 'flat'}
cap_attr = ''
linecap = _get_attr(elem, 'stroke-linecap', ctx)
if linecap and linecap in cap_map:
cap_attr = f' cap="{cap_map[linecap]}"'
# Line join
join_xml = ''
linejoin = _get_attr(elem, 'stroke-linejoin', ctx)
if linejoin == 'round':
join_xml = '<a:round/>'
elif linejoin == 'bevel':
join_xml = '<a:bevel/>'
elif linejoin == 'miter':
join_xml = '<a:miter lim="800000"/>'
# Line-end markers (SVG marker-start / marker-end → <a:headEnd>/<a:tailEnd>)
# DrawingML schema order is: fill → prstDash → join → headEnd → tailEnd,
# so these must be appended after join_xml.
head_end = _emit_line_end(elem, ctx, 'head')
tail_end = _emit_line_end(elem, ctx, 'tail')
line_ends = head_end + tail_end
# Gradient stroke
grad_id = resolve_url_id(stroke)
if grad_id and grad_id in ctx.defs:
grad_fill = build_gradient_fill(ctx.defs[grad_id], opacity)
return f'<a:ln w="{width_emu}"{cap_attr}>{grad_fill}{dash_xml}{join_xml}{line_ends}</a:ln>'
# Solid color stroke
color = parse_hex_color(stroke)
if not color:
return '<a:ln><a:noFill/></a:ln>'
alpha_xml = ''
if opacity is not None and opacity < 1.0:
alpha_xml = f'<a:alpha val="{int(opacity * 100000)}"/>'
return f'''<a:ln w="{width_emu}"{cap_attr}>
<a:solidFill><a:srgbClr val="{color}">{alpha_xml}</a:srgbClr></a:solidFill>{dash_xml}{join_xml}{line_ends}
</a:ln>'''
def _parse_filter_params(
filter_elem: ET.Element,
) -> dict[str, float | str]:
"""Extract common parameters from an SVG filter element.
Returns:
Dict with keys: std_dev, dx, dy, opacity, color, has_offset.
"""
std_dev = 4.0
dx = 0.0
dy = 0.0
opacity = 0.3
color = '000000'
has_offset = False
for child in filter_elem.iter():
tag = child.tag.replace(f'{{{SVG_NS}}}', '')
if tag == 'feDropShadow':
# Shorthand element: all params in one place
std_dev = _f(child.get('stdDeviation'), 4.0)
dx = _f(child.get('dx'), 0.0)
dy = _f(child.get('dy'), 0.0)
if abs(dx) > 0.01 or abs(dy) > 0.01:
has_offset = True
opacity = _f(child.get('flood-opacity'), 0.3)
raw_color = child.get('flood-color', '').strip().lstrip('#')
if len(raw_color) == 6 and all(c in '0123456789abcdefABCDEF' for c in raw_color):
color = raw_color.upper()
elif tag == 'feGaussianBlur':
std_dev = _f(child.get('stdDeviation'), 4.0)
elif tag == 'feOffset':
dx = _f(child.get('dx'), 0.0)
dy = _f(child.get('dy'), 0.0)
if abs(dx) > 0.01 or abs(dy) > 0.01:
has_offset = True
elif tag == 'feFlood':
opacity = _f(child.get('flood-opacity'), 0.3)
raw_color = child.get('flood-color', '').strip().lstrip('#')
if len(raw_color) == 6 and all(c in '0123456789abcdefABCDEF' for c in raw_color):
color = raw_color.upper()
elif tag == 'feFuncA':
if child.get('type') == 'linear':
opacity = _f(child.get('slope'), 0.3)
return {
'std_dev': std_dev, 'dx': dx, 'dy': dy,
'opacity': opacity, 'color': color, 'has_offset': has_offset,
}
def _infer_shadow_alignment(dx: float, dy: float, threshold: float = 0.5) -> str:
"""Infer outer shadow alignment from the SVG offset vector.
DrawingML applies alignment before blur/offset transforms, so we anchor the
shadow opposite to the dominant offset direction:
- diagonal offsets map to the opposite corner
- pure vertical offsets stay centered, matching common PPT shadow presets
- pure horizontal offsets anchor to the opposite side
"""
if abs(dx) < threshold and abs(dy) < threshold:
return 'ctr'
if abs(dx) < threshold:
return 'ctr'
if abs(dy) < threshold:
return 'l' if dx > 0 else 'r'
if dx > 0 and dy > 0:
return 'tl'
if dx < 0 and dy > 0:
return 'tr'
if dx > 0 and dy < 0:
return 'bl'
return 'br'
def _shadow_dir_angle(dx: float, dy: float) -> int:
"""Convert an SVG offset vector to DrawingML clockwise angle units.
OOXML angles are expressed in 60,000ths of a degree, with positive angles
rotating clockwise toward the positive Y axis. SVG uses the same screen
coordinate orientation (positive Y points downward), so the raw screen-space
vector angle can be mapped directly with atan2(dy, dx).
"""
if abs(dx) < 0.001 and abs(dy) < 0.001:
return 0
angle_deg = math.degrees(math.atan2(dy, dx)) % 360
return int(angle_deg * ANGLE_UNIT)
def build_shadow_xml(filter_elem: ET.Element) -> str:
"""Build <a:effectLst> with <a:outerShdw> from SVG filter element.
SVG-to-DrawingML shadow mapping notes:
- SVG feGaussianBlur stdDeviation (σ) maps to DrawingML blurRad using a
2.0× scale. Rationale: σ is a standard deviation whose visual radius
is ~3σ, while DrawingML blurRad is an outer-spread pixel distance.
A 1.0× scale makes PowerPoint render sharp, concentrated shadows
("heavy" visual). 2.0× matches the CSS drop-shadowbox-shadow
convention and produces softer diffusion closer to the SVG preview.
- The algn attribute is inferred from the offset direction so that
the shadow aligns naturally with the shape edge.
"""
if filter_elem is None:
return ''
p = _parse_filter_params(filter_elem)
std_dev = p['std_dev']
dx = p['dx']
dy = p['dy']
# For shadow, default dy to 4 if no offset was found
if not p['has_offset']:
dy = 4.0
blur_rad = px_to_emu(std_dev * 2.0)
dist = px_to_emu(math.sqrt(dx * dx + dy * dy))
dir_angle = _shadow_dir_angle(dx, dy)
# PowerPoint renders outerShdw alpha slightly heavier than SVG's filter
# composite (different blending path). Scale by 0.75 to match the SVG
# preview after blur has been corrected to 2.0× σ.
alpha_val = int(p['opacity'] * 75000)
algn = _infer_shadow_alignment(dx, dy)
return f'''<a:effectLst>
<a:outerShdw blurRad="{blur_rad}" dist="{dist}" dir="{dir_angle}" algn="{algn}" rotWithShape="0">
<a:srgbClr val="{p['color']}"><a:alpha val="{alpha_val}"/></a:srgbClr>
</a:outerShdw>
</a:effectLst>'''
def build_glow_xml(filter_elem: ET.Element) -> str:
"""Build <a:effectLst> with <a:glow> from SVG filter element.
Used for filters that have feGaussianBlur without meaningful feOffset,
typically title glow or highlight effects.
"""
if filter_elem is None:
return ''
p = _parse_filter_params(filter_elem)
rad = px_to_emu(p['std_dev'])
alpha_val = int(p['opacity'] * 100000)
return f'''<a:effectLst>
<a:glow rad="{rad}">
<a:srgbClr val="{p['color']}"><a:alpha val="{alpha_val}"/></a:srgbClr>
</a:glow>
</a:effectLst>'''
def classify_filter_effect(filter_elem: ET.Element) -> str | None:
"""Classify an SVG filter into a supported DrawingML effect kind."""
if filter_elem is None:
return None
p = _parse_filter_params(filter_elem)
return 'shadow' if p['has_offset'] else 'glow'
def build_effect_xml(filter_elem: ET.Element) -> str:
"""Build effect XML by classifying the SVG filter as shadow or glow.
Classification rules:
- feOffset with non-zero dx/dy outer shadow
- No feOffset or zero offset glow effect
"""
if filter_elem is None:
return ''
effect_kind = classify_filter_effect(filter_elem)
if effect_kind == 'shadow':
return build_shadow_xml(filter_elem)
if effect_kind == 'glow':
return build_glow_xml(filter_elem)
return ''
def get_element_opacity(elem: ET.Element) -> float | None:
"""Get opacity value from element. Returns None if 1.0 or not set."""
op = elem.get('opacity')
if op is None:
return None
try:
val = float(op)
return val if val < 1.0 else None
except ValueError:
return None
def get_fill_opacity(
elem: ET.Element,
ctx: ConvertContext | None = None,
) -> float | None:
"""Get effective fill opacity combining 'opacity' and 'fill-opacity'.
Returns:
Combined opacity value, or None if fully opaque.
"""
base = 1.0
op = _get_attr(elem, 'opacity', ctx) if ctx else elem.get('opacity')
if op:
try:
base = float(op)
except ValueError:
pass
fill_op = _get_attr(elem, 'fill-opacity', ctx) if ctx else elem.get('fill-opacity')
if fill_op:
try:
base *= float(fill_op)
except ValueError:
pass
return base if base < 1.0 else None
def get_stroke_opacity(
elem: ET.Element,
ctx: ConvertContext | None = None,
) -> float | None:
"""Get effective stroke opacity combining 'opacity' and 'stroke-opacity'.
Returns:
Combined opacity value, or None if fully opaque.
"""
base = 1.0
op = _get_attr(elem, 'opacity', ctx) if ctx else elem.get('opacity')
if op:
try:
base = float(op)
except ValueError:
pass
stroke_op = _get_attr(elem, 'stroke-opacity', ctx) if ctx else elem.get('stroke-opacity')
if stroke_op:
try:
base *= float(stroke_op)
except ValueError:
pass
return base if base < 1.0 else None

View File

@ -0,0 +1,490 @@
"""Coordinate helpers, color parsing, and font utilities for DrawingML conversion."""
from __future__ import annotations
import re
import math
from xml.etree import ElementTree as ET
from .drawingml_context import AffineMatrix, ConvertContext, IDENTITY_MATRIX
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
SVG_NS = 'http://www.w3.org/2000/svg'
XLINK_NS = 'http://www.w3.org/1999/xlink'
EMU_PER_PX = 9525 # 1 SVG px = 9525 EMU (96 DPI)
FONT_PX_TO_HUNDREDTHS_PT = 75 # 1px = 0.75pt -> 75 hundredths-of-a-point
ANGLE_UNIT = 60000 # DrawingML angle: 60000ths of a degree
# SVG attributes inheritable from parent <g>
INHERITABLE_ATTRS = [
'fill', 'stroke', 'stroke-width', 'stroke-dasharray', 'stroke-linecap',
'stroke-linejoin', 'opacity', 'fill-opacity', 'stroke-opacity',
'font-family', 'font-size', 'font-weight', 'font-style',
'text-anchor', 'letter-spacing', 'text-decoration',
]
# Known East Asian fonts
EA_FONTS = {
'PingFang SC', 'PingFang TC', 'PingFang HK',
'Microsoft YaHei', 'Microsoft JhengHei',
'SimSun', 'SimHei', 'FangSong', 'KaiTi', 'STKaiti',
'STHeiti', 'STSong', 'STFangsong', 'STXihei', 'STZhongsong',
'Hiragino Sans', 'Hiragino Sans GB', 'Hiragino Mincho ProN',
'Hiragino Kaku Gothic ProN', 'Hiragino Kaku Gothic Pro',
'Hiragino Mincho Pro',
'Noto Sans SC', 'Noto Sans TC', 'Noto Serif SC', 'Noto Serif TC',
'Noto Sans JP', 'Noto Serif JP', 'Noto Sans CJK JP',
'Source Han Sans SC', 'Source Han Sans TC',
'Source Han Serif SC', 'Source Han Serif TC',
'Source Han Sans JP', 'Source Han Serif JP',
'WenQuanYi Micro Hei', 'WenQuanYi Zen Hei',
'YouYuan', 'LiSu', 'HuaWenKaiTi',
'Songti SC', 'Songti TC',
# Windows 10/11 + Office default / common Simplified Chinese
'DengXian', 'DengXian Light', 'DengXian Bold', 'Microsoft YaHei UI',
# Office display Chinese (华文 / 方正) — usually title-only, not on every client
'STXingkai', 'STLiti', 'STXinwei', 'STHupo', 'STCaiyun',
'FZShuTi', 'FZYaoti',
# Common Traditional Chinese (Office)
'DFKai-SB', 'MingLiU', 'PMingLiU', 'MingLiU-ExtB', 'PMingLiU-ExtB',
'Microsoft JhengHei UI',
# Japanese fonts (Windows-available)
'Yu Gothic', 'Yu Gothic UI', 'Yu Mincho',
'Meiryo', 'Meiryo UI', 'メイリオ',
'MS Gothic', 'MS Mincho', 'MS PGothic', 'MS PMincho', 'MS UI Gothic',
# Korean
'Malgun Gothic', 'Gulim', 'Dotum', 'Batang',
'Noto Sans KR', 'Noto Serif KR',
}
SYSTEM_FONTS = {'system-ui', '-apple-system', 'BlinkMacSystemFont'}
# macOS/Linux-only fonts -> Windows equivalents
FONT_FALLBACK_WIN = {
'PingFang SC': 'Microsoft YaHei',
'PingFang TC': 'Microsoft JhengHei',
'PingFang HK': 'Microsoft JhengHei',
'Hiragino Sans': 'Microsoft YaHei',
'Hiragino Sans GB': 'Microsoft YaHei',
'Hiragino Mincho ProN': 'SimSun',
'STHeiti': 'SimHei',
'STSong': 'SimSun',
'STKaiti': 'KaiTi',
'STFangsong': 'FangSong',
'STXihei': 'Microsoft YaHei',
'STZhongsong': 'SimSun',
'Songti SC': 'SimSun',
'Songti TC': 'SimSun',
'Noto Sans SC': 'Microsoft YaHei',
'Noto Sans TC': 'Microsoft JhengHei',
'Noto Serif SC': 'SimSun',
'Noto Serif TC': 'SimSun',
# Japanese: keep as-is if user specified (PowerPoint will fallback if uninstalled)
# 'Noto Sans JP': → keep as 'Noto Sans JP' (do not map)
# 'メイリオ': → keep as 'メイリオ' (Meiryo alias)
'メイリオ': 'Meiryo',
'Source Han Sans SC': 'Microsoft YaHei',
'Source Han Sans TC': 'Microsoft JhengHei',
'Source Han Serif SC': 'SimSun',
'Source Han Serif TC': 'SimSun',
'Source Han Sans JP': 'Noto Sans JP',
'Source Han Serif JP': 'Noto Serif JP',
'WenQuanYi Micro Hei': 'Microsoft YaHei',
'WenQuanYi Zen Hei': 'Microsoft YaHei',
# Latin fonts (macOS / Linux / Web -> Windows)
'SF Pro': 'Segoe UI',
'SF Pro Display': 'Segoe UI',
'SF Pro Text': 'Segoe UI',
'SF Mono': 'Consolas',
'Menlo': 'Consolas',
'Monaco': 'Consolas',
'Helvetica Neue': 'Arial',
'Helvetica': 'Arial',
'Roboto': 'Segoe UI',
'Ubuntu': 'Segoe UI',
'Liberation Sans': 'Arial',
'Liberation Serif': 'Times New Roman',
'Liberation Mono': 'Consolas',
'DejaVu Sans': 'Segoe UI',
'DejaVu Serif': 'Times New Roman',
'DejaVu Sans Mono': 'Consolas',
}
GENERIC_FONT_MAP = {
'monospace': 'Consolas',
'sans-serif': 'Segoe UI',
'serif': 'Times New Roman',
}
# When the latin font is serif and no EA font is specified,
# prefer SimSun (serif CJK) over Microsoft YaHei (sans-serif CJK).
_SERIF_LATIN = {
'Times New Roman', 'Georgia', 'Garamond', 'Palatino', 'Palatino Linotype',
'Book Antiqua', 'Cambria', 'SimSun', 'Liberation Serif', 'DejaVu Serif',
}
# SVG stroke-dasharray -> DrawingML prstDash
DASH_PRESETS = {
'4,4': 'dash', '4 4': 'dash',
'6,3': 'dash', '6 3': 'dash',
'2,2': 'sysDot', '2 2': 'sysDot',
'8,4': 'lgDash', '8 4': 'lgDash',
'8,4,2,4': 'lgDashDot', '8 4 2 4': 'lgDashDot',
}
# ---------------------------------------------------------------------------
# Coordinate helpers
# ---------------------------------------------------------------------------
def px_to_emu(px: float) -> int:
"""Convert SVG pixels to EMU."""
return round(px * EMU_PER_PX)
def _f(val: str | None, default: float = 0.0) -> float:
"""Parse a float attribute value, returning default if missing."""
if val is None:
return default
try:
return float(val)
except (ValueError, TypeError):
return default
# ---------------------------------------------------------------------------
# SVG transform matrix helpers
# ---------------------------------------------------------------------------
_TRANSFORM_RE = re.compile(r'([a-zA-Z]+)\(([^)]*)\)')
_NUMBER_RE = re.compile(r'[-+]?(?:\d*\.\d+|\d+\.?)(?:[eE][-+]?\d+)?')
def matrix_multiply(left: AffineMatrix, right: AffineMatrix) -> AffineMatrix:
"""Compose two SVG affine matrices, applying ``right`` before ``left``."""
a1, b1, c1, d1, e1, f1 = left
a2, b2, c2, d2, e2, f2 = right
return (
a1 * a2 + c1 * b2,
b1 * a2 + d1 * b2,
a1 * c2 + c1 * d2,
b1 * c2 + d1 * d2,
a1 * e2 + c1 * f2 + e1,
b1 * e2 + d1 * f2 + f1,
)
def _translate_matrix(tx: float, ty: float = 0.0) -> AffineMatrix:
return (1.0, 0.0, 0.0, 1.0, tx, ty)
def _scale_matrix(sx: float, sy: float | None = None) -> AffineMatrix:
return (sx, 0.0, 0.0, sx if sy is None else sy, 0.0, 0.0)
def _rotate_matrix(angle_deg: float, cx: float | None = None, cy: float | None = None) -> AffineMatrix:
rad = math.radians(angle_deg)
cos_a = math.cos(rad)
sin_a = math.sin(rad)
rot = (cos_a, sin_a, -sin_a, cos_a, 0.0, 0.0)
if cx is None or cy is None:
return rot
return matrix_multiply(
matrix_multiply(_translate_matrix(cx, cy), rot),
_translate_matrix(-cx, -cy),
)
def parse_transform_matrix(transform_str: str) -> AffineMatrix:
"""Parse an SVG transform list into one affine matrix."""
if not transform_str:
return IDENTITY_MATRIX
matrix = IDENTITY_MATRIX
for name, raw_args in _TRANSFORM_RE.findall(transform_str):
args = [float(n) for n in _NUMBER_RE.findall(raw_args)]
name = name.lower()
local = IDENTITY_MATRIX
if name == 'matrix' and len(args) >= 6:
local = (args[0], args[1], args[2], args[3], args[4], args[5])
elif name == 'translate' and args:
local = _translate_matrix(args[0], args[1] if len(args) > 1 else 0.0)
elif name == 'scale' and args:
local = _scale_matrix(args[0], args[1] if len(args) > 1 else None)
elif name == 'rotate' and args:
local = _rotate_matrix(
args[0],
args[1] if len(args) > 2 else None,
args[2] if len(args) > 2 else None,
)
matrix = matrix_multiply(matrix, local)
return matrix
def transform_point(matrix: AffineMatrix, x: float, y: float) -> tuple[float, float]:
"""Apply an SVG affine matrix to a point."""
a, b, c, d, e, f = matrix
return a * x + c * y + e, b * x + d * y + f
def rect_to_dml_xfrm(
x: float,
y: float,
w: float,
h: float,
matrix: AffineMatrix,
) -> tuple[str, int, int, int, int, tuple[int, int, int, int]]:
"""Map a transformed SVG rectangle to DrawingML xfrm attributes.
DrawingML can represent rotated/flipped rectangles, but not arbitrary
shear. Template-import picture wrappers only use translate/rotate/scale,
so decomposing the transformed local X/Y axes is sufficient here.
"""
p0 = transform_point(matrix, x, y)
p1 = transform_point(matrix, x + w, y)
p2 = transform_point(matrix, x + w, y + h)
p3 = transform_point(matrix, x, y + h)
ux = p1[0] - p0[0]
uy = p1[1] - p0[1]
vx = p3[0] - p0[0]
vy = p3[1] - p0[1]
rect_w = max(math.hypot(ux, uy), 0.001)
rect_h = max(math.hypot(vx, vy), 0.001)
cross = ux * vy - uy * vx
if cross < 0:
angle_deg = math.degrees(math.atan2(-uy, -ux))
flip_attr = ' flipH="1"'
else:
angle_deg = math.degrees(math.atan2(uy, ux))
flip_attr = ''
rot = round(angle_deg * ANGLE_UNIT)
rot_attr = f' rot="{rot}"' if rot else ''
center_x = (p0[0] + p2[0]) / 2
center_y = (p0[1] + p2[1]) / 2
off_x = px_to_emu(center_x - rect_w / 2)
off_y = px_to_emu(center_y - rect_h / 2)
ext_cx = px_to_emu(rect_w)
ext_cy = px_to_emu(rect_h)
xs = [p0[0], p1[0], p2[0], p3[0]]
ys = [p0[1], p1[1], p2[1], p3[1]]
bounds = (
px_to_emu(min(xs)),
px_to_emu(min(ys)),
px_to_emu(max(xs)),
px_to_emu(max(ys)),
)
return f'{flip_attr}{rot_attr}', off_x, off_y, ext_cx, ext_cy, bounds
def _extract_inheritable_styles(elem: ET.Element) -> dict[str, str]:
"""Extract all SVG-inheritable presentation attributes from an element."""
styles: dict[str, str] = {}
for attr in INHERITABLE_ATTRS:
val = elem.get(attr)
if val is not None:
styles[attr] = val
return styles
def _get_attr(elem: ET.Element, attr: str, ctx: ConvertContext) -> str | None:
"""Get effective attribute: element's own value first, then inherited."""
val = elem.get(attr)
if val is not None:
return val
return ctx.inherited_styles.get(attr)
def ctx_x(val: float, ctx: ConvertContext) -> float:
"""Apply context scale + translate to an X coordinate."""
return val * ctx.scale_x + ctx.translate_x
def ctx_y(val: float, ctx: ConvertContext) -> float:
"""Apply context scale + translate to a Y coordinate."""
return val * ctx.scale_y + ctx.translate_y
def ctx_w(val: float, ctx: ConvertContext) -> float:
"""Apply context scale to a width value."""
return val * ctx.scale_x
def ctx_h(val: float, ctx: ConvertContext) -> float:
"""Apply context scale to a height value."""
return val * ctx.scale_y
# ---------------------------------------------------------------------------
# Color / style parsing
# ---------------------------------------------------------------------------
def parse_hex_color(color_str: str) -> str | None:
"""Parse '#RRGGBB' or '#RGB' to 'RRGGBB'. Returns None on failure."""
if not color_str:
return None
color_str = color_str.strip()
if color_str.startswith('#'):
color_str = color_str[1:]
if len(color_str) == 3:
color_str = ''.join(c * 2 for c in color_str)
if len(color_str) == 6 and all(c in '0123456789abcdefABCDEF' for c in color_str):
return color_str.upper()
return None
def parse_stop_style(style_str: str) -> tuple[str | None, float]:
"""Parse a gradient stop's style attribute.
Args:
style_str: Style string like 'stop-color:#XXX;stop-opacity:N'.
Returns:
(color, opacity) tuple.
"""
color = None
opacity = 1.0
if not style_str:
return color, opacity
for part in style_str.split(';'):
part = part.strip()
if part.startswith('stop-color:'):
color = parse_hex_color(part.split(':', 1)[1].strip())
elif part.startswith('stop-opacity:'):
try:
opacity = float(part.split(':', 1)[1].strip())
except ValueError:
pass
return color, opacity
def resolve_url_id(url_str: str) -> str | None:
"""Extract ID from 'url(#someId)' reference."""
if not url_str:
return None
m = re.match(r'url\(#([^)]+)\)', url_str.strip())
return m.group(1) if m else None
def get_effective_filter_id(elem: ET.Element, ctx: ConvertContext) -> str | None:
"""Get the effective filter ID for an element, including inherited context."""
filt = elem.get('filter')
if filt:
return resolve_url_id(filt)
return ctx.filter_id
# ---------------------------------------------------------------------------
# Font parsing
# ---------------------------------------------------------------------------
def parse_font_family(font_family_str: str) -> dict[str, str]:
"""Parse CSS font-family into latin/ea typeface names.
Prioritizes Windows-available fonts since PPTX is primarily opened on
Windows. macOS/Linux-only fonts are mapped via FONT_FALLBACK_WIN.
"""
if not font_family_str:
return {'latin': 'Segoe UI', 'ea': 'Microsoft YaHei'}
fonts = [f.strip().strip("'\"") for f in font_family_str.split(',')]
latin_font = None
ea_font = None
for font in fonts:
if font in SYSTEM_FONTS:
continue
if font in GENERIC_FONT_MAP:
resolved = GENERIC_FONT_MAP[font]
latin_font = latin_font or resolved
continue
win_font = FONT_FALLBACK_WIN.get(font, font)
if font in EA_FONTS:
ea_font = ea_font or win_font
else:
latin_font = latin_font or win_font
# PPT renders CJK text via latin typeface when ea doesn't match
if not latin_font and ea_font:
latin_font = ea_font
final_latin = latin_font or 'Segoe UI'
# EA must always be a CJK-capable font
if not ea_font:
ea_font = 'SimSun' if final_latin in _SERIF_LATIN else 'Microsoft YaHei'
return {'latin': final_latin, 'ea': ea_font}
def is_cjk_char(ch: str) -> bool:
"""Check if a character is CJK (Chinese/Japanese/Korean)."""
cp = ord(ch)
return (0x4E00 <= cp <= 0x9FFF or 0x3400 <= cp <= 0x4DBF or
0x2E80 <= cp <= 0x2EFF or 0x3000 <= cp <= 0x303F or
0xFF00 <= cp <= 0xFFEF or 0xF900 <= cp <= 0xFAFF or
0x20000 <= cp <= 0x2A6DF)
def detect_text_lang(text: str) -> str:
"""Return a DrawingML language tag for a text run."""
return 'zh-CN' if any(is_cjk_char(ch) for ch in text) else 'en-US'
def resolve_text_run_fonts(text: str, fonts: dict[str, str]) -> dict[str, str]:
"""Return DrawingML latin/ea/cs typefaces for one text run."""
latin = fonts['latin']
if detect_text_lang(text) == 'zh-CN':
ea = fonts['ea']
else:
ea = latin
return {'latin': latin, 'ea': ea, 'cs': latin}
def estimate_text_width(text: str, font_size: float, font_weight: str = '400') -> float:
"""Estimate text width in SVG pixels."""
width = 0.0
for ch in text:
if is_cjk_char(ch):
width += font_size
elif ch == ' ':
width += font_size * 0.3
elif ch in 'mMwWOQ%':
width += font_size * 0.75
elif ch in 'iIlj!|':
width += font_size * 0.3
elif ch.isdigit():
# digits are tabular (uniform ~0.55em) in most UI fonts, including
# '1' — classing it with 'il|' under-sizes the box and makes
# renderers that ignore wrap="none" (LibreOffice) wrap the line
width += font_size * 0.55
else:
width += font_size * 0.55
if font_weight in ('bold', '600', '700', '800', '900'):
width *= 1.05
return width
def _xml_escape(text: str) -> str:
"""Escape XML special characters."""
return (text.replace('&', '&amp;')
.replace('<', '&lt;')
.replace('>', '&gt;')
.replace('"', '&quot;'))

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,641 @@
"""CLI entry point for svg_to_pptx."""
from __future__ import annotations
import sys
import json
import shutil
import argparse
from datetime import datetime
from pathlib import Path
if __package__ in {None, ''}:
import types
package = types.ModuleType('svg_to_pptx')
package.__path__ = [str(Path(__file__).resolve().parent)] # type: ignore[attr-defined]
sys.modules.setdefault('svg_to_pptx', package)
__package__ = 'svg_to_pptx'
from .pptx_dimensions import CANVAS_FORMATS, get_project_info, get_viewbox_dimensions
from .pptx_discovery import find_svg_files, find_notes_files
from .pptx_builder import create_pptx_with_native_svg
from .pptx_narration import NARRATION_EXTENSIONS, find_narration_files, probe_audio_duration
from .pptx_slide_xml import TRANSITIONS
from .animation_config import load_animation_config, validate_animation_config
try:
from pptx_animations import ANIMATIONS as _ANIMATIONS
except ImportError:
_ANIMATIONS = {}
def _as_dict(value: object) -> dict:
return value if isinstance(value, dict) else {}
def _recorded_narration_on_click_slides(
ref_files: list[Path],
animation_config: dict | None,
animation: str | None,
animation_trigger: str,
animation_cli_overrides: dict[str, bool],
) -> list[str]:
"""Return slides whose effective recorded-video animation trigger is on-click."""
slides_cfg = _as_dict(_as_dict(animation_config).get('slides'))
blocked: list[str] = []
for svg_path in ref_files:
slide_cfg = _as_dict(slides_cfg.get(svg_path.stem))
anim_cfg = _as_dict(slide_cfg.get('animation'))
slide_animation = animation
if not animation_cli_overrides.get('animation') and 'effect' in anim_cfg:
cfg_effect = str(anim_cfg.get('effect'))
slide_animation = None if cfg_effect == 'none' else cfg_effect
if slide_animation is None:
continue
slide_trigger = animation_trigger
if not animation_cli_overrides.get('animation_trigger') and anim_cfg.get('trigger'):
slide_trigger = str(anim_cfg.get('trigger'))
if slide_trigger == 'on-click':
blocked.append(svg_path.stem)
return blocked
def main(argv: list[str] | None = None) -> int:
"""CLI entry point for the SVG to PPTX conversion tool."""
transition_choices = (
['none'] + (list(TRANSITIONS.keys()) if TRANSITIONS
else ['fade', 'push', 'wipe', 'split', 'strips', 'cover', 'random'])
)
animation_choices = (
['none'] + (list(_ANIMATIONS.keys()) if _ANIMATIONS
else ['fade', 'fly', 'zoom', 'appear'])
+ ['auto', 'mixed', 'random']
)
parser = argparse.ArgumentParser(
description='PPT Master - SVG to PPTX Tool (Office Compatibility Mode)',
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=f'''
Examples:
%(prog)s examples/ppt169_demo -s final # Default: native pptx -> exports/, svg_output -> backup/<ts>/
%(prog)s examples/ppt169_demo --svg-snapshot # Also emit SVG-rendered snapshot pptx alongside native in exports/
%(prog)s examples/ppt169_demo --only legacy # Only SVG image version (skips native)
%(prog)s examples/ppt169_demo -o out.pptx # Explicit path (no backup/)
# Disable transition / change transition effect
%(prog)s examples/ppt169_demo -t none
%(prog)s examples/ppt169_demo -t push --transition-duration 1.0
SVG source directory (-s):
output - svg_output (original version)
final - svg_final (post-processed, recommended)
<any> - Specify a subdirectory name directly
Transition effects (-t/--transition):
{', '.join(transition_choices)}
Per-element entrance animation (-a/--animation, native shapes mode):
{', '.join(animation_choices)}
Notes: applied to top-level <g id="..."> SVG groups in z-order. Default is
"auto" (map effect from group id: chartwipe, card-/step-/pillar-fly,
title/takeawayfade; image-like ids hero/figure-/image/img-/kpi cycle
zoom/dissolve/circle/box/diamond/wheel so multiple images vary across
the deck; unmatched ids cycle fade/wipe/fly/zoom). Start mode set by
--animation-trigger, matching PowerPoint's Start dropdown:
on-click one presenter click per group
with-previous all groups start together on slide entry
after-previous (default) cascade on slide entry;
gap = --animation-stagger seconds
mixed (legacy) cycles a larger 16-effect pool by group order;
random samples from the same legacy pool. Use "-a none" to disable.
Compatibility mode (enabled by default):
- Automatically generates PNG fallback images, SVG embedded as extension
- Compatible with all Office versions (including Office LTSC 2021)
- Newer Office still displays SVG (editable), older versions display PNG
- Requires svglib: pip install svglib reportlab
- Use --no-compat to disable (only Office 2019+ supported)
Speaker notes (enabled by default):
- Automatically reads Markdown notes files from the notes/ directory
- Supports two naming conventions:
1. Match by filename (recommended): 01_cover.md corresponds to 01_cover.svg
2. Match by index: slide01.md corresponds to the 1st SVG (backward compatible)
- Use --no-notes to disable
Recorded narration:
%(prog)s examples/ppt169_demo -s final --recorded-narration audio
- Keeps speaker notes when enabled
- Prepares PowerPoint recorded timings and narrations
- Requires one m4a/mp3/wav file per slide
- Embeds per-slide audio matched by SVG filename / slide number
- Sets slide auto-advance from audio duration so video export can use
"recorded timings and narrations"
- Rejects on-click object animations; use after-previous or with-previous
%(prog)s examples/ppt169_demo --narration-audio-dir audio
- Lower-level audio embedding: embeds matched files but allows partial matches
- Use only when you do not need a complete recorded-timings export
''',
)
parser.add_argument('project_path', type=str, help='Project directory path')
parser.add_argument('-o', '--output', type=str, default=None, help='Output file path')
parser.add_argument('-s', '--source', type=str, default=None,
help='SVG source directory. Default: native reads '
'svg_output/ (high-fidelity, preserves icons / '
'preserveAspectRatio / rx-ry); legacy reads '
'svg_final/ (PPT-internal SVG parser fallback). '
'Pass output/final/<name> to force one source.')
parser.add_argument('-f', '--format', type=str,
choices=list(CANVAS_FORMATS.keys()), default=None,
help='Specify canvas format')
parser.add_argument('-q', '--quiet', action='store_true', help='Quiet mode')
parser.add_argument('--no-compat', action='store_true',
help='Disable Office compatibility mode (pure SVG only, requires Office 2019+)')
mode_group = parser.add_mutually_exclusive_group()
mode_group.add_argument('--only', type=str, choices=['native', 'legacy'], default=None,
help='Only generate one version: native (editable shapes) or legacy (SVG image)')
mode_group.add_argument('--native', action='store_true', default=False,
help='(Deprecated, now default) Convert SVG to native DrawingML shapes')
merge_group = parser.add_mutually_exclusive_group()
merge_group.add_argument('--merge-paragraphs', action='store_true', dest='merge_paragraphs',
help='Compatibility no-op: mergeable paragraph blocks are merged '
'by default.')
merge_group.add_argument('--no-merge', action='store_false', dest='merge_paragraphs',
help='Disable paragraph merging. Every dy-stacked line becomes '
'its own text frame for strict SVG line-layout fidelity.')
parser.set_defaults(merge_paragraphs=True)
parser.add_argument('--conversion-trace', action='store_true', default=False,
help='Write a JSON diagnostics report next to the native PPTX '
'(<output>.trace.json). Records per-slide SVG element '
'conversion decisions for debugging.')
parser.add_argument('--svg-snapshot', action='store_true', default=False,
help='Also emit the SVG-rendered snapshot pptx alongside the native pptx in exports/ '
'(named <project>_<ts>_svg.pptx). Off by default — the native pptx is the '
'canonical output; live preview already provides the SVG visual reference. '
'Note: the svg_output/ source snapshot is always written to backup/<ts>/ '
'regardless of this flag.')
def non_negative_float(value: str) -> float:
try:
number = float(value)
except ValueError as exc:
raise argparse.ArgumentTypeError(f"must be a number: {value}") from exc
if number < 0:
raise argparse.ArgumentTypeError("must be non-negative")
return number
parser.add_argument('-t', '--transition', type=str, choices=transition_choices, default=None,
help='Page transition effect (default: fade, use "none" to disable)')
parser.add_argument('--transition-duration', type=non_negative_float, default=None,
help='Transition duration in seconds (default: 0.4)')
parser.add_argument('--auto-advance', type=non_negative_float, default=None,
help='Auto-advance interval in seconds (default: manual advance)')
parser.add_argument('-a', '--animation', type=str, choices=animation_choices,
default=None,
help='Per-element entrance animation (native shapes mode '
'only). Default "none" (no auto element builds; page '
'transitions still apply). Pick a single effect, "auto" '
'(map effect from group id — image-like ids cycle a '
'richer pool for visual variation, fallback cycles fade/'
'wipe/fly/zoom), "mixed" (legacy 16-effect pool), or '
'"random".')
parser.add_argument('--animation-duration', type=non_negative_float, default=None,
help='Per-element entrance duration in seconds (default: 0.4)')
parser.add_argument('--animation-trigger', type=str,
choices=['on-click', 'with-previous', 'after-previous'],
default=None,
help='Per-element Start mode (matches PowerPoint Start dropdown): '
'"on-click" (one click per element), '
'"with-previous" (all start together on slide entry), '
'"after-previous" (default, cascade after the previous element).')
parser.add_argument('--animation-stagger', type=non_negative_float, default=None,
help='Delay between elements in --animation-trigger=after-previous '
'(seconds, default 0.5). Ignored in other modes.')
parser.add_argument('--animation-config', type=str, default=None,
help='Optional per-slide/per-object animation config. '
'Default: <project>/animations.json when present.')
parser.add_argument('--no-notes', action='store_true',
help='Disable speaker notes embedding (enabled by default)')
parser.add_argument('--narration-audio-dir', type=str, default=None,
help='Low-level audio embedding from this directory; allows partial matches')
parser.add_argument('--use-narration-timings', action='store_true',
help='Set slide auto-advance timings from narration audio durations')
parser.add_argument('--recorded-narration', type=str, default=None,
help='Prepare PowerPoint recorded timings and narrations from a complete audio directory')
parser.add_argument('--narration-padding', type=float, default=0.5,
help='Seconds to add after each narration before auto-advance (default: 0.5)')
parser.add_argument('--cache-dir', type=str, default=None,
help='Cache directory for SVG→PNG renders (default: '
'<project>/.cache/svg_png). Cache key uses SVG content '
'hash + size + renderer; safe across renderer switches. '
'Removed automatically after a successful export.')
parser.add_argument('--no-cache', action='store_true',
help='Disable the SVG→PNG cache for this run (still parallel).')
parser.add_argument('--keep-cache', action='store_true',
help='Keep the SVG→PNG cache directory after export '
'(default: removed on success to keep project clean).')
parser.add_argument('--workers', type=int, default=None,
help='Parallel workers for SVG→PNG pre-rendering. '
'Default: min(cpu, pages, 8). Set 1 for sequential.')
args = parser.parse_args(argv)
project_path = Path(args.project_path)
if not project_path.exists():
print(f"Error: Path does not exist: {project_path}")
return 1
try:
project_info = get_project_info(str(project_path))
project_name = project_info.get('name', project_path.name)
detected_format = project_info.get('format')
except Exception:
project_name = project_path.name
detected_format = None
canvas_format = args.format
if canvas_format is None and detected_format and detected_format != 'unknown':
canvas_format = detected_format
# Determine which versions to generate.
# Default is native-only; SVG snapshot is opt-in via --svg-snapshot.
# --only native / --only legacy still force a single version explicitly.
only_mode = args.only
if only_mode == 'native':
gen_native, gen_legacy = True, False
elif only_mode == 'legacy':
gen_native, gen_legacy = False, True
else:
gen_native = True
gen_legacy = args.svg_snapshot
# Pipeline split: native pptx gets the high-fidelity svg_output/ source
# (icons, preserveAspectRatio, rounded-rect rx/ry are all preserved by the
# converter); legacy pptx still needs svg_final/ because PowerPoint's
# internal SVG parser cannot handle <use data-icon> or honour
# preserveAspectRatio. An explicit -s overrides both branches so callers
# can keep the previous single-source behaviour for unusual workflows.
explicit_source = args.source is not None
native_source = args.source if explicit_source else 'output'
legacy_source = args.source if explicit_source else 'final'
native_files: list[Path] = []
legacy_files: list[Path] = []
native_source_dir = ''
legacy_source_dir = ''
if gen_native:
native_files, native_source_dir = find_svg_files(project_path, native_source)
if gen_legacy:
legacy_files, legacy_source_dir = find_svg_files(project_path, legacy_source)
# Reference list for cross-product lookups (notes / narration matching).
# native_files and legacy_files share filenames because svg_final/ is
# copytree'd from svg_output/, so either list works for matching.
ref_files = native_files or legacy_files
if not ref_files:
print("Error: No SVG files found")
return 1
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
backup_dir: Path | None = None
legacy_path: Path | None = None
if args.output:
output_base = Path(args.output)
native_path = output_base
if gen_legacy:
stem = output_base.stem
legacy_path = output_base.parent / f"{stem}_svg{output_base.suffix}"
else:
exports_dir = project_path / "exports"
exports_dir.mkdir(parents=True, exist_ok=True)
native_path = exports_dir / f"{project_name}_{timestamp}.pptx"
# svg_output/ snapshot always goes under backup/<ts>/ in default-flow
# mode (no -o). --svg-snapshot only controls the optional legacy
# SVG-rendered pptx, which now sits alongside the native pptx in
# exports/ rather than nested inside backup/.
backup_dir = project_path / "backup" / timestamp
if gen_legacy:
legacy_path = exports_dir / f"{project_name}_{timestamp}_svg.pptx"
native_path.parent.mkdir(parents=True, exist_ok=True)
if legacy_path is not None:
legacy_path.parent.mkdir(parents=True, exist_ok=True)
verbose = not args.quiet
# Honor the actual SVG pixels over a stale project-recorded format. The
# canvas_format read from project init can disagree with what the Executor
# actually drew — e.g. a mirror template imported at 2560×1440 while the
# project was initialized as ppt169 (1280×720). When the first SVG's real
# viewBox doesn't match the recorded format's dimensions, drop the format
# so the builder sizes the slide by pixels (custom_pixels path). Standard
# decks match exactly, so this only changes behavior on the conflict case.
# An explicit --format always wins and is never second-guessed.
if args.format is None and canvas_format:
fmt_info = CANVAS_FORMATS.get(canvas_format)
actual_dims = get_viewbox_dimensions(ref_files[0])
if fmt_info and actual_dims:
fmt_dims = (fmt_info.get('width'), fmt_info.get('height'))
if fmt_dims != actual_dims:
if verbose:
print(
f" Recorded format '{canvas_format}' "
f"({fmt_dims[0]}×{fmt_dims[1]}) differs from SVG viewBox "
f"({actual_dims[0]}×{actual_dims[1]}); exporting by SVG pixels"
)
canvas_format = None
enable_notes = not args.no_notes
notes: dict[str, str] = {}
if enable_notes:
notes = find_notes_files(project_path, ref_files)
narration_audio: dict[str, Path] = {}
narration_audio_dir_arg = args.recorded_narration or args.narration_audio_dir
use_narration_timings = args.use_narration_timings or bool(args.recorded_narration)
if narration_audio_dir_arg:
narration_audio_dir = Path(narration_audio_dir_arg)
if not narration_audio_dir.is_absolute():
narration_audio_dir = project_path / narration_audio_dir
if args.recorded_narration and not narration_audio_dir.is_dir():
print(
f"Error: Recorded narration directory does not exist: {narration_audio_dir}",
file=sys.stderr,
)
return 1
narration_audio = find_narration_files(narration_audio_dir, ref_files)
if verbose:
print(f" Narration audio directory: {narration_audio_dir}")
print(f" Narration audio matched: {len(narration_audio)}/{len(ref_files)} slide(s)")
if args.recorded_narration:
missing = [path.stem for path in ref_files if path.stem not in narration_audio]
if missing:
print(
"Error: Recorded narration requires one supported audio file per slide. "
f"Matched {len(narration_audio)}/{len(ref_files)} slide(s). "
f"Supported extensions: {', '.join(NARRATION_EXTENSIONS)}",
file=sys.stderr,
)
for stem in missing[:20]:
print(f" Missing audio for: {stem}", file=sys.stderr)
if len(missing) > 20:
print(f" ... and {len(missing) - 20} more", file=sys.stderr)
return 1
unreadable = [
f"{stem}: {audio_path}"
for stem, audio_path in sorted(narration_audio.items())
if probe_audio_duration(audio_path) is None
]
if unreadable:
print(
"Error: Recorded narration requires readable audio durations. "
"Install ffprobe/ffmpeg or replace the listed audio files.",
file=sys.stderr,
)
for item in unreadable[:20]:
print(f" {item}", file=sys.stderr)
if len(unreadable) > 20:
print(f" ... and {len(unreadable) - 20} more", file=sys.stderr)
return 1
elif narration_audio_dir_arg and verbose:
missing = [path.stem for path in ref_files if path.stem not in narration_audio]
if missing:
print(
f" [warn] Narration audio matched {len(narration_audio)}/{len(ref_files)} slide(s); "
"unmatched slides will export without audio."
)
if args.animation_config:
config_path = Path(args.animation_config)
if not config_path.is_absolute():
config_path = project_path / config_path
if not config_path.exists():
print(f"Error: Animation config does not exist: {config_path}")
return 1
try:
animation_config = load_animation_config(project_path, args.animation_config)
except Exception as exc:
print(f"Error: Failed to load animation config: {exc}")
return 1
if animation_config and verbose:
config_label = args.animation_config or str(project_path / 'animations.json')
print(f" Animation config: {config_label}")
for warning in validate_animation_config(project_path, animation_config):
print(f" [warn] {warning}")
defaults = animation_config.get('defaults', {}) if animation_config else {}
transition_defaults = defaults.get('transition', {}) if isinstance(defaults, dict) else {}
animation_defaults = defaults.get('animation', {}) if isinstance(defaults, dict) else {}
transition_arg = args.transition
transition_effect = (
transition_arg
if transition_arg is not None
else transition_defaults.get('effect', 'fade')
)
transition = None if transition_effect == 'none' else transition_effect
transition_duration = (
args.transition_duration
if args.transition_duration is not None
else float(transition_defaults.get('duration', 0.4))
)
animation_arg = args.animation
animation_effect = (
animation_arg
if animation_arg is not None
# Per-element entrance is opt-in by default: auto-firing element builds
# read as the "AI deck" tell and were unsolicited. Page transitions stay
# on (see transition default above). Re-enable with -a auto / animations.json.
else animation_defaults.get('effect', 'none')
)
animation = None if animation_effect == 'none' else animation_effect
animation_duration = (
args.animation_duration
if args.animation_duration is not None
else float(animation_defaults.get('duration', 0.4))
)
animation_stagger = (
args.animation_stagger
if args.animation_stagger is not None
else float(animation_defaults.get('stagger', 0.5))
)
animation_trigger = (
args.animation_trigger
if args.animation_trigger is not None
else animation_defaults.get('trigger', 'after-previous')
)
animation_cli_overrides = {
'transition': args.transition is not None,
'transition_duration': args.transition_duration is not None,
'auto_advance': args.auto_advance is not None,
'animation': args.animation is not None,
'animation_duration': args.animation_duration is not None,
'animation_stagger': args.animation_stagger is not None,
'animation_trigger': args.animation_trigger is not None,
}
if args.recorded_narration and gen_native:
on_click_slides = _recorded_narration_on_click_slides(
ref_files,
animation_config,
animation,
animation_trigger,
animation_cli_overrides,
)
if on_click_slides:
print(
"Error: --recorded-narration cannot be used with on-click object animations. "
"Use --animation-trigger after-previous or --animation-trigger with-previous.",
file=sys.stderr,
)
for slide in on_click_slides[:20]:
print(f" on-click trigger: {slide}", file=sys.stderr)
if len(on_click_slides) > 20:
print(f" ... and {len(on_click_slides) - 20} more", file=sys.stderr)
return 1
if args.no_cache:
cache_dir: Path | None = None
elif args.cache_dir:
cache_dir = Path(args.cache_dir)
if not cache_dir.is_absolute():
cache_dir = project_path / cache_dir
else:
cache_dir = project_path / '.cache' / 'svg_png'
# svg_files is per-product (native vs legacy may now read different
# directories); everything else is shared.
# Optional per-project document properties. Absent file → factual fields
# are still stamped at export; only the authored fields stay blank.
doc_metadata = None
metadata_path = project_path / 'metadata.json'
if metadata_path.is_file():
try:
loaded = json.loads(metadata_path.read_text(encoding='utf-8'))
except (json.JSONDecodeError, OSError) as exc:
print(f" [warn] metadata.json ignored ({exc})", file=sys.stderr)
else:
if isinstance(loaded, dict):
doc_metadata = loaded
if verbose:
print(f" Document properties: metadata.json ({len(loaded)} field(s))")
else:
print(" [warn] metadata.json ignored (top level is not an object)", file=sys.stderr)
shared_kwargs = dict(
canvas_format=canvas_format,
doc_metadata=doc_metadata,
verbose=verbose,
transition=transition,
transition_duration=transition_duration,
auto_advance=args.auto_advance,
use_compat_mode=not args.no_compat,
notes=notes,
enable_notes=enable_notes,
animation=animation,
animation_duration=animation_duration,
animation_stagger=animation_stagger,
animation_trigger=animation_trigger,
animation_config=animation_config,
animation_cli_overrides=animation_cli_overrides,
narration_audio=narration_audio,
use_narration_timings=use_narration_timings,
narration_padding=args.narration_padding,
cache_dir=cache_dir,
workers=args.workers,
merge_paragraphs=args.merge_paragraphs,
)
success = True
# --- Native shapes version (primary) ---
if gen_native:
if verbose:
print("PPT Master - SVG to PPTX Tool")
print("=" * 50)
print(f" Project path: {project_path}")
print(f" SVG directory: {native_source_dir}")
print(f" Output file: {native_path}")
print()
ok = create_pptx_with_native_svg(
output_path=native_path,
use_native_shapes=True,
svg_files=native_files,
conversion_trace_path=(
native_path.with_name(native_path.name + '.trace.json')
if args.conversion_trace else None
),
**shared_kwargs,
)
success = success and ok
# --- SVG image reference version ---
if gen_legacy:
if verbose:
if gen_native:
print()
print("-" * 50)
print("PPT Master - SVG to PPTX Tool (SVG Reference)")
print("=" * 50)
print(f" Project path: {project_path}")
print(f" SVG directory: {legacy_source_dir}")
print(f" Output file: {legacy_path}")
print()
ok = create_pptx_with_native_svg(
output_path=legacy_path,
use_native_shapes=False,
svg_files=legacy_files,
**shared_kwargs,
)
success = success and ok
# svg_output/ snapshot — runs once per export in default-flow mode,
# decoupled from --svg-snapshot. Preserves the AI-generated SVG sources
# under backup/<ts>/svg_output/ for later inspection / re-export.
if success and backup_dir is not None:
svg_output_src = project_path / "svg_output"
if svg_output_src.is_dir():
backup_dir.mkdir(parents=True, exist_ok=True)
svg_output_dst = backup_dir / "svg_output"
try:
shutil.copytree(svg_output_src, svg_output_dst)
if verbose:
print(f" svg_output backup: {svg_output_dst}")
except Exception as exc:
if verbose:
print(f" [warn] svg_output backup skipped: {exc}")
elif verbose:
print(f" [info] svg_output/ not found, backup skipped")
if success and cache_dir is not None and cache_dir.is_dir() and not args.keep_cache:
try:
shutil.rmtree(cache_dir)
cache_parent = cache_dir.parent
if cache_parent.is_dir() and cache_parent.name == '.cache' and not any(cache_parent.iterdir()):
cache_parent.rmdir()
except Exception as exc:
if verbose:
print(f" [warn] cache cleanup skipped: {exc}")
return 0 if success else 1
if __name__ == '__main__':
raise SystemExit(main())

View File

@ -0,0 +1,148 @@
"""Slide dimensions, format detection, EMU conversion, and constants."""
from __future__ import annotations
import re
import sys
from pathlib import Path
from xml.etree import ElementTree as ET
# Import project utility modules
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
try:
from project_utils import get_project_info
from config import CANVAS_FORMATS
except ImportError:
CANVAS_FORMATS = {
'ppt169': {'name': 'PPT 16:9', 'dimensions': '1280×720', 'viewbox': '0 0 1280 720'},
}
def get_project_info(path: str) -> dict:
return {'format': 'unknown', 'name': Path(path).name}
# EMU conversion constants
EMU_PER_INCH = 914400
EMU_PER_PIXEL = EMU_PER_INCH / 96
# XML namespaces
NAMESPACES = {
'a': 'http://schemas.openxmlformats.org/drawingml/2006/main',
'r': 'http://schemas.openxmlformats.org/officeDocument/2006/relationships',
'p': 'http://schemas.openxmlformats.org/presentationml/2006/main',
'asvg': 'http://schemas.microsoft.com/office/drawing/2016/SVG/main',
}
# Register namespaces for ElementTree output
for prefix, uri in NAMESPACES.items():
ET.register_namespace(prefix, uri)
def get_slide_dimensions(
canvas_format: str,
custom_pixels: tuple[int, int] | None = None,
) -> tuple[int, int]:
"""Get slide dimensions in EMU units.
Args:
canvas_format: Canvas format key (e.g. 'ppt169').
custom_pixels: Optional custom pixel dimensions override.
Returns:
(width_emu, height_emu) tuple.
"""
if custom_pixels:
width_px, height_px = custom_pixels
else:
if canvas_format not in CANVAS_FORMATS:
canvas_format = 'ppt169'
dimensions = CANVAS_FORMATS[canvas_format]['dimensions']
match = re.match(r'(\d+)[×x](\d+)', dimensions)
if match:
width_px = int(match.group(1))
height_px = int(match.group(2))
else:
width_px, height_px = 1280, 720
return int(width_px * EMU_PER_PIXEL), int(height_px * EMU_PER_PIXEL)
def get_pixel_dimensions(
canvas_format: str,
custom_pixels: tuple[int, int] | None = None,
) -> tuple[int, int]:
"""Get canvas pixel dimensions.
Args:
canvas_format: Canvas format key.
custom_pixels: Optional custom pixel dimensions override.
Returns:
(width_px, height_px) tuple.
"""
if custom_pixels:
return custom_pixels
if canvas_format not in CANVAS_FORMATS:
canvas_format = 'ppt169'
dimensions = CANVAS_FORMATS[canvas_format]['dimensions']
match = re.match(r'(\d+)[×x](\d+)', dimensions)
if match:
return int(match.group(1)), int(match.group(2))
return 1280, 720
def get_viewbox_dimensions(svg_path: Path) -> tuple[int, int] | None:
"""Extract pixel dimensions from SVG viewBox.
Args:
svg_path: Path to the SVG file.
Returns:
(width, height) as integers, or None if not found.
"""
try:
with open(svg_path, 'r', encoding='utf-8') as f:
content = f.read(2000)
match = re.search(r'viewBox="([^"]+)"', content)
if not match:
return None
parts = re.split(r'[\s,]+', match.group(1).strip())
if len(parts) < 4:
return None
width = float(parts[2])
height = float(parts[3])
if width <= 0 or height <= 0:
return None
return int(round(width)), int(round(height))
except Exception:
return None
def detect_format_from_svg(svg_path: Path) -> str | None:
"""Detect canvas format from an SVG file's viewBox.
Args:
svg_path: Path to the SVG file.
Returns:
Canvas format key (e.g. 'ppt169'), or None if not detected.
"""
try:
with open(svg_path, 'r', encoding='utf-8') as f:
content = f.read(2000)
match = re.search(r'viewBox="([^"]+)"', content)
if match:
viewbox = match.group(1)
for fmt_key, fmt_info in CANVAS_FORMATS.items():
if fmt_info['viewbox'] == viewbox:
return fmt_key
except Exception:
pass
return None

View File

@ -0,0 +1,101 @@
"""Find SVG and notes files in a project directory."""
from __future__ import annotations
import re
from pathlib import Path
def find_svg_files(
project_path: Path,
source: str = 'output',
) -> tuple[list[Path], str]:
"""Find SVG files in the project.
Args:
project_path: Project directory path.
source: SVG source directory alias or name.
- 'output': svg_output (original version)
- 'final': svg_final (post-processed, recommended)
- or any subdirectory name
Returns:
(list_of_svg_files, actual_directory_name) tuple.
"""
dir_map = {
'output': 'svg_output',
'final': 'svg_final',
}
dir_name = dir_map.get(source, source)
svg_dir = project_path / dir_name
if not svg_dir.exists():
print(f" Warning: {dir_name} directory does not exist, trying svg_output")
dir_name = 'svg_output'
svg_dir = project_path / dir_name
if not svg_dir.exists():
if project_path.is_dir():
svg_dir = project_path
dir_name = project_path.name
else:
return [], ''
return sorted(svg_dir.glob('*.svg')), dir_name
def find_notes_files(
project_path: Path,
svg_files: list[Path] | None = None,
) -> dict[str, str]:
"""Find notes files and map them to SVG files.
Supports two matching modes (mixed matching supported):
1. Match by filename (priority): notes/01_cover.md -> 01_cover.svg
2. Match by index (backward compatible): notes/slide01.md -> 1st SVG
Args:
project_path: Project directory path.
svg_files: SVG file list (for filename matching).
Returns:
Dict mapping SVG filename stem to notes content.
"""
notes_dir = project_path / 'notes'
notes: dict[str, str] = {}
if not notes_dir.exists():
return notes
svg_stems_mapping: dict[str, int] = {}
svg_index_mapping: dict[int, str] = {}
if svg_files:
for i, svg_path in enumerate(svg_files, 1):
svg_stems_mapping[svg_path.stem] = i
svg_index_mapping[i] = svg_path.stem
for notes_file in notes_dir.glob('*.md'):
try:
with open(notes_file, 'r', encoding='utf-8') as f:
content = f.read().strip()
if not content:
continue
stem = notes_file.stem
# Try index-based matching (backward compat with slide01.md format)
match = re.search(r'slide[_]?(\d+)', stem)
if match:
index = int(match.group(1))
mapped_stem = svg_index_mapping.get(index)
if mapped_stem:
notes[mapped_stem] = content
# Filename-based matching (overrides index-based)
if stem in svg_stems_mapping:
notes[stem] = content
except Exception:
pass
return notes

View File

@ -0,0 +1,160 @@
"""SVG to PNG conversion for Office compatibility mode."""
from __future__ import annotations
import hashlib
import shutil
import tempfile
from pathlib import Path
# SVG to PNG library detection
# Prefer CairoSVG (better quality), fall back to svglib
PNG_RENDERER: str | None = None
try:
import cairosvg
PNG_RENDERER = 'cairosvg'
except (ImportError, OSError):
try:
from svglib.svglib import svg2rlg
from reportlab.graphics import renderPM
PNG_RENDERER = 'svglib'
except (ImportError, OSError):
pass
def get_png_renderer_info() -> tuple[str | None, str, str | None]:
"""Get PNG renderer status information.
Returns:
(renderer_name, status_text, install_hint) tuple.
"""
if PNG_RENDERER == 'cairosvg':
return ('cairosvg', '(full gradient/filter support)', None)
elif PNG_RENDERER == 'svglib':
return ('svglib', '(some gradients may be lost)',
'Install cairosvg for better results: pip install cairosvg')
else:
return (None, '(not installed)',
'Install via: pip install cairosvg or pip install svglib reportlab')
def convert_svg_to_png(
svg_path: Path,
png_path: Path,
width: int | None = None,
height: int | None = None,
) -> bool:
"""Convert SVG to PNG using the available renderer.
Args:
svg_path: SVG file path.
png_path: Output PNG file path.
width: Output width in pixels.
height: Output height in pixels.
Returns:
Whether the conversion was successful.
"""
if PNG_RENDERER is None:
return False
try:
if PNG_RENDERER == 'cairosvg':
cairosvg.svg2png(
url=str(svg_path),
write_to=str(png_path),
output_width=width,
output_height=height,
)
return True
elif PNG_RENDERER == 'svglib':
drawing = svg2rlg(str(svg_path))
if drawing is None:
print(f" Warning: Unable to parse SVG ({svg_path.name})")
return False
renderPM.drawToFile(
drawing,
str(png_path),
fmt="PNG",
configPIL={'quality': 95},
)
return True
except Exception as e:
print(f" Warning: SVG to PNG conversion failed ({svg_path.name}): {e}")
return False
return False
def _cache_key(svg_path: Path, width: int | None, height: int | None) -> str:
h = hashlib.sha256()
with open(svg_path, 'rb') as f:
for chunk in iter(lambda: f.read(65536), b''):
h.update(chunk)
return f"{h.hexdigest()}_{width or 0}x{height or 0}_{PNG_RENDERER or 'none'}"
def convert_svg_to_png_cached(
svg_path: Path,
png_path: Path,
width: int | None = None,
height: int | None = None,
cache_dir: Path | None = None,
) -> bool:
"""Cache-aware SVG→PNG conversion.
Returns True on success (cache hit or fresh render). Cache key bakes in
SVG content hash + size + renderer name; switching renderers invalidates
naturally. Failures are never cached.
"""
if cache_dir is None:
return convert_svg_to_png(svg_path, png_path, width, height)
if PNG_RENDERER is None:
return False
try:
key = _cache_key(svg_path, width, height)
except OSError as e:
print(f" Warning: Failed to hash SVG ({svg_path.name}): {e}")
return convert_svg_to_png(svg_path, png_path, width, height)
cached = cache_dir / f"{key}.png"
if cached.is_file():
try:
shutil.copy(cached, png_path)
return True
except OSError as e:
print(f" Warning: Cache copy failed, re-rendering ({svg_path.name}): {e}")
cache_dir.mkdir(parents=True, exist_ok=True)
tmp_fd, tmp_name = tempfile.mkstemp(suffix='.png', dir=str(cache_dir))
tmp_path = Path(tmp_name)
import os
os.close(tmp_fd)
ok = convert_svg_to_png(svg_path, tmp_path, width, height)
if not ok:
try:
tmp_path.unlink()
except OSError:
pass
return False
try:
os.replace(tmp_path, cached)
except OSError:
try:
tmp_path.unlink()
except OSError:
pass
try:
shutil.copy(cached, png_path)
return True
except OSError as e:
print(f" Warning: Cache copy failed ({svg_path.name}): {e}")
return False

View File

@ -0,0 +1,221 @@
"""Narration audio discovery and PPTX XML helpers."""
from __future__ import annotations
import base64
import json
import re
import subprocess
from pathlib import Path
MEDIA_REL_TYPE = "http://schemas.microsoft.com/office/2007/relationships/media"
AUDIO_REL_TYPE = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/audio"
IMAGE_REL_TYPE = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/image"
AUDIO_CONTENT_TYPES = {
".m4a": "audio/mp4",
".mp3": "audio/mpeg",
".wav": "audio/wav",
}
NARRATION_EXTENSIONS = tuple(AUDIO_CONTENT_TYPES.keys())
TRANSPARENT_PNG_BYTES = base64.b64decode(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAFgwJ/"
"lBf7WQAAAABJRU5ErkJggg=="
)
def _normalize_title(title: str) -> str:
text = re.sub(r"[^0-9A-Za-z\u4e00-\u9fff]+", "_", title.strip())
return re.sub(r"_+", "_", text).strip("_").lower()
def _leading_number(text: str) -> int | None:
match = re.match(r"^(\d{1,3})", text.strip())
return int(match.group(1)) if match else None
def find_narration_files(audio_dir: Path, svg_files: list[Path]) -> dict[str, Path]:
"""Return `{svg_stem: audio_path}` matched by exact stem, normalized stem, or index."""
if not audio_dir.exists() or not audio_dir.is_dir():
return {}
audio_files = [
path for path in sorted(audio_dir.iterdir())
if path.is_file() and path.suffix.lower() in NARRATION_EXTENSIONS
]
exact = {path.stem: path for path in audio_files}
normalized: dict[str, Path] = {}
numbered: dict[int, Path] = {}
for path in audio_files:
normalized.setdefault(_normalize_title(path.stem), path)
number = _leading_number(path.stem)
if number is not None:
numbered.setdefault(number, path)
matched: dict[str, Path] = {}
for index, svg in enumerate(svg_files, 1):
stem = svg.stem
if stem in exact:
matched[stem] = exact[stem]
continue
norm = _normalize_title(stem)
if norm in normalized:
matched[stem] = normalized[norm]
continue
if index in numbered:
matched[stem] = numbered[index]
return matched
def probe_audio_duration(audio_path: Path) -> float | None:
"""Return duration in seconds using ffprobe when available."""
try:
result = subprocess.run(
[
"ffprobe", "-v", "error",
"-show_entries", "format=duration",
"-of", "json",
str(audio_path),
],
check=True,
capture_output=True,
text=True,
encoding="utf-8",
)
data = json.loads(result.stdout or "{}")
duration = float(data.get("format", {}).get("duration", 0))
return duration if duration > 0 else None
except Exception:
return None
def next_shape_id(slide_xml: str) -> int:
ids = [int(value) for value in re.findall(r'<p:cNvPr[^>]*\sid="(\d+)"', slide_xml)]
return max(ids, default=1) + 1
def create_audio_pic_xml(
shape_id: int,
shape_name: str,
audio_rid: str,
media_rid: str,
poster_rid: str,
) -> str:
"""Create a tiny audio picture shape carrying narration media."""
return f'''<p:pic>
<p:nvPicPr>
<p:cNvPr id="{shape_id}" name="{shape_name}">
<a:hlinkClick r:id="" action="ppaction://media"/>
</p:cNvPr>
<p:cNvPicPr>
<a:picLocks noChangeAspect="1"/>
</p:cNvPicPr>
<p:nvPr>
<a:audioFile r:link="{audio_rid}"/>
<p:extLst>
<p:ext uri="{{DAA4B4D4-6D71-4841-9C94-3DE7FCFB9230}}">
<p14:media xmlns:p14="http://schemas.microsoft.com/office/powerpoint/2010/main" r:embed="{media_rid}"/>
</p:ext>
</p:extLst>
</p:nvPr>
</p:nvPicPr>
<p:blipFill>
<a:blip r:embed="{poster_rid}"/>
<a:stretch><a:fillRect/></a:stretch>
</p:blipFill>
<p:spPr>
<a:xfrm>
<a:off x="0" y="0"/>
<a:ext cx="1" cy="1"/>
</a:xfrm>
<a:prstGeom prst="rect"><a:avLst/></a:prstGeom>
</p:spPr>
</p:pic>'''
def _next_timing_id(slide_xml: str) -> int:
ids = [int(value) for value in re.findall(r'<p:cTn[^>]*\sid="(\d+)"', slide_xml)]
return max(ids, default=1) + 1
def _create_audio_timing_xml(shape_id: int, ctn_id: int) -> str:
return f'''<p:audio>
<p:cMediaNode vol="80000">
<p:cTn id="{ctn_id}" fill="hold" display="0">
<p:stCondLst><p:cond delay="0"/></p:stCondLst>
</p:cTn>
<p:tgtEl><p:spTgt spid="{shape_id}"/></p:tgtEl>
</p:cMediaNode>
</p:audio>'''
def inject_narration(
slide_xml: str,
*,
shape_id: int,
shape_name: str,
audio_rid: str,
media_rid: str,
poster_rid: str,
) -> str:
"""Inject a hidden narration media shape and slide-entry autoplay timing."""
audio_pic_xml = create_audio_pic_xml(
shape_id=shape_id,
shape_name=shape_name,
audio_rid=audio_rid,
media_rid=media_rid,
poster_rid=poster_rid,
)
slide_xml = slide_xml.replace("</p:spTree>", audio_pic_xml + "\n </p:spTree>", 1)
audio_timing_xml = _create_audio_timing_xml(shape_id, _next_timing_id(slide_xml))
if "<p:timing>" not in slide_xml:
timing_xml = f''' <p:timing>
<p:tnLst>
<p:par>
<p:cTn id="1" dur="indefinite" restart="never" nodeType="tmRoot">
<p:childTnLst>
{audio_timing_xml}
</p:childTnLst>
</p:cTn>
</p:par>
</p:tnLst>
</p:timing>'''
return slide_xml.replace("</p:sld>", timing_xml + "\n</p:sld>", 1)
pattern = re.compile(r'(<p:cTn\s+id="1"[^>]*>\s*<p:childTnLst>)', re.S)
if pattern.search(slide_xml):
return pattern.sub(r"\1\n " + audio_timing_xml, slide_xml, count=1)
return slide_xml.replace("</p:tnLst>", audio_timing_xml + "\n </p:tnLst>", 1)
def apply_recorded_timing(
slide_xml: str,
*,
advance_after: float,
transition_duration: float,
transition_effect: str | None = "fade",
) -> str:
"""Set slide auto-advance timing so exported video follows narration length."""
adv_ms = max(1, int(advance_after * 1000))
dur_ms = max(1, int(transition_duration * 1000))
transition_match = re.search(r"<p:transition\b[^>]*>", slide_xml)
if transition_match:
tag = transition_match.group(0)
if "advTm=" in tag:
new_tag = re.sub(r'\sadvTm="[^"]*"', f' advTm="{adv_ms}"', tag, count=1)
else:
new_tag = tag[:-1] + f' advTm="{adv_ms}">'
return slide_xml[:transition_match.start()] + new_tag + slide_xml[transition_match.end():]
effect = transition_effect or "fade"
transition_xml = f''' <p:transition p14:dur="{dur_ms}" xmlns:p14="http://schemas.microsoft.com/office/powerpoint/2010/main" advTm="{adv_ms}">
<p:{effect}/>
</p:transition>'''
if "<p:timing>" in slide_xml:
return slide_xml.replace("<p:timing>", transition_xml + "\n <p:timing>", 1)
return slide_xml.replace("</p:sld>", transition_xml + "\n</p:sld>", 1)

View File

@ -0,0 +1,162 @@
"""Markdown to plain text conversion and notes slide XML generation."""
from __future__ import annotations
import re
from .drawingml_utils import detect_text_lang
def markdown_to_plain_text(md_content: str) -> str:
"""Convert Markdown notes to plain text for PPTX notes.
Args:
md_content: Markdown formatted notes content.
Returns:
Plain text content.
"""
def strip_inline_bold(text: str) -> str:
text = re.sub(r'\*\*(.+?)\*\*', r'\1', text)
text = re.sub(r'__(.+?)__', r'\1', text)
return text
lines: list[str] = []
for line in md_content.split('\n'):
if line.startswith('#'):
text = re.sub(r'^#+\s*', '', line).strip()
text = strip_inline_bold(text)
if text:
lines.append(text)
lines.append('')
elif line.strip().startswith('- '):
item_text = line.strip()[2:]
item_text = strip_inline_bold(item_text)
lines.append('' + item_text)
elif line.strip():
text = strip_inline_bold(line.strip())
lines.append(text)
else:
lines.append('')
# Merge consecutive empty lines
result: list[str] = []
is_prev_empty = False
for line in lines:
if line == '':
if not is_prev_empty:
result.append(line)
is_prev_empty = True
else:
result.append(line)
is_prev_empty = False
return '\n'.join(result).strip()
def create_notes_slide_xml(slide_num: int, notes_text: str) -> str:
"""Create notes slide XML.
Args:
slide_num: Slide number.
notes_text: Notes text in plain text format.
Returns:
Notes slide XML string.
"""
notes_text = (notes_text
.replace('&', '&amp;')
.replace('<', '&lt;')
.replace('>', '&gt;'))
paragraphs: list[str] = []
for para in notes_text.split('\n'):
if para.strip():
lang = detect_text_lang(para)
paragraphs.append(f'''<a:p>
<a:r>
<a:rPr lang="{lang}" dirty="0"/>
<a:t>{para}</a:t>
</a:r>
</a:p>''')
else:
paragraphs.append('<a:p><a:endParaRPr lang="en-US" dirty="0"/></a:p>')
paragraphs_xml = (
'\n '.join(paragraphs)
if paragraphs
else '<a:p><a:endParaRPr lang="en-US" dirty="0"/></a:p>'
)
return f'''<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<p:notes xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"
xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"
xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main">
<p:cSld>
<p:spTree>
<p:nvGrpSpPr>
<p:cNvPr id="1" name=""/>
<p:cNvGrpSpPr/>
<p:nvPr/>
</p:nvGrpSpPr>
<p:grpSpPr>
<a:xfrm>
<a:off x="0" y="0"/>
<a:ext cx="0" cy="0"/>
<a:chOff x="0" y="0"/>
<a:chExt cx="0" cy="0"/>
</a:xfrm>
</p:grpSpPr>
<p:sp>
<p:nvSpPr>
<p:cNvPr id="2" name="Slide Image Placeholder 1"/>
<p:cNvSpPr>
<a:spLocks noGrp="1" noRot="1" noChangeAspect="1"/>
</p:cNvSpPr>
<p:nvPr>
<p:ph type="sldImg"/>
</p:nvPr>
</p:nvSpPr>
<p:spPr/>
</p:sp>
<p:sp>
<p:nvSpPr>
<p:cNvPr id="3" name="Notes Placeholder 2"/>
<p:cNvSpPr>
<a:spLocks noGrp="1"/>
</p:cNvSpPr>
<p:nvPr>
<p:ph type="body" idx="1"/>
</p:nvPr>
</p:nvSpPr>
<p:spPr/>
<p:txBody>
<a:bodyPr/>
<a:lstStyle/>
{paragraphs_xml}
</p:txBody>
</p:sp>
</p:spTree>
</p:cSld>
<p:clrMapOvr>
<a:masterClrMapping/>
</p:clrMapOvr>
</p:notes>'''
def create_notes_slide_rels_xml(slide_num: int) -> str:
"""Create notes slide relationship file XML.
Args:
slide_num: Slide number.
Returns:
Relationship file XML string.
"""
# No notesMaster relationship: the base PPTX produced by python-pptx does
# not ship a notesMaster part, so referencing one here would create a
# dangling rels Target and PowerPoint reports the file as corrupt.
return 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/slide" Target="../slides/slide{slide_num}.xml"/>
</Relationships>'''

View File

@ -0,0 +1,136 @@
"""Slide XML and slide relationship XML generation."""
from __future__ import annotations
# Import animation module (optional)
try:
from pptx_animations import create_transition_xml, TRANSITIONS
ANIMATIONS_AVAILABLE = True
except ImportError:
ANIMATIONS_AVAILABLE = False
TRANSITIONS = {}
def create_slide_xml_with_svg(
slide_num: int,
png_rid: str,
svg_rid: str,
width_emu: int,
height_emu: int,
transition: str | None = 'fade',
transition_duration: float = 0.5,
auto_advance: float | None = None,
use_compat_mode: bool = True,
) -> str:
"""Create slide XML containing an SVG image.
Args:
slide_num: Slide number.
png_rid: PNG fallback image relationship ID.
svg_rid: SVG relationship ID.
width_emu: Width in EMU.
height_emu: Height in EMU.
transition: Transition effect name.
transition_duration: Transition duration in seconds.
auto_advance: Auto-advance interval in seconds.
use_compat_mode: Whether to use compatibility mode (PNG + SVG dual format).
"""
transition_xml = ''
if transition and ANIMATIONS_AVAILABLE:
transition_xml = '\n' + create_transition_xml(
effect=transition,
duration=transition_duration,
advance_after=auto_advance,
)
if use_compat_mode:
blip_xml = f'''<a:blip r:embed="{png_rid}">
<a:extLst>
<a:ext uri="{{96DAC541-7B7A-43D3-8B79-37D633B846F1}}">
<asvg:svgBlip xmlns:asvg="http://schemas.microsoft.com/office/drawing/2016/SVG/main" r:embed="{svg_rid}"/>
</a:ext>
</a:extLst>
</a:blip>'''
else:
blip_xml = f'<a:blip r:embed="{svg_rid}"/>'
return f'''<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<p:sld xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"
xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"
xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main">
<p:cSld>
<p:spTree>
<p:nvGrpSpPr>
<p:cNvPr id="1" name=""/>
<p:cNvGrpSpPr/>
<p:nvPr/>
</p:nvGrpSpPr>
<p:grpSpPr>
<a:xfrm>
<a:off x="0" y="0"/>
<a:ext cx="0" cy="0"/>
<a:chOff x="0" y="0"/>
<a:chExt cx="0" cy="0"/>
</a:xfrm>
</p:grpSpPr>
<p:pic>
<p:nvPicPr>
<p:cNvPr id="2" name="SVG Image {slide_num}"/>
<p:cNvPicPr>
<a:picLocks noChangeAspect="1"/>
</p:cNvPicPr>
<p:nvPr/>
</p:nvPicPr>
<p:blipFill>
{blip_xml}
<a:stretch>
<a:fillRect/>
</a:stretch>
</p:blipFill>
<p:spPr>
<a:xfrm>
<a:off x="0" y="0"/>
<a:ext cx="{width_emu}" cy="{height_emu}"/>
</a:xfrm>
<a:prstGeom prst="rect">
<a:avLst/>
</a:prstGeom>
</p:spPr>
</p:pic>
</p:spTree>
</p:cSld>
<p:clrMapOvr>
<a:masterClrMapping/>
</p:clrMapOvr>{transition_xml}
</p:sld>'''
def create_slide_rels_xml(
png_rid: str,
png_filename: str,
svg_rid: str,
svg_filename: str,
use_compat_mode: bool = True,
) -> str:
"""Create slide relationship file XML.
Args:
png_rid: PNG image relationship ID.
png_filename: PNG filename.
svg_rid: SVG relationship ID.
svg_filename: SVG filename.
use_compat_mode: Whether to use compatibility mode.
"""
if use_compat_mode:
return 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"/>
<Relationship Id="{png_rid}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="../media/{png_filename}"/>
<Relationship Id="{svg_rid}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="../media/{svg_filename}"/>
</Relationships>'''
else:
return 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"/>
<Relationship Id="{svg_rid}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="../media/{svg_filename}"/>
</Relationships>'''

View File

@ -0,0 +1,48 @@
"""In-memory flattening of positional ``<tspan>`` elements.
DrawingML's text-run model has no way to express "jump to a new x/y inside
the same paragraph". Every ``<tspan>`` carrying ``x``, ``y`` or non-zero
``dy`` is therefore a layout instruction this converter cannot honour
inline without flattening, a 4-line dy-stacked block collapses onto a
single baseline and an x-anchored tspan jumps to the wrong column.
The on-disk ``finalize_svg`` pipeline solves this by promoting each
positional tspan to an independent ``<text>`` element. This module
performs the same transformation in memory so ``svg_to_pptx`` can consume
``svg_output/`` directly without that disk step.
Public API:
flatten_positional_tspans(tree) -> bool
Walk the SVG element tree, replace every positional ``<tspan>``
with an independent ``<text>``, and return whether anything
changed.
Heavy lifting is delegated to ``svg_finalize.flatten_tspan`` so the two
pipelines stay behaviourally aligned.
"""
from __future__ import annotations
import sys
from pathlib import Path
from xml.etree import ElementTree as ET
def flatten_positional_tspans(
tree: ET.ElementTree,
merge_paragraphs: bool = False,
) -> bool:
"""Flatten positional ``<tspan>`` elements into independent ``<text>``.
Delegates to ``svg_finalize.flatten_tspan.flatten_text_with_tspans`` so
the in-memory transform exactly matches the on-disk one. When
``merge_paragraphs`` is True, mergeable paragraph blocks are preserved
as a single <text> for downstream multi-<a:p> conversion.
Returns True if any tspan was rewritten.
"""
scripts_dir = Path(__file__).resolve().parent.parent
if str(scripts_dir) not in sys.path:
sys.path.insert(0, str(scripts_dir))
from svg_finalize.flatten_tspan import flatten_text_with_tspans # type: ignore
return flatten_text_with_tspans(tree, merge_paragraphs=merge_paragraphs)

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