feat(ppt): 视觉系统升级为卡片式 —— 治"生成效果不太行"

学 hugohe3/ppt-master 后定位根因:被 python-pptx 原语(平矩形+左色条+
圆点 bullet)摁死视觉天花板。选路径 B(升级 python-pptx 设计系统,保留
单脚本批量架构、原生可编辑)。

- pptx_helpers: 加 add_card(圆角+柔和投影)/add_gradient_rect/add_kpi/
  add_icon_tile/add_pill/add_eyebrow/add_chevron/add_notes;set_palette
  派生明暗色阶 WASH/SOFT/DARK;apply_brand 封面/章节改渐变大色块;
  所有 helper 把 name= 写进形状 .name(原来只喂 assert_inside)
- layouts.md: 9 版式重写成卡片式 + 扩到 13 种(KPI 卡/卡片网格/流程/大数字)
- quality_check: 跟新设计语言对齐 —— 三色制按色相归桶、标签按 .name 豁免
  小字号/bullet、≥40pt 展示字跳过溢出估算、bullet≤5 改按列判
- SKILL.md: opt-in 真实配图(imagegen, ¥0.22/张)+ 每页演讲者备注
- design_principles/SKILL_LIST/PROGRESS 同步

验证:13 版式全覆盖 demo + 6 页样例 deck quality_check 全过;单列 6-bullet
回归仍触发。未动 SVG 路线/live preview/动画(更大工程)。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
caoqianming 2026-06-08 15:28:37 +08:00
parent b95c247971
commit c0169e7766
7 changed files with 726 additions and 249 deletions

View File

@ -23,6 +23,7 @@
### 2026-06-08 ### 2026-06-08
- **ppt skill 视觉系统升级为「卡片式」(治"生成效果不太行")**:学 GitHub `hugohe3/ppt-master`(24.9k★)后定位根因——其好看的核心是「SVG 作画→转原生 PPTX」给足设计自由度,而 zcbot 被 python-pptx **原语**(平矩形+左色条+圆点 bullet)摁死了视觉天花板,出来就是"2010 办公模板"。岔路三选(A 自建 SVG→pptx 转换器=最高天花板但大工程且与"一脚本整建/少来回"冲突;B 升级 python-pptx 设计系统;C 混合),**选 B**(保留单脚本批量架构、原生可编辑、风险低)。落地:① `pptx_helpers.py` 加质感件——`add_card`(圆角矩形 `adjustments[0]` 调圆角 + `a:outerShdw` XML 柔和投影)/`add_gradient_rect`(`fill.gradient()` + 角度)/`add_kpi`(数字卡)/`add_icon_tile`(图标底块)/`add_pill`/`add_eyebrow`/`add_chevron`/`add_notes`(演讲者备注),`set_palette` 从主/辅/强调**派生明暗色阶** `PRIMARY_WASH/SOFT/DARK`+`ACCENT_SOFT`;`apply_brand` 封面/章节改**渐变大色块**;**所有 helper 把 `name=` 写进形状 `.name`**(原来只喂 assert_inside,导致 quality_check 拿不到语义名)。② `layouts.md` 9 版式重写成卡片式 + 扩到 **13 种**(加 L10 KPI 卡 / L11 卡片网格 / L12 流程 / L13 大数字论据)。③ **quality_check 跟新设计语言对齐**(否则每个 deck 淹在假警告里):三色制改**按色相归桶**判(主色深浅/wash tint 不算新色)、小字号/bullet 按 `.name` 豁免标签类、大号展示字(≥40pt)跳过溢出估算、bullet ≤5 改**按列**判(双栏 3+3 不误报、单列 6 仍抓)。④ SKILL.md 工作流加 opt-in 真实配图(走 imagegen,¥0.22/张,大纲标 `[img]`)+ 每页 `add_notes`;`design_principles.md` 加派生色阶/KPI 卡/图表透明底卡片化。验证:13 版式全覆盖 demo deck 建成 + quality_check 全过;单列 6-bullet 回归仍触发。改 `skills/ppt/{SKILL.md,scripts/pptx_helpers.py,scripts/quality_check.py,references/layouts.md,references/design_principles.md}` + `SKILL_LIST.md`。**未动**:SVG 路线(A)、live preview、动画——属更大工程,本轮不上。
- **system prompt 加「少来回」全局原则(广谱减轮)**:ppt 之外的长尾 task(改代码/跑数据/画图)没专属 skill 兜,加一条通用 `工作原则`:互相独立、不依赖中间结果的操作(建多页产物/批量改文件/生成整份产物)合到一个脚本或一轮并发 tool call 里做,别一步一 call(每轮重发整段上下文,轮数=token 体量线性乘数);但下一步输入要看上一步结果时(探索检索/按报错改/需用户确认)就老实分步,别硬批——精准措辞避免"过度批处理"踩掉该有的 checkpoint。定位是便宜补充(prompt 走缓存近零成本),不指望它动 100+ 轮大头(那靠结构改造)。改 `prompts/system/general_v1.md` - **system prompt 加「少来回」全局原则(广谱减轮)**:ppt 之外的长尾 task(改代码/跑数据/画图)没专属 skill 兜,加一条通用 `工作原则`:互相独立、不依赖中间结果的操作(建多页产物/批量改文件/生成整份产物)合到一个脚本或一轮并发 tool call 里做,别一步一 call(每轮重发整段上下文,轮数=token 体量线性乘数);但下一步输入要看上一步结果时(探索检索/按报错改/需用户确认)就老实分步,别硬批——精准措辞避免"过度批处理"踩掉该有的 checkpoint。定位是便宜补充(prompt 走缓存近零成本),不指望它动 100+ 轮大头(那靠结构改造)。改 `prompts/system/general_v1.md`
- **ppt skill 工作流批量化(减高轮数 task 的来回)**:实测高成本 task 几乎全是 100+ 轮的"逐步 tool 调用循环"(rust→PPT 34 轮、文献采集 245 轮),每轮重发整段上下文,轮数是 token 体量的线性乘数。ppt 是最易压、风险最低的试点:原 §阶段二**逐页**(每页 `读spec→glob图标→一个 run_python 加页→等用户确认→下一页`,N 页 ~2N 轮)。改法:① 阶段一 spec 增「逐页大纲」表(页|版式|标题|要点|图标),作为**替代逐页确认的前置 checkpoint**——改文字大纲比建完 slide 再推翻便宜;② 阶段二改成**写一个 `build_deck.py` 一次建整 deck**(同进程 `new_presentation`→按大纲循环 `add_slide`→一次 `save`,坐标天然一致;`pptx_helpers` 模块化已消解原"逐页防漂移"理由),图标**全 deck 批量预取**(不逐页拉);③ quality_check 一次 → 改脚本重跑(不 edit 成品);④ 可选"风格探针"(先建封面+1 页看观感)兜视觉返工险。N 页从 ~2N 轮降到 ~3-4 轮。改 `skills/ppt/SKILL.md`(阶段一/二/三 + 反模式 + 文件树)、`references/layouts.md`(§通用起手换成"整 deck 单脚本"模板)、`SKILL_LIST.md`(流程描述/典型产物同步)。冒烟过:单脚本 `new_presentation`+循环 `add_slide`+`save` 建 2 页成功,API 调用与模板一致。**注**:数据采集类(host 工具中转免不了)是另一条路(拆采集/处理相位),未动。 - **ppt skill 工作流批量化(减高轮数 task 的来回)**:实测高成本 task 几乎全是 100+ 轮的"逐步 tool 调用循环"(rust→PPT 34 轮、文献采集 245 轮),每轮重发整段上下文,轮数是 token 体量的线性乘数。ppt 是最易压、风险最低的试点:原 §阶段二**逐页**(每页 `读spec→glob图标→一个 run_python 加页→等用户确认→下一页`,N 页 ~2N 轮)。改法:① 阶段一 spec 增「逐页大纲」表(页|版式|标题|要点|图标),作为**替代逐页确认的前置 checkpoint**——改文字大纲比建完 slide 再推翻便宜;② 阶段二改成**写一个 `build_deck.py` 一次建整 deck**(同进程 `new_presentation`→按大纲循环 `add_slide`→一次 `save`,坐标天然一致;`pptx_helpers` 模块化已消解原"逐页防漂移"理由),图标**全 deck 批量预取**(不逐页拉);③ quality_check 一次 → 改脚本重跑(不 edit 成品);④ 可选"风格探针"(先建封面+1 页看观感)兜视觉返工险。N 页从 ~2N 轮降到 ~3-4 轮。改 `skills/ppt/SKILL.md`(阶段一/二/三 + 反模式 + 文件树)、`references/layouts.md`(§通用起手换成"整 deck 单脚本"模板)、`SKILL_LIST.md`(流程描述/典型产物同步)。冒烟过:单脚本 `new_presentation`+循环 `add_slide`+`save` 建 2 页成功,API 调用与模板一致。**注**:数据采集类(host 工具中转免不了)是另一条路(拆采集/处理相位),未动。
- **修进度还原错乱 + 进度区移到对话区顶部(codex 式)**:根因(查 DB 实锤)= 上下文压缩把旧 `task_progress` tool_call 参数换成 `{"_compacted":true,"step_id":"sX"}` 这种"看着像合法调用"的标记,① 毒化模型让它后续照抄出残废 `update_step`(丢了 `step.status`)并入库,② 残废格式前端 `applyProgressAction` 读不到 `args.step` → s4/s5 永停 pending → 进度显示不对。修复:`context.py` 对 `task_progress` 参数**一律不压缩**(参数本就小,压缩省不了几个 token 却两头坏事);旧的 `_compact_task_progress_arguments` 整个删除。**进度展示重构**:删掉每条消息卡内联进度块(`renderProgressHtml`/`renderProgressInto` 移除),进度统一只在**对话区顶部**单一 `#task-progress-dock`(从 composer 上方移到 `chat-stream` 之上、`flex-shrink:0` 钉顶不滚)实时显示;**完成态折叠**——全部步骤 completed 时 dock 自动收成一行 `✓ 全部完成 · N/N 步`(`<details>` 点开看清单)。prompt + tool 描述改为"跑完把最后一步标 `completed`、不要 `clear`",留住全绿收尾。校验:`python -m unittest tests.test_context_compaction tests.test_task_progress_tool`(12 过,改写 `test_keeps_old_task_progress_arguments_intact` 断言参数原样保留);`node --test tests/frontend_task_progress.test.mjs`(2 过)。 - **修进度还原错乱 + 进度区移到对话区顶部(codex 式)**:根因(查 DB 实锤)= 上下文压缩把旧 `task_progress` tool_call 参数换成 `{"_compacted":true,"step_id":"sX"}` 这种"看着像合法调用"的标记,① 毒化模型让它后续照抄出残废 `update_step`(丢了 `step.status`)并入库,② 残废格式前端 `applyProgressAction` 读不到 `args.step` → s4/s5 永停 pending → 进度显示不对。修复:`context.py` 对 `task_progress` 参数**一律不压缩**(参数本就小,压缩省不了几个 token 却两头坏事);旧的 `_compact_task_progress_arguments` 整个删除。**进度展示重构**:删掉每条消息卡内联进度块(`renderProgressHtml`/`renderProgressInto` 移除),进度统一只在**对话区顶部**单一 `#task-progress-dock`(从 composer 上方移到 `chat-stream` 之上、`flex-shrink:0` 钉顶不滚)实时显示;**完成态折叠**——全部步骤 completed 时 dock 自动收成一行 `✓ 全部完成 · N/N 步`(`<details>` 点开看清单)。prompt + tool 描述改为"跑完把最后一步标 `completed`、不要 `clear`",留住全绿收尾。校验:`python -m unittest tests.test_context_compaction tests.test_task_progress_tool`(12 过,改写 `test_keeps_old_task_progress_arguments_intact` 断言参数原样保留);`node --test tests/frontend_task_progress.test.mjs`(2 过)。

View File

@ -137,7 +137,7 @@ zcbot 的"skill"是一份可加载的工作流脚本(`skills/<name>/SKILL.md` +
### ppt ### ppt
**生成 PowerPoint 演示文稿 (.pptx)。** **生成 PowerPoint 演示文稿 (.pptx)。**
把材料(汇报草稿 / 项目方案 / 调研报告)变成可演示的 .pptx。流程:**先定调(8 项 + 逐页大纲)→ 一个脚本建整 deck → quality_check 验收**。方向在大纲阶段对齐,执行阶段一把出稿(不逐页来回)。 把材料(汇报草稿 / 项目方案 / 调研报告)变成可演示的 .pptx。流程:**先定调(8 项 + 逐页大纲)→ 一个脚本建整 deck → quality_check 验收**。方向在大纲阶段对齐,执行阶段一把出稿(不逐页来回)。视觉走**卡片式系统**(圆角卡片 + 柔和投影 + 渐变 + 从主色派生的明暗色阶),原生可编辑,告别扁平办公模板观感。
**触发**: **触发**:
- ✅ 用户明确点名 PPT / 幻灯片 / 演示文稿 / .pptx / slide / deck - ✅ 用户明确点名 PPT / 幻灯片 / 演示文稿 / .pptx / slide / deck
@ -157,14 +157,16 @@ zcbot 的"skill"是一份可加载的工作流脚本(`skills/<name>/SKILL.md` +
| 4 | 风格 | 现代简约(白底 + 细线 + 留白) | | 4 | 风格 | 现代简约(白底 + 细线 + 留白) |
| 5 | 配色 | 商务红 | | 5 | 配色 | 商务红 |
| 6 | 字体 | 微软雅黑 + Arial | | 6 | 字体 | 微软雅黑 + Arial |
| 7 | 图标 | Iconify `tabler` 集(主色染色,本地缓存) | | 7 | 图标 | Iconify `tabler` 集(主色染色,本地缓存;概念页配图标底块) |
| 8 | 图表 | 数据 ≥ 3 个点的页用 matplotlib 配图 | | 8 | 图表 / 配图 | 数据图 matplotlib / 少量数字上 KPI 卡;真实配图 opt-in 走 imagegen(每张 ¥0.22) |
**核心能力**: **核心能力**:
- 9 种版式起手代码(封面 / 大标题 / 双栏 / 三栏 / 时间线 / 表格 / 图文混排 / 引子 / 尾页) - **13 种卡片式版式**(封面 / 目录 / 章节 / 要点 / 双栏 / 图表 / 图片 / 金句 / 尾页 + KPI 数字卡 / 卡片网格 / 流程图 / 大数字论据)
- **质感工具箱** `pptx_helpers.py`:`add_card`(圆角+投影)/ `add_gradient_rect`(渐变)/ `add_kpi` / `add_icon_tile` / `add_pill` / 从主色自动派生明暗色阶
- 演讲者备注 `add_notes`(每页口述要点)
- 业务图标双层兜底(Iconify CDN 拉 SVG → 本地缓存 → unicode 字形) - 业务图标双层兜底(Iconify CDN 拉 SVG → 本地缓存 → unicode 字形)
- `apply_brand` 品牌条 + 安全区 / 越界保护 - `apply_brand` 品牌锚点(封面/章节渐变)+ 安全区 / 越界保护
- `quality_check.py` 验收(越界 / 文本溢出 / 颜色一致) - `quality_check.py` 验收(越界 / 文本溢出 / 按列 bullet ≤5 / 按色系三色制 / 内容重叠)
- 素材摄取走 markitdown 把 PDF/DOCX/PPTX/XLSX/HTML/URL 统一转 Markdown - 素材摄取走 markitdown 把 PDF/DOCX/PPTX/XLSX/HTML/URL 统一转 Markdown
**典型产物**:`<task>.pptx` + `build_deck.py`(整 deck 构建脚本,改稿/修验收项都改它重跑)。 **典型产物**:`<task>.pptx` + `build_deck.py`(整 deck 构建脚本,改稿/修验收项都改它重跑)。

View File

@ -10,7 +10,7 @@ description: 生成 PowerPoint 演示文稿 (.pptx) 文件。✅ 触发:用户
进度展示建议:多页 deck 任务用 `task_progress` 标记「摄取素材 / 八条对齐 + 逐页大纲 / 图标预取 / 脚本建 deck / 质量检查 / 交付」等关键阶段;不要把每一页的内部写入都作为进度步骤。 进度展示建议:多页 deck 任务用 `task_progress` 标记「摄取素材 / 八条对齐 + 逐页大纲 / 图标预取 / 脚本建 deck / 质量检查 / 交付」等关键阶段;不要把每一页的内部写入都作为进度步骤。
## 资源 ## 资源
- `scripts/pptx_helpers.py` —— **版式工具箱模块**:配色/字体常量 + `new_presentation`/`load`/`add_slide`/`set_palette` + `add_textbox`/`add_rect`/`add_dot`/`add_badge`/`page_title`/`apply_brand` 等 helper。每页 `import pptx_helpers as P` 调用,**不要把 helper 源码默写进 run_python** - `scripts/pptx_helpers.py` —— **卡片式视觉工具箱模块**:配色/字体常量 + 从主色派生的明暗色阶(`PRIMARY_WASH/SOFT/DARK`)+ `new_presentation`/`load`/`set_palette` + 质感件 `add_card`(圆角卡片+柔和投影)/`add_gradient_rect`(渐变)/`add_kpi`(数字卡)/`add_icon_tile`(图标底块)/`add_pill`(胶囊标签)/`add_eyebrow` + `add_notes`(演讲者备注)+ 基础件 `add_textbox`/`add_dot`/`add_badge`/`page_title`/`apply_brand`。`import pptx_helpers as P` 调用,**不要把 helper 源码默写进脚本**。⚠️ helper 的 `name=` 会写进形状名,quality_check 靠它判标签/bullet —— 自己加元素时按 layouts.md 的命名习惯起名
- `references/design_principles.md` —— 画布尺寸 + 字号/配色/留白/字数预算等硬规则 - `references/design_principles.md` —— 画布尺寸 + 字号/配色/留白/字数预算等硬规则
- `references/layouts.md` —— 9 种版式的调用示例(基于 `pptx_helpers`)+ helper API 速查 + 安全区/越界保护 + `apply_brand` 品牌条 - `references/layouts.md` —— 9 种版式的调用示例(基于 `pptx_helpers`)+ helper API 速查 + 安全区/越界保护 + `apply_brand` 品牌条
- `references/icons.md` —— 业务图标两层:Iconify (在线/本地缓存) / unicode 字形兜底 - `references/icons.md` —— 业务图标两层:Iconify (在线/本地缓存) / unicode 字形兜底
@ -66,20 +66,23 @@ glob <task_dir>/*-<task_short_id>-*.spec.md → 按文件名字典序排,取最
| 4 | 风格 | **现代简约** (白底 + 细线 + 留白) | | 4 | 风格 | **现代简约** (白底 + 细线 + 留白) |
| 5 | 配色 | **商务红** `#C00000` `#E15554` `#FFC107` (见上"默认主题") | | 5 | 配色 | **商务红** `#C00000` `#E15554` `#FFC107` (见上"默认主题") |
| 6 | 字体 | **微软雅黑 + Arial** | | 6 | 字体 | **微软雅黑 + Arial** |
| 7 | 图标 | **Iconify `tabler` 集** (描边商务图标,主色染色;`fetch_icon.py` 拉到 `<task_dir>/assets/icons/`) | | 7 | 图标 | **Iconify `tabler` 集** (描边商务图标,主色染色;`fetch_icon.py` 拉到 `<task_dir>/assets/icons/`;业务概念页用 `add_icon_tile` 配图标底块) |
| 8 | 图表 | 数据 ≥ 3 个点的页用 matplotlib 配图 | | 8 | 图表 / 配图 | 数据 ≥ 3 个点 → matplotlib 图(或 ≤4 个数字直接上 KPI 卡 L10);**真实配图 opt-in**:封面/章节/图片页可走 imagegen 生图(**每张 ¥0.22**,默认不开,要用在大纲里标 `[img]` 并经用户确认) |
把这 8 项写进上面那个 task 级 spec 文件,以表格形式给用户预览,问一句"按这个开干?"。**spec 写定后不再改**(要改就走 §0 的「重定调」分支,以 today 为前缀写新版,旧版保留)。 把这 8 项写进上面那个 task 级 spec 文件,以表格形式给用户预览,问一句"按这个开干?"。**spec 写定后不再改**(要改就走 §0 的「重定调」分支,以 today 为前缀写新版,旧版保留)。
**8 项之外,spec 还要含一张「逐页大纲」表** —— 这是阶段二一个脚本建整 deck 的输入,也是替代"逐页确认"的前置 checkpoint(改一行文字大纲,比建完一页 slide 再推翻便宜得多): **8 项之外,spec 还要含一张「逐页大纲」表** —— 这是阶段二一个脚本建整 deck 的输入,也是替代"逐页确认"的前置 checkpoint(改一行文字大纲,比建完一页 slide 再推翻便宜得多):
| 页 | 版式 | 标题 | 核心信息 / 要点(≤5) | 图标 / 图表 | | 页 | 版式 | 标题 | 核心信息 / 要点(≤5) | 图标 / 图表 / 配图 |
|---|---|---|---|---| |---|---|---|---|---|
| 1 | L1 封面 | <主标题> | <副标题 / 定位> | — | | 1 | L1 封面 | <主标题> | <副标题 / 定位> | 可选 `[img]` 主图 |
| 2 | L4 三栏 | <标题> | <要点 1 / 2 / 3> | `target` / `cpu` / `chart-bar` | | 2 | L11 卡片网格 | <标题> | <要点 1 / 2 / 3> | `target` / `cpu` / `chart-bar` |
| 3 | L10 KPI 卡 | <标题> | <数字 1 / 2 / 3 / 4> | — |
| … | … | … | … | … | | … | … | … | … | … |
| N | L9 尾页 | Q&A / 致谢 | <联系方式> | — | | N | L9 尾页 | Q&A / 致谢 | <联系方式> | — |
> 版式从 layouts.md 的 L1-L13 里选(见 §选版式速查):**业务概念优先 L11 卡片网格(图标底块,别只摆圆点)**,**2-4 个关键数字优先 L10 KPI 卡(别硬画柱图)**,**单个震撼数字用 L13**。要真实配图的页在「图标/图表/配图」列标 `[img]` + 一句画面描述。
大纲连同 8 项一起给用户预览,**BLOCKING 等用户确认整份结构**(页数、每页讲什么、用什么版式/图标)后再进阶段二。用户在这一步推翻方向 = 改表格文字,零 slide 返工。 大纲连同 8 项一起给用户预览,**BLOCKING 等用户确认整份结构**(页数、每页讲什么、用什么版式/图标)后再进阶段二。用户在这一步推翻方向 = 改表格文字,零 slide 返工。
### 阶段二: 执行 (Executor) — 一个脚本建整 deck ### 阶段二: 执行 (Executor) — 一个脚本建整 deck
@ -89,10 +92,11 @@ glob <task_dir>/*-<task_short_id>-*.spec.md → 按文件名字典序排,取最
流程: 流程:
1. **读 current spec**(按 §0 的 glob 规则拿字典序最大那份),含 8 项 + 逐页大纲;只用里面定的颜色/字体/图标/页结构,**不凭记忆发挥**。 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**。 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. **写 `build_deck.py` 到 `<task_dir>`,一次建整 deck**: 顶部 `import pptx_helpers as P`(`sys.path` 指到 `<skill_dir>/scripts`)→ `prs = P.new_presentation(...)``P.set_palette(spec_path=...)`**按大纲循环每页**(`P.add_slide` + 各 helper,每页一个小函数 `page_1(prs)`…清晰)→ 末尾一次 `prs.save(...)`。**helper 一律 `P.xxx`,不默写源码**。起手见 `layouts.md §通用起手(整 deck 单脚本)`。先 `write` 脚本文件再 `run_python(script_path=...)` 跑(避免大段源码进对话历史)。 3. **真实配图(opt-in,仅当大纲标了 `[img]`)**: 把标 `[img]` 的页(封面/章节/图片页)汇总,**load `imagegen` skill 走它自己的确认流程**逐张生成(每张 ¥0.22,有强制确认门,不要绕过),产物落 `<task_dir>/figures/`;build_deck 里 `add_picture(<figures 路径>)` 引用。**没标 `[img]` 的 deck 跳过这步**,图标/卡片/渐变已足够撑视觉。
4. **quality_check 一次**(见阶段三)→ 按报告**改 `build_deck.py` 重跑**(不要逐页 edit 成品 .pptx —— 改源脚本可复现、可再跑)。 4. **写 `build_deck.py` 到 `<task_dir>`,一次建整 deck**: 顶部 `import pptx_helpers as P`(`sys.path` 指到 `<skill_dir>/scripts`)→ `prs = P.new_presentation(...)``P.set_palette(spec_path=...)`**按大纲循环每页**(`P.add_slide` + `P.apply_brand` + 各 helper,每页一个小函数 `page_1(prs)`…清晰;**每页 `P.add_notes(slide, ...)` 写 2-4 句演讲者口述要点**)→ 末尾一次 `prs.save(...)`。**helper 一律 `P.xxx`,不默写源码**;版式照 layouts.md L1-L13 选,**业务概念用 L11 卡片网格、数字用 L10 KPI 卡**。起手见 `layouts.md §通用起手`。先 `write` 脚本再 `run_python(script_path=...)`(避免大段源码进对话历史)。
5. 报整份 deck:页数、各页版式、用到的图标;问用户要不要改。 5. **quality_check 一次**(见阶段三)→ 按报告**改 `build_deck.py` 重跑**(不要逐页 edit 成品 .pptx —— 改源脚本可复现、可再跑)。
6. 用户确认了**实质改动**(改版式 / 换图标 / 改文案要点 / 增删页 / 调主色)后,追加一行到 `<task_dir>/REVISIONS.md` —— 见 §修订日志。 7. 报整份 deck:页数、各页版式、用到的图标/配图;问用户要不要改。
8. 用户确认了**实质改动**(改版式 / 换图标 / 改文案要点 / 增删页 / 调主色)后,追加一行到 `<task_dir>/REVISIONS.md` —— 见 §修订日志。
**风格探针(可选,降视觉返工险)**: 用户对观感没底、或这是全新风格时,可先只建**封面 + 1 内页**给用户看一眼,确认后把 `build_deck.py` 的页范围放开重跑补齐其余页 —— 仍是改一个脚本,不退回逐页。用户要快("直接全做")就跳过探针,整 deck 一把出。 **风格探针(可选,降视觉返工险)**: 用户对观感没底、或这是全新风格时,可先只建**封面 + 1 内页**给用户看一眼,确认后把 `build_deck.py` 的页范围放开重跑补齐其余页 —— 仍是改一个脚本,不退回逐页。用户要快("直接全做")就跳过探针,整 deck 一把出。
@ -108,15 +112,18 @@ python <skill_dir>/scripts/quality_check.py <task_dir>/<output.pptx> --spec <tas
## 设计原则 (硬规则速查) ## 设计原则 (硬规则速查)
- **每页一个核心信息**: 一页讲一件事,塞两件就拆页 - **每页一个核心信息**: 一页讲一件事,塞两件就拆页
- **bullet ≤ 5 条**: 超过就拆页或改成图表/双栏 - **内容装进卡片**: 内容页主力容器是 `add_card`(圆角+柔和投影),白底之上靠卡片浮起分层,别让元素裸贴白纸
- **正文不写完整段落**: 列要点;长句留给演讲者口述 - **概念配图标底块**: 业务概念(能力/模块/策略)用 L11 卡片网格 + `add_icon_tile`,**别只摆圆点 bullet**(视觉太单薄)
- **数据 ≥ 3 个点应有图表**: 用 matplotlib 生成 .png 嵌入 - **数字上 KPI 卡**: 2-4 个关键数字用 L10 `add_kpi`,优先于硬画柱状图;单个震撼数字用 L13
- **bullet ≤ 5 条/列**: 单列超过就拆页或改卡片网格;双栏对比左右各 ≤5
- **正文不写完整段落**: 列要点;长句留给演讲者口述(写进 `add_notes`)
- **数据 ≥ 3 个点应有图表**: matplotlib 生成 .png 嵌入(或转 KPI 卡)
- **中文标题 ≤ 30 字** - **中文标题 ≤ 30 字**
- **配色三色封顶**: 主 + 辅 + 强调,其他用灰阶 - **配色三色封顶 + 派生阶**: 主 + 辅 + 强调三色系,浅底/卡片底走 `set_palette` 自动派生的 `PRIMARY_WASH/SOFT`,不算新色
- **少用大色块,多用细线 + 图标 + 留白** - **渐变只用在大色块**: 封面/章节用 `apply_brand` 内置渐变;渐变深底上文字一律用白/`ACCENT_SOFT`
- **图标走 MSO_SHAPE**: 矢量、可编辑;复杂图标走 `render_icon.py` - **每页演讲者备注**: `add_notes` 写 2-4 句口述要点(正式产物标配)
- **Shape 不能越界**: `layouts.md` 起手代码用 `assert_inside`生成时即报错 - **Shape 不能越界**: helper 内置 `assert_inside` 生成时即报错
- **字数按预算来**: 写 bullet 前查 `design_principles.md §4.1` 字数预算表 - **字数按预算来**: 写 bullet 前查 `design_principles.md §4.1` 字数预算表;卡片内按"卡宽 - 0.8"算框宽
- 详细规则见 `references/design_principles.md` - 详细规则见 `references/design_principles.md`
## 工作目录约定 ## 工作目录约定
@ -127,7 +134,9 @@ python <skill_dir>/scripts/quality_check.py <task_dir>/<output.pptx> --spec <tas
<task_dir>/ <task_dir>/
├── source/ # markitdown 转出的素材(同 working_dir 多 task 共享;用 markitdown -o <task_dir>/source/<name>.md) ├── 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 主锚,重定调时写新日期,旧版保留 ├── <today>-<task_short_id>-<task_name>.spec.md # 八条对齐落定,task 级宪法;命名见 system prompt 约定;按 short_id 主锚,重定调时写新日期,旧版保留
├── slides/ # 各页用到的图片素材 (chart_p3.png 等),多 task 时文件名前缀区分 ├── slides/ # 各页 matplotlib 图表 (chart_p3.png 等),多 task 时文件名前缀区分
├── figures/ # imagegen 生成的真实配图 (opt-in;封面/章节主图),由 imagegen skill 落盘
├── assets/icons/ # fetch_icon.py 新拉的主题色图标(种子库在 skill 只读侧)
├── build_deck.py # 整 deck 构建脚本(一次建完所有页);改稿/修 quality_check 项都改它重跑 ├── build_deck.py # 整 deck 构建脚本(一次建完所有页);改稿/修 quality_check 项都改它重跑
├── REVISIONS.md # 修订日志:每次卡点用户确认的实质改动,见 §修订日志 ├── REVISIONS.md # 修订日志:每次卡点用户确认的实质改动,见 §修订日志
└── <topic>.pptx # 最终产物 (按主题命名,多 task 时主题必须不同) └── <topic>.pptx # 最终产物 (按主题命名,多 task 时主题必须不同)
@ -185,7 +194,10 @@ python <skill_dir>/scripts/quality_check.py <task_dir>/<output.pptx> --spec <tas
- **基于"场景判断"自行换配色**(见上"默认主题"违规清单) - **基于"场景判断"自行换配色**(见上"默认主题"违规清单)
- **缺封面 / 缺尾页(Q&A)** —— 两端都是强制项,不算在正文页数预算内 - **缺封面 / 缺尾页(Q&A)** —— 两端都是强制项,不算在正文页数预算内
- **裸白纸版式** —— 所有版式起手都必须 `apply_brand(slide, kind)`,见 layouts.md - **裸白纸版式** —— 所有版式起手都必须 `apply_brand(slide, kind)`,见 layouts.md
- **业务概念页只用几何形状** —— 比如"战略目标"页只摆圆点 bullet 没有 `target` 图标,视觉太单薄;按 §阶段二第 2 步先拉 Iconify 图标再做页 - **业务概念页只用几何形状 / 裸圆点 bullet** —— "战略目标 / 三大能力"这类页摆光圆点没图标没卡片,视觉太单薄;用 L11 卡片网格 + `add_icon_tile`,图标按 §阶段二第 2 步先拉
- **数字页硬画柱图** —— 只有 2-4 个数字却画 bar chart 浪费版面,用 L10 KPI 卡
- **元素裸贴白纸不进卡片** —— 内容页一坨文字/图标直接铺白底,显扁平;装进 `add_card`(自带投影)分层
- **演讲者备注全空** —— 正式产物每页应有口述要点,`add_notes` 顺手写,别交白板
- **逐页 run_python 建 deck**(每页一轮来回烧轮数;改用一个 `build_deck.py` 整建,方向风险靠阶段一大纲 + 可选探针兜) - **逐页 run_python 建 deck**(每页一轮来回烧轮数;改用一个 `build_deck.py` 整建,方向风险靠阶段一大纲 + 可选探针兜)
- **没经阶段一大纲对齐就直接整建** —— 大纲是替代逐页确认的 checkpoint,跳过它整建才会"改方向全推翻" - **没经阶段一大纲对齐就直接整建** —— 大纲是替代逐页确认的 checkpoint,跳过它整建才会"改方向全推翻"
- 跑完不做 `quality_check.py` 就交付 - 跑完不做 `quality_check.py` 就交付

View File

@ -47,10 +47,19 @@
| 现代简约 | #2D3748 | #4A5568 | #38B2AC | 互联网/SaaS | | 现代简约 | #2D3748 | #4A5568 | #38B2AC | 互联网/SaaS |
| 科技深色 | #0A192F | #112240 | #64FFDA | 黑客松/技术大会 | | 科技深色 | #0A192F | #112240 | #64FFDA | 黑客松/技术大会 |
### 派生色阶(卡片式视觉的层次来源)
`set_palette` 从主/辅/强调自动派生明暗阶,**这些不算"新色"**(quality_check 按色相归桶,同色系深浅收敛成一个):
- `PRIMARY_WASH`(主色兑 92% 白)—— 整页/大区域浅底(尾页、L13 论据卡)
- `PRIMARY_SOFT`(兑 80% 白)—— 卡片/图标底块/标签浅底
- `PRIMARY_DARK`(主色压暗)—— 封面/章节渐变深端
- `ACCENT_SOFT`(强调兑 78% 白)—— 渐变深底上的弱化文字
> 白底之上靠卡片(`add_card` 圆角+投影)+ 浅色阶分层,才有"现代咨询风"的层次;纯白底裸贴元素 = 扁平办公模板。
### 禁忌 ### 禁忌
- 红配绿、紫配黄等高对比互补色不要直接用 - 红配绿、紫配黄等高对比互补色不要直接用
- 渐变只用在 accent 上,正文/标题不要渐变 - **渐变只用在大色块**(封面右块 / 章节整页,`apply_brand` 已内置);正文/标题/小图形不要渐变
- 一份 deck 主色不要换。封面是 A 色、内页变 B 色 —— 这是大忌 - 一份 deck 主色不要换。封面是 A 色、内页变 B 色 —— 这是大忌
- 渐变深底上文字一律用**白 / `ACCENT_SOFT`**,别用深灰 `INK`(看不清)
## 3. 留白 ## 3. 留白
@ -67,7 +76,8 @@
| 目录 | 每条 ≤ 15 字 | 不要图 | | 目录 | 每条 ≤ 15 字 | 不要图 |
| 分章页 | ≤ 20 字 | 大号数字 + 章节名 | | 分章页 | ≤ 20 字 | 大号数字 + 章节名 |
| 要点页 | bullet ≤ 5 条,每条 ≤ 25 字 | 可选小图标 | | 要点页 | bullet ≤ 5 条,每条 ≤ 25 字 | 可选小图标 |
| 数据页 | 标题 + 一句结论 | **必须有图表** | | 数据页 | 标题 + 一句结论 | **必须有图表**;2-4 个数字优先 KPI 卡(L10)而非柱图 |
| 概念页 | 卡片标题 ≤6 字 + 说明 ≤2 行 | 图标底块 + 卡片网格(L11),别裸圆点 |
| 图片页 | ≤ 15 字标题 + 1-2 行说明 | 主体是图 | | 图片页 | ≤ 15 字标题 + 1-2 行说明 | 主体是图 |
## 4.1 字数预算 (避免溢出) ## 4.1 字数预算 (避免溢出)
@ -116,10 +126,13 @@
## 7. 图表规则 (matplotlib) ## 7. 图表规则 (matplotlib)
> **先问要不要图表**:只有 2-4 个数字 → 用 KPI 卡(layouts L10),别画柱图;真有趋势/分布/多系列才上 matplotlib。图表 png 嵌进 `add_card` 白卡片里(L6)比裸图精致。
- 颜色用 spec 里定的主/辅/强调三色,**不要用 matplotlib 默认色板** - 颜色用 spec 里定的主/辅/强调三色,**不要用 matplotlib 默认色板**
- 字号: 标题 16,坐标轴 12,刻度 10 - 字号: 标题 16,坐标轴 12,刻度 10
- 去掉上方和右方边框 (`ax.spines['top'/'right'].set_visible(False)`) - **去四边框**,只留极淡横向网格 (`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['font.sans-serif'] = ['Microsoft YaHei', 'SimHei']`
- 负号: `plt.rcParams['axes.unicode_minus'] = False` - 负号: `plt.rcParams['axes.unicode_minus'] = False`

View File

@ -1,6 +1,8 @@
# 9 种常用版式 (16:9, 13.33×7.5 in) # 版式 (16:9, 13.33×7.5 in) — 卡片式视觉系统
> **要点**:版式 helper 已全部收进 `scripts/pptx_helpers.py`,**不要再把 helper 源码默写进每页的 run_python** —— 每页只 `import pptx_helpers as P` 然后调用。这样长 deck 里不会出现第 7 页和第 2 页的 `apply_brand` 坐标对不上的漂移,也省 token。配色用 current spec(命名见 SKILL.md §阶段一)里的实际 hex —— 通过 `P.set_palette()` 注入,默认商务红。 > **要点**:版式 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 单脚本 — 默认路径) ## 通用起手(整 deck 单脚本 — 默认路径)
@ -10,24 +12,25 @@
import sys import sys
sys.path.insert(0, "<skill_dir>/scripts") # <skill_dir> 用 system prompt 注入的绝对路径替换 sys.path.insert(0, "<skill_dir>/scripts") # <skill_dir> 用 system prompt 注入的绝对路径替换
import pptx_helpers as P 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" SPEC = "<task_dir>/<today>-<task_short_id>-<task_name>.spec.md"
OUT = "<task_dir>/<topic>.pptx" OUT = "<task_dir>/<topic>.pptx"
ICONS = "<task_dir>/assets/icons" # fetch_icon.py 拉到这;种子库在 <skill_dir>/assets/icons
def page_1_cover(prs): def page_1_cover(prs):
slide = P.add_slide(prs) s = P.add_slide(prs)
P.apply_brand(slide, "cover") P.apply_brand(s, "cover")
# ... 见 L1 封面 ... # ... 见 L1 封面 ...
def page_2(prs): def page_2(prs):
slide = P.add_slide(prs) s = P.add_slide(prs)
# ... 见对应 Lx 版式 ... # ... 见对应 Lx 版式 ...
# ... 按大纲补齐 page_3 … page_N ...
def main(): def main():
prs = P.new_presentation("16:9") # 默认 16:9;可传 "4:3" / "9:16" / "3:4" prs = P.new_presentation("16:9") # 默认 16:9;可传 "4:3" / "9:16" / "3:4"
P.set_palette(spec_path=SPEC) # 整 deck 设一次配色(同进程常驻) P.set_palette(spec_path=SPEC) # 整 deck 设一次配色 + 派生色阶(同进程常驻)
for build in (page_1_cover, page_2, ...): # 按逐页大纲顺序 for build in (page_1_cover, page_2, ...): # 按逐页大纲顺序
build(prs) build(prs)
prs.save(OUT) prs.save(OUT)
@ -35,114 +38,127 @@ def main():
main() main()
``` ```
跑法:先 `write` 这个脚本到 `<task_dir>/build_deck.py`,再 `run_python(script_path="<task_dir>/build_deck.py")`。要改(quality_check 报错 / 用户要调)→ 改脚本里对应 `page_x` 函数,重跑整脚本(可复现,不 edit 成品 .pptx)。 跑法:先 `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` 按文件实际尺寸回填画布常量)。**常规整建不用 `load`**。 > **风格探针 / 增量补页**:要先看封面 + 1 页观感,把 `main()` 循环临时缩到前 2 个函数跑一遍;或对已存在 deck 追加单页时 `prs = P.load(OUT)``add_slide`。**常规整建不用 `load`**。
⚠️ 一律用 `P.xxx`(不要 `from pptx_helpers import *`)—— `set_palette` 靠改模块属性覆盖配色,`import *` 会把旧绑定拷进页面命名空间导致覆盖不生效。 ⚠️ 一律用 `P.xxx`(不要 `from pptx_helpers import *`)—— `set_palette` 靠改模块属性覆盖配色,`import *` 会把旧绑定拷进命名空间导致覆盖不生效。
---
## Helper API 速查 (都在 `P.` 命名空间下) ## Helper API 速查 (都在 `P.` 命名空间下)
**画布 / 配色入口** **画布 / 配色入口**
- `P.new_presentation(canvas="16:9")` → 建空 deck,设画布,回填 `P.SLIDE_W/H` 与安全区 - `P.new_presentation(canvas="16:9")` → 建空 deck,设画布,回填 `P.SLIDE_W/H` 与安全区
- `P.load(path)` → 载入已有 deck,按文件实际尺寸回填画布常量(逐页进程间自动同步) - `P.load(path)` → 载入已有 deck,按文件实际尺寸回填画布常量
- `P.add_slide(prs)` → 追加一张空白版式(layout 6)slide - `P.add_slide(prs)` → 追加空白 slide
- `P.set_palette(primary=, secondary=, accent=, cn_font=, en_font=, spec_path=)` → 覆盖主题色/字体;传 `spec_path` 自动从 spec.md 按文档顺序取前 3 个 #hex 作 主/辅/强调;**默认商务红,什么都不传无副作用** - `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` `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.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.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)` 压暗(自定义中间色用)
**放置 helper**(全部内置 `assert_inside` 越界即报错) **容器 / 质感**(卡片式核心)
- `P.add_textbox(slide, left, top, w, h, text, size, bold=False, color=P.INK, align=PP_ALIGN.LEFT, anchor=MSO_ANCHOR.TOP, font=None, shrink=True, name=...)` → 文本框;`font=None` 自动 latin=Arial + 东亚=微软雅黑(**中文真落到雅黑靠这个**),传 `font` 则两槽都用它(纯英文大字/数字) - `P.add_card(slide, l, t, w, h, fill=SURFACE, radius=0.12, shadow=True, border=False, accent=None)` → 圆角卡片(投影/边线/左强调条可选);内容再叠其上,内边距约 0.3-0.4
- `P.add_rect(slide, left, top, w, h, fill, name=...)` → 无边线实心矩形 - `P.add_round_rect(slide, l, t, w, h, fill, radius=0.10)` → 无投影圆角矩形
- `P.add_shape(slide, kind, left, top, w, h, fill, name=...)` → 任意 MSO_SHAPE(`kind` 用 `MSO_SHAPE.XXX`) - `P.add_gradient_rect(slide, l, t, w, h, c1, c2, angle=90, rounded=False)` → 渐变块(封面/章节大色块;原生可编辑非图片)
- `P.add_dot(slide, x, y, size=0.18, color=P.ACCENT)` → 圆点(bullet 前缀) - `P.set_shadow(shape, blur=0.10, dist=0.045, dir_deg=90, alpha=0.26)` → 给任意形状加柔和投影
- `P.add_accent_line(slide, x, y, length=1.0, thickness=0.05, color=P.ACCENT)` → 强调短线 - `P.set_line(shape, color, weight=0.75)` → 描边(color=None 去边)
- `P.add_badge(slide, x, y, num, diameter=0.7, fill=P.PRIMARY, fg=P.WHITE)` → 编号徽章(圆+数字) - `P.add_bg(slide, color=BG)` → 整页背景(`apply_brand` 已内置,一般不用手调)
- `P.page_title(slide, text, page_num=None, total=None, footer="项目汇报")` → 内页标题+强调线(+可选页脚页码)
- `P.apply_brand(slide, kind)` → 品牌锚点,`kind` ∈ `"cover"/"inner"/"section"/"end"`;**每页第一行必调**
- `P.assert_inside(left, top, w, h, name="")` → 手动越界校验(上面的 helper 已内置)
> `MSO_SHAPE` / `PP_ALIGN` / `MSO_ANCHOR` 等枚举若页面里要直接用,自行 `from pptx.enum.shapes import MSO_SHAPE` 等(`pptx_helpers` 内部已 import,但不重导出)。 **组件**
- `P.add_kpi(slide, l, t, w, h, value, label, sub=None, value_color=PRIMARY, card=True, value_size=40)` → 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 已内置)
> ⚠️ **给每个元素起语义 `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) —— 主色长竖条锚点 ## L1 · 封面 (Cover) —— 渐变大色块 + 左侧标题区
```python ```python
slide = P.add_slide(prs) s = P.add_slide(prs)
P.apply_brand(slide, "cover") # 左侧主色长竖条 + 顶部短横 P.apply_brand(s, "cover") # 右侧 40% 主色→深主色渐变块 + 左上强调短线 + 底细线
# 左侧标题区(避开右侧渐变块,文字区约 7.4 寸宽)
# 主标题 (避开左竖条) P.add_eyebrow(s, 0.9, 2.0, "2026 年度技术汇报") # kicker 小标签
P.add_textbox(slide, 0.9, 2.6, 11.9, 1.4, "项目名称 / 演示主题", P.add_textbox(s, 0.9, 2.5, 7.2, 1.6, "项目名称 / 演示主题",
44, bold=True, color=P.INK, name="cover_title") 42, bold=True, color=P.INK, name="cover_title")
# 副标题 (灰色,弱化) P.add_textbox(s, 0.9, 4.4, 7.0, 0.6, "一句话副标题或定位",
P.add_textbox(slide, 0.9, 4.1, 11.9, 0.6, "一句话副标题或定位", 20, color=P.GREY, name="cover_sub")
22, 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_textbox(slide, 0.9, 6.4, 11.9, 0.4, P.add_notes(s, "开场白:点出主题与本次汇报要解决的核心问题。")
"汇报人 · 部门 · 2026-05-06", 14, color=P.GREY_LIGHT,
name="cover_meta")
# 右下角小图标点缀 (五角星,可选)
from pptx.enum.shapes import MSO_SHAPE
P.add_shape(slide, MSO_SHAPE.STAR_5_POINT, 12.2, 6.3, 0.5, 0.5, P.ACCENT,
"deco_star")
``` ```
> 有合适主图时(见 SKILL.md §配图),可把右侧渐变块换成**真实图片**:`s.shapes.add_picture(hero, Inches(P.SLIDE_W*0.6), Inches(0), height=Inches(7.5))`,再在图上叠半透明主色块保证文字区干净。
--- ---
## L2 · 目录 (Agenda) —— 编号徽章 + 文字 ## L2 · 目录 (Agenda) —— 编号徽章 + 文字
```python ```python
from pptx.enum.text import MSO_ANCHOR from pptx.enum.text import MSO_ANCHOR
slide = P.add_slide(prs) s = P.add_slide(prs)
P.apply_brand(slide, "inner") P.apply_brand(s, "inner")
P.page_title(slide, "目录") P.page_title(s, "目录")
items = ["背景与现状", "核心问题", "解决方案", "实施计划", "预期成果"] items = ["背景与现状", "核心问题", "解决方案", "实施计划", "预期成果"]
for i, item in enumerate(items): for i, item in enumerate(items):
y = 1.9 + i * 0.95 y = 1.9 + i * 0.95
P.add_badge(slide, P.SAFE_LEFT, y, i + 1, diameter=0.65) P.add_badge(s, P.SAFE_LEFT, y, i + 1, diameter=0.65)
P.add_textbox(slide, P.SAFE_LEFT + 1.0, y, P.SAFE_W - 1.0, 0.65, P.add_textbox(s, P.SAFE_LEFT + 1.0, y, P.SAFE_W - 1.0, 0.65, item, 22,
item, 22, color=P.INK, anchor=MSO_ANCHOR.MIDDLE, color=P.INK, anchor=MSO_ANCHOR.MIDDLE, name=f"agenda_{i}")
name=f"agenda_{i}")
``` ```
--- ---
## L3 · 章节分隔 (Section Divider) —— 浅色背景 + 大字编号 ## L3 · 章节分隔 (Section Divider) —— 渐变整页 + 大字编号(白字)
```python ```python
from pptx.enum.text import MSO_ANCHOR from pptx.enum.text import MSO_ANCHOR
from pptx.enum.shapes import MSO_SHAPE s = P.add_slide(prs)
slide = P.add_slide(prs) P.apply_brand(s, "section") # 主色→深主色整页渐变 + 强调装饰条
P.apply_brand(slide, "section") # 整页浅灰 + 主色左竖条 + 强调装饰 # 大编号(白色;font=EN_FONT 让数字走 Arial)
# 大编号 (主色;font=EN_FONT 让数字走 Arial) P.add_textbox(s, 1.1, 2.0, 4, 2.5, "01", 150, bold=True, color=P.WHITE,
P.add_textbox(slide, 1.1, 2.0, 4, 2.5, "01", 160, bold=True, font=P.EN_FONT, name="sec_num")
color=P.PRIMARY, font=P.EN_FONT, name="sec_num") # 章节名(白色)
# 章节名 P.add_textbox(s, 5.3, 2.8, 7, 1.0, "背景与现状", 44, bold=True,
P.add_textbox(slide, 5.5, 2.8, 7, 1.0, "背景与现状", color=P.WHITE, anchor=MSO_ANCHOR.MIDDLE, name="sec_title")
44, bold=True, color=P.INK, 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")
P.add_textbox(slide, 5.5, 4.0, 7, 0.6,
"本章讨论行业现状与机会窗口", 18, color=P.GREY,
name="sec_lead")
# 装饰小图标
P.add_shape(slide, MSO_SHAPE.RIGHT_ARROW, 5.5, 5.0, 0.6, 0.3, P.ACCENT,
"sec_arrow")
``` ```
> 渐变深底上文字一律用 **白 / `ACCENT_SOFT`** 等浅色,不要用 `INK` 深灰(看不清)。
--- ---
## L4 · 要点 (Bullets) —— 圆点 + 文字,无大块色 ## L4 · 要点 (Bullets) —— 圆点 + 文字;≥3 条建议升级成卡片(见 L11)
```python ```python
from pptx.enum.text import MSO_ANCHOR from pptx.enum.text import MSO_ANCHOR
slide = P.add_slide(prs) s = P.add_slide(prs)
P.apply_brand(slide, "inner") P.apply_brand(s, "inner")
P.page_title(slide, "核心结论") P.page_title(s, "核心结论")
bullets = [ bullets = [
"结论一:用一句话讲清楚", "结论一:用一句话讲清楚",
@ -152,141 +168,235 @@ bullets = [
] ]
for i, b in enumerate(bullets): for i, b in enumerate(bullets):
y = 2.0 + i * 0.95 y = 2.0 + i * 0.95
P.add_dot(slide, P.SAFE_LEFT + 0.05, y + 0.22, size=0.18) P.add_dot(s, P.SAFE_LEFT + 0.05, y + 0.22, size=0.18)
P.add_textbox(slide, P.SAFE_LEFT + 0.45, y, P.SAFE_W - 0.45, 0.6, P.add_textbox(s, P.SAFE_LEFT + 0.45, y, P.SAFE_W - 0.45, 0.6, b, 22,
b, 22, color=P.INK, anchor=MSO_ANCHOR.MIDDLE, color=P.INK, anchor=MSO_ANCHOR.MIDDLE, name=f"bullet_{i}")
name=f"bullet_{i}")
``` ```
> 纯圆点 bullet 偏单薄。**业务概念类要点(能力/模块/策略)优先用 L11 卡片网格 + 图标底块**,视觉重量足。
--- ---
## L5 · 双栏对比 (Two-Column) —— 中线分隔,小色块标签 ## L5 · 双栏对比 (Two-Column) —— 两张卡片,左中右灰
```python ```python
from pptx.enum.text import PP_ALIGN, MSO_ANCHOR from pptx.enum.text import PP_ALIGN, MSO_ANCHOR
slide = P.add_slide(prs) s = P.add_slide(prs)
P.apply_brand(slide, "inner") P.apply_brand(s, "inner")
P.page_title(slide, "现状 vs 改进后") P.page_title(s, "现状 vs 改进后")
mid_x = P.SLIDE_W / 2 cw = (P.SAFE_W - 0.5) / 2 # 两卡 + 中间 0.5 间隙
ly, lh = 2.0, 4.5
# 中间细分隔线 (替代两块大矩形) # 左卡:现状(中性灰底,弱化)
P.add_rect(slide, mid_x - 0.02, 2.0, 0.04, 4.8, P.HAIRLINE, "divider") 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:描述"]
P.add_rect(slide, P.SAFE_LEFT, 2.0, 0.8, 0.35, P.GREY, "left_tag")
P.add_textbox(slide, P.SAFE_LEFT, 2.0, 0.8, 0.35, "现状", 14, bold=True,
color=P.WHITE, align=PP_ALIGN.CENTER, anchor=MSO_ANCHOR.MIDDLE,
shrink=False, name="left_label")
left_pts = ["问题 A: 描述", "问题 B: 描述", "问题 C: 描述"]
for i, p in enumerate(left_pts): for i, p in enumerate(left_pts):
P.add_dot(slide, P.SAFE_LEFT + 0.05, 2.7 + i * 0.7 + 0.18, color=P.GREY) yy = ly + 1.1 + i * 0.7
P.add_textbox(slide, P.SAFE_LEFT + 0.45, 2.7 + i * 0.7, P.add_dot(s, P.SAFE_LEFT + 0.4, yy + 0.16, color=P.GREY)
mid_x - P.SAFE_LEFT - 0.7, 0.55, p, 18, color=P.INK, P.add_textbox(s, P.SAFE_LEFT + 0.8, yy, cw - 1.1, 0.55, p, 17,
anchor=MSO_ANCHOR.MIDDLE, name=f"l_pt_{i}") color=P.INK, anchor=MSO_ANCHOR.MIDDLE, name=f"l_pt_{i}")
# 右卡:改进后(主色强调条 + 浅底,突出)
# 右栏小标签 rx = P.SAFE_LEFT + cw + 0.5
P.add_rect(slide, mid_x + 0.3, 2.0, 0.8, 0.35, P.PRIMARY, "right_tag") P.add_card(s, rx, ly, cw, lh, fill=P.SURFACE, accent=P.PRIMARY)
P.add_textbox(slide, mid_x + 0.3, 2.0, 0.8, 0.35, "改进后", 14, bold=True, P.add_pill(s, rx + 0.5, ly + 0.35, 1.3, 0.36, "改进后", fill=P.PRIMARY)
color=P.WHITE, align=PP_ALIGN.CENTER, anchor=MSO_ANCHOR.MIDDLE, right_pts = ["改善 A:描述", "改善 B:描述", "改善 C:描述"]
shrink=False, name="right_label")
right_pts = ["改善 A: 描述", "改善 B: 描述", "改善 C: 描述"]
for i, p in enumerate(right_pts): for i, p in enumerate(right_pts):
P.add_dot(slide, mid_x + 0.35, 2.7 + i * 0.7 + 0.18, color=P.ACCENT) yy = ly + 1.1 + i * 0.7
P.add_textbox(slide, mid_x + 0.75, 2.7 + i * 0.7, P.add_dot(s, rx + 0.55, yy + 0.16, color=P.ACCENT)
P.SAFE_RIGHT - mid_x - 0.75, 0.55, p, 18, color=P.INK, 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}") anchor=MSO_ANCHOR.MIDDLE, name=f"r_pt_{i}")
``` ```
--- ---
## L6 · 图表为主 (Chart-focus) —— 标题 + 一句结论 + 大图 ## L6 · 图表为主 (Chart-focus) —— 标题 + 一句结论 + 大图嵌卡片
```python ```python
from pptx.util import Inches from pptx.util import Inches
from pptx.enum.text import PP_ALIGN from pptx.enum.text import PP_ALIGN
# chart.png 已用 matplotlib 生成 (见 design_principles.md §7) # chart.png 已用 matplotlib 生成(见 design_principles.md §7)
slide = P.add_slide(prs) s = P.add_slide(prs)
P.apply_brand(slide, "inner") P.apply_brand(s, "inner")
P.page_title(slide, "季度营收持续增长") P.page_title(s, "季度营收持续增长")
# 一句话结论 P.add_textbox(s, P.SAFE_LEFT, P.SAFE_TOP + 1.1, P.SAFE_W, 0.5,
P.add_textbox(slide, P.SAFE_LEFT, P.SAFE_TOP + 1.1, P.SAFE_W, 0.5,
"Q4 同比增长 158%,创历史新高", 18, color=P.GREY, name="lead") "Q4 同比增长 158%,创历史新高", 18, color=P.GREY, name="lead")
# 图表 (居中,占 8.9 寸宽,高度自适应 —— 只给 width 等比缩放) # 图表衬一张白卡片(浮起,比裸图精致)
slide.shapes.add_picture("<task_dir>/slides/chart.png", Inches(2.2), P.add_card(s, 2.0, 2.4, 9.3, 4.3, fill=P.SURFACE)
Inches(2.4), width=Inches(8.9)) s.shapes.add_picture("<task_dir>/slides/chart.png", Inches(2.4),
# 数据来源 (右下角弱化) Inches(2.7), width=Inches(8.5))
P.add_textbox(slide, P.SAFE_LEFT, 6.95, P.SAFE_W, 0.4, P.add_textbox(s, P.SAFE_LEFT, 6.95, P.SAFE_W, 0.4, "数据来源:公司年报 2025",
"数据来源: 公司年报 2025", 11, color=P.GREY_LIGHT, 11, color=P.GREY_LIGHT, align=PP_ALIGN.RIGHT, shrink=False,
align=PP_ALIGN.RIGHT, shrink=False, name="source") name="source")
``` ```
--- ---
## L7 · 图片为主 (Image-focus) —— 文字在图旁,不压图 ## L7 · 图片为主 (Image-focus) —— 图占 58%,文字独立区
> 之前用满铺图 + 半透明遮罩,效果不稳定。改成"图占 60% + 文字独立区"。
```python ```python
from pptx.util import Inches from pptx.util import Inches
from pptx.enum.shapes import MSO_SHAPE from pptx.enum.shapes import MSO_SHAPE
slide = P.add_slide(prs) s = P.add_slide(prs)
# 左侧图占 60% 宽 (只给 width 或 height 一项,避免变形;此处图需正好铺满左 8 寸高 7.5 寸时按素材比例取舍) P.add_bg(s, P.WHITE)
slide.shapes.add_picture("<task_dir>/slides/hero.jpg", Inches(0), Inches(0), # 左侧图(只给 height 等比铺满,避免变形)
s.shapes.add_picture("<task_dir>/slides/hero.jpg", Inches(0), Inches(0),
height=Inches(7.5)) height=Inches(7.5))
# 右侧浅灰背景区放文字 # 右侧浅底文字区
P.add_rect(slide, 8, 0, 5.33, 7.5, P.BG, "text_panel") P.add_rect(s, 7.7, 0, 5.63, 7.5, P.PRIMARY_WASH, "text_panel")
P.add_rect(slide, 8.4, 1.0, 0.06, 0.8, P.ACCENT, "deco_bar") # 装饰短线 P.add_eyebrow(s, 8.1, 1.4, "PRODUCT")
P.add_textbox(slide, 8.4, 2.0, 4.6, 1.6, "走进未来", 36, P.add_textbox(s, 8.1, 1.9, 4.9, 1.0, "走进未来", 36, bold=True, color=P.INK,
bold=True, color=P.INK, name="img_title") name="img_title")
P.add_textbox(slide, 8.4, 3.8, 4.6, 1.5, 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") 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")
P.add_shape(slide, MSO_SHAPE.RIGHT_ARROW, 8.4, 6.5, 0.7, 0.35, P.ACCENT,
"img_cta")
``` ```
--- ---
## L8 · 金句 / 大字 (Quote) —— 留白主导,装饰极简 ## L8 · 金句 / 大字 (Quote) —— 留白主导
```python ```python
from pptx.enum.text import MSO_ANCHOR from pptx.enum.text import MSO_ANCHOR
slide = P.add_slide(prs) s = P.add_slide(prs)
P.apply_brand(slide, "inner") P.apply_brand(s, "inner")
# 左上大引号 (用字形;font=EN_FONT 走 Arial) P.add_textbox(s, 0.8, 0.6, 1.5, 1.5, '"', 200, bold=True, color=P.ACCENT,
P.add_textbox(slide, 0.8, 0.6, 1.5, 1.5, '"', 200, bold=True, font=P.EN_FONT, shrink=False, name="quote_mark")
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_textbox(slide, 1.5, 2.7, 10.5, 2.0, P.add_accent_line(s, 1.5, 5.0, length=0.5)
"把复杂留给我们,把简单留给用户。", 36, bold=True, P.add_textbox(s, 1.5, 5.2, 10.5, 0.5, "—— 公司价值观 2025", 16, color=P.GREY,
color=P.INK, anchor=MSO_ANCHOR.MIDDLE, name="quote_text") name="quote_attr")
# 装饰短线
P.add_accent_line(slide, 1.5, 5.0, length=0.5)
# 出处
P.add_textbox(slide, 1.5, 5.2, 10.5, 0.5, "—— 公司价值观 2025",
16, color=P.GREY, name="quote_attr")
``` ```
--- ---
## L9 · 结尾 / Q&A —— 浅底 + 大字,**强制必有** ## L9 · 结尾 / Q&A —— 浅底 + 大字,**强制必有**
> **不是可选** —— 任何 deck 都必须以这页收尾。无论是汇报、提案、路演,缺尾页等于"话没说完"。 > **不是可选** —— 任何 deck 都必须以这页收尾。
```python ```python
from pptx.enum.text import PP_ALIGN from pptx.enum.text import PP_ALIGN
slide = P.add_slide(prs) s = P.add_slide(prs)
P.apply_brand(slide, "end") # 整页浅灰 + 顶/底强调短线 P.apply_brand(s, "end") # PRIMARY_WASH 浅底 + 顶/底强调短线
P.add_textbox(slide, 0, 2.5, P.SLIDE_W, 1.6, "Thank You", 80, bold=True, 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, color=P.PRIMARY, align=PP_ALIGN.CENTER, font=P.EN_FONT,
name="thanks") name="thanks")
P.add_textbox(slide, 0, 4.3, P.SLIDE_W, 0.6, "欢迎提问与讨论", P.add_textbox(s, 0, 4.3, P.SLIDE_W, 0.6, "欢迎提问与讨论", 22, color=P.INK,
22, color=P.ACCENT, align=PP_ALIGN.CENTER, name="qa") align=PP_ALIGN.CENTER, name="qa")
P.add_textbox(slide, 0, 6.2, P.SLIDE_W, 0.5, P.add_textbox(s, 0, 6.2, P.SLIDE_W, 0.5, "联系方式 / 邮箱 / 公众号", 14,
"联系方式 / 邮箱 / 公众号", 14, color=P.GREY_LIGHT, color=P.GREY_LIGHT, align=PP_ALIGN.CENTER, name="contact")
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}")
``` ```
--- ---
@ -294,19 +404,25 @@ P.add_textbox(slide, 0, 6.2, P.SLIDE_W, 0.5,
## 选版式速查 ## 选版式速查
``` ```
有数据 ≥ 3 点 → L6 (Chart-focus) 封面 → L1 (Cover)
对比类 (前/后, A/B) → L5 (Two-Column) 目录 → L2 (Agenda)
要点 ≤ 5 条 → L4 (Bullets)
转场 / 换章 → L3 (Section Divider) 转场 / 换章 → L3 (Section Divider)
首页 → L1 (Cover) 要点 ≤ 5 条(纯文字) → L4 (Bullets)
末页 → L9 (Q&A) 对比类 (前/后, A/B) → L5 (Two-Column)
有数据图表 → L6 (Chart-focus)
有大图 / 视觉优先 → L7 (Image-focus) 有大图 / 视觉优先 → L7 (Image-focus)
观点强调 / 名言 → L8 (Quote) 观点强调 / 名言 → L8 (Quote)
末页 → L9 (Q&A) [强制]
2-4 个关键数字 → L10 (KPI 数字卡) ← 优先于硬画柱图
业务概念(能力/模块) → L11 (卡片网格 + 图标) ← 优先于圆点 bullet
流程 / 步骤 → L12 (Process)
单个震撼数字 → L13 (Stat Highlight)
``` ```
## 三个常犯的越界场景 ## 三个常犯的越界场景
1. **bullet 字数超额** —— 22pt 在 11.5 寸宽下每行约 50 个中文字。超过 1 行就溢出 0.7 in 高的框。`add_textbox` 内置 `assert_inside` + shrink-to-fit 兜底;但根本解法是**字数压缩**(见 design_principles.md §字数预算) 1. **bullet 字数超额** —— 22pt 在 11.5 寸宽下每行约 50 个中文字,超 1 行就溢出 0.6 in 框。根本解法是**字数压缩**(见 design_principles.md §字数预算),不要靠 `auto_size` 收字号兜底。
2. **标题占两行** —— 标题在 0.7 in 高的框里,32pt 单行高约 0.45 in,**两行就溢出**。中文标题 ≤ 30 字 2. **卡片内容超出卡片** —— 卡片内文字按 `卡宽 - 2×0.4` 内边距算框宽;标题/正文字数超了会顶出卡片下边缘。卡片高度留够(KPI 卡 ≥2.5,概念卡 ≥3.4)。
3. **图片不等比拉伸** —— `add_picture(width=, height=)` 同时给会变形;**只给 width 或 height 一项** 3. **图片不等比拉伸** —— `add_picture(width=, height=)` 同时给会变形;**只给 width 或 height 一项**。
4. **渐变深底上用深色字** —— L3 章节页 / cover 渐变块上的文字必须 `WHITE` / `ACCENT_SOFT`,用 `INK` 看不清。
``` ```

View File

@ -1,15 +1,13 @@
"""pptx_helpers.py — PPT skill 的共享版式工具箱 """pptx_helpers.py — PPT skill 的共享版式工具箱(卡片式视觉系统)
逐页生成时**每页一个 run_python**(载入已有 .pptx append 一页 save), deck 在一个 `build_deck.py` 里构建,每页一个小函数,这些 helper 统一在
这些 helper 以前要在每页里重新默写一遍 既烧 token 又会在长 deck 里漂移 `P.` 命名空间下调用 既省 token,又保证长 deck 里坐标/配色不漂移
( 7 页的 apply_brand 坐标和第 2 页写得不一样)收进本模块后,每页只 import
用法( run_python block 顶部): 用法( build_deck.py 顶部):
import sys; sys.path.insert(0, "<skill_dir>/scripts") # <skill_dir> 用 system prompt 注入值 import sys; sys.path.insert(0, "<skill_dir>/scripts") # <skill_dir> 用 system prompt 注入值
import pptx_helpers as P import pptx_helpers as P
# —— 第一页(创建)——
prs = P.new_presentation("16:9") # 默认 16:9,可传 4:3 / 9:16 / 3:4 prs = P.new_presentation("16:9") # 默认 16:9,可传 4:3 / 9:16 / 3:4
P.set_palette(spec_path="<task_dir>/...spec.md") # 默认商务红;spec 覆盖了才需要 P.set_palette(spec_path="<task_dir>/...spec.md") # 默认商务红;spec 覆盖了才需要
slide = P.add_slide(prs) slide = P.add_slide(prs)
@ -17,12 +15,12 @@
P.add_textbox(slide, 0.9, 2.6, 11.9, 1.4, "标题", 44, bold=True, color=P.INK) P.add_textbox(slide, 0.9, 2.6, 11.9, 1.4, "标题", 44, bold=True, color=P.INK)
prs.save("<task_dir>/<topic>.pptx") prs.save("<task_dir>/<topic>.pptx")
# —— 后续页(追加)—— 视觉系统(相对老版"平矩形 + 圆点 bullet"的升级):
prs = P.load("<task_dir>/<topic>.pptx") # 从文件实际尺寸回填画布常量 - **卡片**:`add_card` 圆角 + 柔和投影 + 可选底色/边线/强调条 内容页主力容器
P.set_palette(spec_path="<task_dir>/...spec.md") # 每页都重读 spec(同 SKILL.md 规则) - **色阶**:`set_palette` 从主//强调派生 wash/soft/dark 明暗阶,白底之外有层次
slide = P.add_slide(prs) - **渐变**:`add_gradient_rect` 用于封面/章节大色块(原生可编辑,非图片)
... - **组件**:`add_kpi`(数字卡) `add_pill`(胶囊标签) `add_icon_tile`(图标底块)
prs.save("<task_dir>/<topic>.pptx") `add_eyebrow`(小标签) `add_chevron`(流程箭头) `add_notes`(演讲者备注)
一律用 `P.xxx` 访问颜色常量与函数 set_palette 靠改模块属性生效, 一律用 `P.xxx` 访问颜色常量与函数 set_palette 靠改模块属性生效,
`from pptx_helpers import *` 会把旧绑定拷进页面命名空间,覆盖配色不生效 `from pptx_helpers import *` 会把旧绑定拷进页面命名空间,覆盖配色不生效
@ -33,7 +31,7 @@ import re
from pathlib import Path from pathlib import Path
from pptx import Presentation from pptx import Presentation
from pptx.util import Inches, Pt from pptx.util import Inches, Pt, Emu
from pptx.dml.color import RGBColor from pptx.dml.color import RGBColor
from pptx.enum.text import PP_ALIGN, MSO_ANCHOR, MSO_AUTO_SIZE from pptx.enum.text import PP_ALIGN, MSO_ANCHOR, MSO_AUTO_SIZE
from pptx.enum.shapes import MSO_SHAPE from pptx.enum.shapes import MSO_SHAPE
@ -54,6 +52,14 @@ HAIRLINE = RGBColor(0xDD, 0xDD, 0xDD) # 细分隔线
BG = RGBColor(0xFA, 0xFA, 0xFA) # 背景近白 BG = RGBColor(0xFA, 0xFA, 0xFA) # 背景近白
WHITE = RGBColor(0xFF, 0xFF, 0xFF) WHITE = RGBColor(0xFF, 0xFF, 0xFF)
# —— 从主/辅/强调派生的明暗色阶 (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> 槽位 CN_FONT = "微软雅黑" # 中文字形走 <a:ea> 槽位
EN_FONT = "Arial" # 拉丁字形走 <a:latin> 槽位 EN_FONT = "Arial" # 拉丁字形走 <a:latin> 槽位
@ -118,16 +124,44 @@ def add_slide(prs: Presentation):
# ============================================================ # ============================================================
# 配色覆盖 (默认商务红;spec 写了别的色才调) # 配色覆盖 (默认商务红;spec 写了别的色才调) + 色阶派生
# ============================================================ # ============================================================
def _to_rgb(h: str) -> RGBColor: def _to_rgb(h: str) -> RGBColor:
return RGBColor.from_string(h.lstrip("#").upper()) 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.22)
ACCENT_SOFT = tint(ACCENT, 0.78)
def set_palette(primary: str | None = None, secondary: str | None = None, def set_palette(primary: str | None = None, secondary: str | None = None,
accent: str | None = None, cn_font: str | None = None, accent: str | None = None, cn_font: str | None = None,
en_font: str | None = None, spec_path=None) -> None: en_font: str | None = None, spec_path=None) -> None:
"""覆盖主题色 / 字体。逐页生成时每页都调一次(对齐 SKILL.md「每页重读 spec」)。 """覆盖主题色 / 字体,并重算派生色阶。整 deck 设一次
- 显式传 primary/secondary/accent(hex,带不带 # 都行)即覆盖对应色。 - 显式传 primary/secondary/accent(hex,带不带 # 都行)即覆盖对应色。
- spec_path: spec.md 按文档顺序取前 3 #hex 作 主/辅/强调 - spec_path: spec.md 按文档顺序取前 3 #hex 作 主/辅/强调
@ -155,6 +189,7 @@ def set_palette(primary: str | None = None, secondary: str | None = None,
CN_FONT = cn_font CN_FONT = cn_font
if en_font: if en_font:
EN_FONT = en_font EN_FONT = en_font
_recompute_ramp()
# ============================================================ # ============================================================
@ -213,6 +248,7 @@ def add_textbox(slide, left, top, width, height, text, size,
assert_inside(left, top, width, height, name) assert_inside(left, top, width, height, name)
tb = slide.shapes.add_textbox(Inches(left), Inches(top), tb = slide.shapes.add_textbox(Inches(left), Inches(top),
Inches(width), Inches(height)) Inches(width), Inches(height))
tb.name = name # 语义名写进 pptx —— quality_check 按名豁免标签 / 计 bullet 靠这个
tf = tb.text_frame tf = tb.text_frame
tf.vertical_anchor = anchor tf.vertical_anchor = anchor
tf.word_wrap = True tf.word_wrap = True
@ -229,6 +265,7 @@ def add_rect(slide, left, top, width, height, fill, name="rect"):
assert_inside(left, top, width, height, name) assert_inside(left, top, width, height, name)
s = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, Inches(left), Inches(top), s = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, Inches(left), Inches(top),
Inches(width), Inches(height)) Inches(width), Inches(height))
s.name = name
s.fill.solid() s.fill.solid()
s.fill.fore_color.rgb = fill s.fill.fore_color.rgb = fill
s.line.fill.background() s.line.fill.background()
@ -239,6 +276,7 @@ def add_shape(slide, kind, left, top, width, height, fill, name="shape"):
assert_inside(left, top, width, height, name) assert_inside(left, top, width, height, name)
s = slide.shapes.add_shape(kind, Inches(left), Inches(top), s = slide.shapes.add_shape(kind, Inches(left), Inches(top),
Inches(width), Inches(height)) Inches(width), Inches(height))
s.name = name
s.fill.solid() s.fill.solid()
s.fill.fore_color.rgb = fill s.fill.fore_color.rgb = fill
s.line.fill.background() s.line.fill.background()
@ -269,13 +307,244 @@ def add_badge(slide, x, y, num, diameter=0.7, fill=None, fg=None):
return c 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=True, border=False, accent=None, accent_w=0.07,
name="card"):
"""圆角卡片:白面 + 柔和投影 + 可选发丝边 + 可选左侧强调竖条。
- fill:卡片底色,默认 SURFACE()想要浅色卡传 PRIMARY_SOFT / PRIMARY_WASH
- accent:给个颜色则在卡片左内缘加一条竖强调条(常用 PRIMARY / ACCENT)
内容(标题/正文/图标)再叠到卡片上,自己按 left+0.3 内边距摆
"""
card = add_round_rect(slide, left, top, width, height,
SURFACE if fill is None else fill, radius, name)
if shadow:
set_shadow(card)
if border:
set_line(card, HAIRLINE, 0.75)
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, sub=None,
value_color=None, card=True, value_size=40, name="kpi"):
"""KPI 数字卡:大号数字 + 下方标签 + 可选小注(同比/单位)。
数据页把"小柱状图 / 一行结论"升级成 2-4 张并排数字卡,信息密度与质感都更高
value EN_FONT(数字/百分号更紧致)
"""
if card:
add_card(slide, left, top, width, height, fill=SURFACE, radius=0.12,
shadow=True, name=name + "_card")
pad = 0.28
vh = height * 0.5
add_textbox(slide, left + pad, top + pad, width - 2 * pad, vh, 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 + vh, width - 2 * pad, 0.4, label,
15, color=INK, anchor=MSO_ANCHOR.TOP, name=name + "_label")
if sub:
add_textbox(slide, left + pad, top + height - 0.5, width - 2 * pad,
0.35, sub, 12, color=GREY_LIGHT, shrink=False,
name=name + "_sub")
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")
# ============================================================
# 演讲者备注
# ============================================================
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="项目汇报"): def page_title(slide, text, page_num=None, total=None, footer="项目汇报",
add_textbox(slide, SAFE_LEFT, SAFE_TOP, SAFE_W, 0.7, text, 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") 32, bold=True, color=PRIMARY, name="title")
add_accent_line(slide, SAFE_LEFT, SAFE_TOP + 0.85, length=0.8) add_accent_line(slide, SAFE_LEFT, ty + 0.85, length=0.8)
if page_num is not None and total is not None: if page_num is not None and total is not None:
add_textbox(slide, SAFE_LEFT, SLIDE_H - 0.5, 6, 0.4, footer, add_textbox(slide, SAFE_LEFT, SLIDE_H - 0.5, 6, 0.4, footer,
11, color=GREY_LIGHT, shrink=False, name="footer") 11, color=GREY_LIGHT, shrink=False, name="footer")
@ -289,28 +558,34 @@ def page_title(slide, text, page_num=None, total=None, footer="项目汇报"):
# ============================================================ # ============================================================
def apply_brand(slide, kind="inner"): def apply_brand(slide, kind="inner"):
"""统一品牌锚点。每个版式第一行调用。 """统一品牌锚点。每个版式第一行调用。
cover 左侧主色长竖条 + 顶部短横 + 底部细灰线
inner (默认) 左侧主色窄条 + 底部细灰线 cover 右侧主色深主色渐变大块 + 左侧细强调短线(现代封面)
section 整页浅灰 + 左侧主色竖条 + 强调色粗竖条 inner (默认) 近白底 + 左侧主色窄条 + 底部细灰线
end 整页浅灰 + /底强调色短线 section 主色深主色整页渐变 + 强调装饰条(章节大色块)
end 浅底 + /底强调短线
""" """
btm = SLIDE_H - 0.32 btm = SLIDE_H - 0.32
if kind == "cover": if kind == "cover":
add_rect(slide, 0, 0, 0.18, SLIDE_H, PRIMARY, "brand_left_bar") add_bg(slide, WHITE)
add_rect(slide, 0.7, 0.6, 0.8, 0.06, PRIMARY, "brand_top_line") # 右侧约 38% 宽的渐变色块,封面从"白纸加条"升级成有视觉重量的构图
add_rect(slide, SAFE_LEFT, btm, SAFE_W, 0.02, HAIRLINE, bw = SLIDE_W * 0.40
"brand_btm_hairline") 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": elif kind == "section":
add_rect(slide, 0, 0, SLIDE_W, SLIDE_H, BG, "brand_bg") add_gradient_rect(slide, 0, 0, SLIDE_W, SLIDE_H,
add_rect(slide, 0, 0, 0.18, SLIDE_H, PRIMARY, "brand_left_bar") PRIMARY, PRIMARY_DARK, angle=55, name="section_bg")
add_rect(slide, 0.7, SLIDE_H / 3, 0.08, SLIDE_H / 3, ACCENT, add_rect(slide, 0.7, SLIDE_H / 3, 0.09, SLIDE_H / 3, ACCENT,
"brand_section_bar") "brand_section_bar")
elif kind == "end": elif kind == "end":
add_rect(slide, 0, 0, SLIDE_W, SLIDE_H, BG, "brand_bg") add_bg(slide, PRIMARY_WASH)
add_rect(slide, SAFE_LEFT, 0.6, 0.8, 0.06, ACCENT, "brand_top_line") 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, add_rect(slide, SAFE_RIGHT - 0.8, SLIDE_H - 0.65, 0.8, 0.06, ACCENT,
"brand_btm_line") "brand_btm_line")
else: # inner else: # inner
add_bg(slide, BG)
add_rect(slide, 0, 0, 0.10, SLIDE_H, PRIMARY, "brand_left_bar") add_rect(slide, 0, 0, 0.10, SLIDE_H, PRIMARY, "brand_left_bar")
add_rect(slide, SAFE_LEFT, btm, SAFE_W, 0.02, HAIRLINE, add_rect(slide, SAFE_LEFT, btm, SAFE_W, 0.02, HAIRLINE,
"brand_btm_hairline") "brand_btm_hairline")

View File

@ -24,6 +24,7 @@
from __future__ import annotations from __future__ import annotations
import argparse import argparse
import colorsys
import re import re
import sys import sys
from pathlib import Path from pathlib import Path
@ -47,14 +48,46 @@ _OVERLAP_MIN_RATIO = 0.25 # 交叠面积 / 较小形状面积 超过此比例
# ---- 颜色辅助 ---- # ---- 颜色辅助 ----
# 三色制按"色系数"判定,不是"hex 数":主/辅常同色系,主色的明暗阶(深红 #8A0000)、
# 浅底(wash/soft tint #F2CCCC)都从那三色派生,不该被算成"新色"。所以:
# - 低饱和的浅色/灰阶 → 中性(卡片底、wash 底),不计入彩色
# - 高饱和的算"彩色",但按色相(hue)归桶 —— 同色系(红的深浅)收敛成一个
# 这样"白底+红卡片+深红渐变+金强调"= 2 个色系,不会误报超 3 色。
def _is_neutral(hex6: str) -> bool: def _hsv(hex6: str):
"""灰阶/黑/白判定:R/G/B 极差 ≤ 12 即视为中性色(三色制里不计入彩色)。""" 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: try:
r, g, b = int(hex6[0:2], 16), int(hex6[2:4], 16), int(hex6[4:6], 16) _h, s, v = _hsv(hex6)
except (ValueError, IndexError): except (ValueError, IndexError):
return False return False
return max(r, g, b) - min(r, g, b) <= 12 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_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: def _shape_fill_hex(shape) -> str | None:
@ -132,8 +165,8 @@ def check_pptx(path: Path, spec: dict) -> tuple[list, list]:
for idx, slide in enumerate(prs.slides, 1): for idx, slide in enumerate(prs.slides, 1):
title_text = None title_text = None
bullet_count = 0
small_font_count = 0 small_font_count = 0
bullet_xs: list = [] # 每个 bullet 项的 x 中心 —— 末尾按列分组判 ≤5
content_shapes: list = [] # (l, t, w, h, label, head) — 有文字 / 图片的形状 content_shapes: list = [] # (l, t, w, h, label, head) — 有文字 / 图片的形状
for s_i, shape in enumerate(slide.shapes): for s_i, shape in enumerate(slide.shapes):
@ -211,7 +244,11 @@ def check_pptx(path: Path, spec: dict) -> tuple[list, list]:
break break
if first_size_pt: if first_size_pt:
break break
if first_size_pt and w_in > 0.5 and h_in > 0.2: # 大号展示字(标题/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)) chars_per_line = max(1, int(w_in * 72 / first_size_pt))
est_lines = 0 est_lines = 0
for para in tf.paragraphs: for para in tf.paragraphs:
@ -233,14 +270,26 @@ def check_pptx(path: Path, spec: dict) -> tuple[list, list]:
except (AttributeError, TypeError, ValueError): except (AttributeError, TypeError, ValueError):
pass 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: for para in tf.paragraphs:
ptxt = (para.text or "").strip() ptxt = (para.text or "").strip()
if not ptxt: if not ptxt:
continue continue
if len(ptxt) > 1 and ptxt != title_text:
bullet_count += 1
for run in para.runs: for run in para.runs:
if run.font.size: # 标签类(eyebrow/胶囊/页脚/小注)天然小字,不算"投影看不清"
if run.font.size and not is_label:
if run.font.size < Pt(14): if run.font.size < Pt(14):
small_font_count += 1 small_font_count += 1
if run.font.color and run.font.color.type: if run.font.color and run.font.color.type:
@ -258,9 +307,15 @@ def check_pptx(path: Path, spec: dict) -> tuple[list, list]:
f"{idx} 页标题过长 ({len(title_text)} 字): {title_text[:20]}..." f"{idx} 页标题过长 ({len(title_text)} 字): {title_text[:20]}..."
) )
if bullet_count > 5: # 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( warnings.append(
f"{idx} 页 bullet {bullet_count} 条 (上限 5),建议拆页或转图表" f"{idx}单列 bullet {max_col} 条 (上限 5),建议拆页或转图表"
) )
if small_font_count > 0: if small_font_count > 0:
@ -287,20 +342,23 @@ def check_pptx(path: Path, spec: dict) -> tuple[list, list]:
f'{alab}("{ahead}") × {blab}("{bhead}")' f'{alab}("{ahead}") × {blab}("{bhead}")'
) )
# 三色制按"非灰阶色"判定:灰/黑/白不计 (design_principles §2「其他全部用灰阶」) # 三色制按"色系数"判定:同色系深浅(主色/深红渐变/浅红卡片底)收敛成一桶,
chromatic = {c for c in seen_colors if not _is_neutral(c)} # 低饱和浅色/灰阶不计。这样卡片式设计的派生色阶不会被误报超 3 色。
if len(chromatic) > 3: chromatic = {c for c in seen_colors if _is_chromatic(c)}
families = {_hue_family(c) for c in chromatic}
if len(families) > 3:
warnings.append( warnings.append(
f"非灰阶色 {len(chromatic)} 种 (三色制上限 3): " f"彩色色系 {len(families)} (三色制上限 3): "
f"{', '.join('#' + c for c in sorted(chromatic))};收敛到主/辅/强调三色" f"{', '.join('#' + c for c in sorted(chromatic))};收敛到主/辅/强调三色"
) )
if spec_colors: if spec_colors:
spec_chromatic = {c for c in spec_colors if not _is_neutral(c)} spec_families = {_hue_family(c) for c in spec_colors if _is_chromatic(c)}
extra = chromatic - spec_chromatic extra = {c for c in chromatic if _hue_family(c) not in spec_families}
if extra: if extra:
spec_chromatic = {c for c in spec_colors if _is_chromatic(c)}
warnings.append( warnings.append(
f"出现 spec 之外的非灰阶{', '.join('#' + c for c in sorted(extra))};" f"出现 spec 之外的 {', '.join('#' + c for c in sorted(extra))};"
f"擅自换色 / 非主题色 (spec 定的是 " f"擅自换色 / 非主题色 (spec 定的是 "
f"{', '.join('#' + c for c in sorted(spec_chromatic))})" f"{', '.join('#' + c for c in sorted(spec_chromatic))})"
) )