diff --git a/PROGRESS.md b/PROGRESS.md index 1697629..7617405 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -23,6 +23,7 @@ ### 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`。 - **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 步`(`
` 点开看清单)。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 过)。 diff --git a/SKILL_LIST.md b/SKILL_LIST.md index dc7ade7..acbbcc7 100644 --- a/SKILL_LIST.md +++ b/SKILL_LIST.md @@ -137,7 +137,7 @@ zcbot 的"skill"是一份可加载的工作流脚本(`skills//SKILL.md` + ### ppt **生成 PowerPoint 演示文稿 (.pptx)。** -把材料(汇报草稿 / 项目方案 / 调研报告)变成可演示的 .pptx。流程:**先定调(8 项 + 逐页大纲)→ 一个脚本建整 deck → quality_check 验收**。方向在大纲阶段对齐,执行阶段一把出稿(不逐页来回)。 +把材料(汇报草稿 / 项目方案 / 调研报告)变成可演示的 .pptx。流程:**先定调(8 项 + 逐页大纲)→ 一个脚本建整 deck → quality_check 验收**。方向在大纲阶段对齐,执行阶段一把出稿(不逐页来回)。视觉走**卡片式系统**(圆角卡片 + 柔和投影 + 渐变 + 从主色派生的明暗色阶),原生可编辑,告别扁平办公模板观感。 **触发**: - ✅ 用户明确点名 PPT / 幻灯片 / 演示文稿 / .pptx / slide / deck @@ -157,14 +157,16 @@ zcbot 的"skill"是一份可加载的工作流脚本(`skills//SKILL.md` + | 4 | 风格 | 现代简约(白底 + 细线 + 留白) | | 5 | 配色 | 商务红 | | 6 | 字体 | 微软雅黑 + Arial | -| 7 | 图标 | Iconify `tabler` 集(主色染色,本地缓存) | -| 8 | 图表 | 数据 ≥ 3 个点的页用 matplotlib 配图 | +| 7 | 图标 | Iconify `tabler` 集(主色染色,本地缓存;概念页配图标底块) | +| 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 字形) -- `apply_brand` 品牌条 + 安全区 / 越界保护 -- `quality_check.py` 验收(越界 / 文本溢出 / 颜色一致) +- `apply_brand` 品牌锚点(封面/章节渐变)+ 安全区 / 越界保护 +- `quality_check.py` 验收(越界 / 文本溢出 / 按列 bullet ≤5 / 按色系三色制 / 内容重叠) - 素材摄取走 markitdown 把 PDF/DOCX/PPTX/XLSX/HTML/URL 统一转 Markdown **典型产物**:`.pptx` + `build_deck.py`(整 deck 构建脚本,改稿/修验收项都改它重跑)。 diff --git a/skills/ppt/SKILL.md b/skills/ppt/SKILL.md index 44c1dd7..1ec3920 100644 --- a/skills/ppt/SKILL.md +++ b/skills/ppt/SKILL.md @@ -10,7 +10,7 @@ description: 生成 PowerPoint 演示文稿 (.pptx) 文件。✅ 触发:用户 进度展示建议:多页 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/layouts.md` —— 9 种版式的调用示例(基于 `pptx_helpers`)+ helper API 速查 + 安全区/越界保护 + `apply_brand` 品牌条 - `references/icons.md` —— 业务图标两层:Iconify (在线/本地缓存) / unicode 字形兜底 @@ -66,20 +66,23 @@ glob /*--*.spec.md → 按文件名字典序排,取最 | 4 | 风格 | **现代简约** (白底 + 细线 + 留白) | | 5 | 配色 | **商务红** `#C00000` `#E15554` `#FFC107` (见上"默认主题") | | 6 | 字体 | **微软雅黑 + Arial** | -| 7 | 图标 | **Iconify `tabler` 集** (描边商务图标,主色染色;`fetch_icon.py` 拉到 `/assets/icons/`) | -| 8 | 图表 | 数据 ≥ 3 个点的页用 matplotlib 配图 | +| 7 | 图标 | **Iconify `tabler` 集** (描边商务图标,主色染色;`fetch_icon.py` 拉到 `/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(改一行文字大纲,比建完一页 slide 再推翻便宜得多): -| 页 | 版式 | 标题 | 核心信息 / 要点(≤5) | 图标 / 图表 | +| 页 | 版式 | 标题 | 核心信息 / 要点(≤5) | 图标 / 图表 / 配图 | |---|---|---|---|---| -| 1 | L1 封面 | <主标题> | <副标题 / 定位> | — | -| 2 | L4 三栏 | <标题> | <要点 1 / 2 / 3> | `target` / `cpu` / `chart-bar` | +| 1 | L1 封面 | <主标题> | <副标题 / 定位> | 可选 `[img]` 主图 | +| 2 | L11 卡片网格 | <标题> | <要点 1 / 2 / 3> | `target` / `cpu` / `chart-bar` | +| 3 | L10 KPI 卡 | <标题> | <数字 1 / 2 / 3 / 4> | — | | … | … | … | … | … | | N | L9 尾页 | Q&A / 致谢 | <联系方式> | — | +> 版式从 layouts.md 的 L1-L13 里选(见 §选版式速查):**业务概念优先 L11 卡片网格(图标底块,别只摆圆点)**,**2-4 个关键数字优先 L10 KPI 卡(别硬画柱图)**,**单个震撼数字用 L13**。要真实配图的页在「图标/图表/配图」列标 `[img]` + 一句画面描述。 + 大纲连同 8 项一起给用户预览,**BLOCKING 等用户确认整份结构**(页数、每页讲什么、用什么版式/图标)后再进阶段二。用户在这一步推翻方向 = 改表格文字,零 slide 返工。 ### 阶段二: 执行 (Executor) — 一个脚本建整 deck @@ -89,10 +92,11 @@ glob /*--*.spec.md → 按文件名字典序排,取最 流程: 1. **读 current spec**(按 §0 的 glob 规则拿字典序最大那份),含 8 项 + 逐页大纲;只用里面定的颜色/字体/图标/页结构,**不凭记忆发挥**。 2. **图标批量预取(全 deck 一次,不逐页)**: 把大纲里所有页需要的图标概念汇总,`glob` 两处看现成 —— 种子库 `/assets/icons/`(只读)+ 本 task `/assets/icons/`;缺的在**一个 `run_python` 里批量** `fetch_icon.py --set tabler --color C00000 --size 128 -o /assets/icons/...` 拉齐。**几何形状(圆点/徽章/装饰线)不算图标,走 layouts.md helper**。 -3. **写 `build_deck.py` 到 ``,一次建整 deck**: 顶部 `import pptx_helpers as P`(`sys.path` 指到 `/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=...)` 跑(避免大段源码进对话历史)。 -4. **quality_check 一次**(见阶段三)→ 按报告**改 `build_deck.py` 重跑**(不要逐页 edit 成品 .pptx —— 改源脚本可复现、可再跑)。 -5. 报整份 deck:页数、各页版式、用到的图标;问用户要不要改。 -6. 用户确认了**实质改动**(改版式 / 换图标 / 改文案要点 / 增删页 / 调主色)后,追加一行到 `/REVISIONS.md` —— 见 §修订日志。 +3. **真实配图(opt-in,仅当大纲标了 `[img]`)**: 把标 `[img]` 的页(封面/章节/图片页)汇总,**load `imagegen` skill 走它自己的确认流程**逐张生成(每张 ¥0.22,有强制确认门,不要绕过),产物落 `/figures/`;build_deck 里 `add_picture()` 引用。**没标 `[img]` 的 deck 跳过这步**,图标/卡片/渐变已足够撑视觉。 +4. **写 `build_deck.py` 到 ``,一次建整 deck**: 顶部 `import pptx_helpers as P`(`sys.path` 指到 `/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. **quality_check 一次**(见阶段三)→ 按报告**改 `build_deck.py` 重跑**(不要逐页 edit 成品 .pptx —— 改源脚本可复现、可再跑)。 +7. 报整份 deck:页数、各页版式、用到的图标/配图;问用户要不要改。 +8. 用户确认了**实质改动**(改版式 / 换图标 / 改文案要点 / 增删页 / 调主色)后,追加一行到 `/REVISIONS.md` —— 见 §修订日志。 **风格探针(可选,降视觉返工险)**: 用户对观感没底、或这是全新风格时,可先只建**封面 + 1 内页**给用户看一眼,确认后把 `build_deck.py` 的页范围放开重跑补齐其余页 —— 仍是改一个脚本,不退回逐页。用户要快("直接全做")就跳过探针,整 deck 一把出。 @@ -108,15 +112,18 @@ python /scripts/quality_check.py / --spec /scripts/quality_check.py / --spec / ├── source/ # markitdown 转出的素材(同 working_dir 多 task 共享;用 markitdown -o /source/.md) ├── --.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 项都改它重跑 ├── REVISIONS.md # 修订日志:每次卡点用户确认的实质改动,见 §修订日志 └── .pptx # 最终产物 (按主题命名,多 task 时主题必须不同) @@ -185,7 +194,10 @@ python /scripts/quality_check.py / --spec 白底之上靠卡片(`add_card` 圆角+投影)+ 浅色阶分层,才有"现代咨询风"的层次;纯白底裸贴元素 = 扁平办公模板。 + ### 禁忌 - 红配绿、紫配黄等高对比互补色不要直接用 -- 渐变只用在 accent 上,正文/标题不要渐变 +- **渐变只用在大色块**(封面右块 / 章节整页,`apply_brand` 已内置);正文/标题/小图形不要渐变 - 一份 deck 主色不要换。封面是 A 色、内页变 B 色 —— 这是大忌 +- 渐变深底上文字一律用**白 / `ACCENT_SOFT`**,别用深灰 `INK`(看不清) ## 3. 留白 @@ -67,7 +76,8 @@ | 目录 | 每条 ≤ 15 字 | 不要图 | | 分章页 | ≤ 20 字 | 大号数字 + 章节名 | | 要点页 | bullet ≤ 5 条,每条 ≤ 25 字 | 可选小图标 | -| 数据页 | 标题 + 一句结论 | **必须有图表** | +| 数据页 | 标题 + 一句结论 | **必须有图表**;2-4 个数字优先 KPI 卡(L10)而非柱图 | +| 概念页 | 卡片标题 ≤6 字 + 说明 ≤2 行 | 图标底块 + 卡片网格(L11),别裸圆点 | | 图片页 | ≤ 15 字标题 + 1-2 行说明 | 主体是图 | ## 4.1 字数预算 (避免溢出) @@ -116,10 +126,13 @@ ## 7. 图表规则 (matplotlib) +> **先问要不要图表**:只有 2-4 个数字 → 用 KPI 卡(layouts L10),别画柱图;真有趋势/分布/多系列才上 matplotlib。图表 png 嵌进 `add_card` 白卡片里(L6)比裸图精致。 + - 颜色用 spec 里定的主/辅/强调三色,**不要用 matplotlib 默认色板** - 字号: 标题 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['axes.unicode_minus'] = False` diff --git a/skills/ppt/references/layouts.md b/skills/ppt/references/layouts.md index ce656b1..ef99ab7 100644 --- a/skills/ppt/references/layouts.md +++ b/skills/ppt/references/layouts.md @@ -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 单脚本 — 默认路径) @@ -10,24 +12,25 @@ import sys sys.path.insert(0, "/scripts") # 用 system prompt 注入的绝对路径替换 import pptx_helpers as P +from pptx.enum.text import MSO_ANCHOR, PP_ALIGN +from pptx.enum.shapes import MSO_SHAPE SPEC = "/--.spec.md" OUT = "/.pptx" +ICONS = "/assets/icons" # fetch_icon.py 拉到这;种子库在 /assets/icons def page_1_cover(prs): - slide = P.add_slide(prs) - P.apply_brand(slide, "cover") + s = P.add_slide(prs) + P.apply_brand(s, "cover") # ... 见 L1 封面 ... def page_2(prs): - slide = P.add_slide(prs) + s = P.add_slide(prs) # ... 见对应 Lx 版式 ... -# ... 按大纲补齐 page_3 … page_N ... - def main(): 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, ...): # 按逐页大纲顺序 build(prs) prs.save(OUT) @@ -35,114 +38,127 @@ def main(): main() ``` -跑法:先 `write` 这个脚本到 `/build_deck.py`,再 `run_python(script_path="/build_deck.py")`。要改(quality_check 报错 / 用户要调)→ 改脚本里对应 `page_x` 函数,重跑整脚本(可复现,不 edit 成品 .pptx)。 +跑法:先 `write` 脚本到 `/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.` 命名空间下) **画布 / 配色入口** - `P.new_presentation(canvas="16:9")` → 建空 deck,设画布,回填 `P.SLIDE_W/H` 与安全区 -- `P.load(path)` → 载入已有 deck,按文件实际尺寸回填画布常量(逐页进程间自动同步) -- `P.add_slide(prs)` → 追加一张空白版式(layout 6)slide -- `P.set_palette(primary=, secondary=, accent=, cn_font=, en_font=, spec_path=)` → 覆盖主题色/字体;传 `spec_path` 自动从 spec.md 按文档顺序取前 3 个 #hex 作 主/辅/强调;**默认商务红,什么都不传无副作用** +- `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)` 压暗(自定义中间色用) -**放置 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_rect(slide, left, top, w, h, fill, name=...)` → 无边线实心矩形 -- `P.add_shape(slide, kind, left, top, w, h, fill, name=...)` → 任意 MSO_SHAPE(`kind` 用 `MSO_SHAPE.XXX`) -- `P.add_dot(slide, x, y, size=0.18, color=P.ACCENT)` → 圆点(bullet 前缀) -- `P.add_accent_line(slide, x, y, length=1.0, thickness=0.05, color=P.ACCENT)` → 强调短线 -- `P.add_badge(slide, x, y, num, diameter=0.7, fill=P.PRIMARY, fg=P.WHITE)` → 编号徽章(圆+数字) -- `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 已内置) +**容器 / 质感**(卡片式核心) +- `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_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, blur=0.10, dist=0.045, dir_deg=90, alpha=0.26)` → 给任意形状加柔和投影 +- `P.set_line(shape, color, weight=0.75)` → 描边(color=None 去边) +- `P.add_bg(slide, color=BG)` → 整页背景(`apply_brand` 已内置,一般不用手调) -> `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 -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, name="cover_title") -# 副标题 (灰色,弱化) -P.add_textbox(slide, 0.9, 4.1, 11.9, 0.6, "一句话副标题或定位", - 22, color=P.GREY, name="cover_sub") -# 汇报人 / 日期 -P.add_textbox(slide, 0.9, 6.4, 11.9, 0.4, - "汇报人 · 部门 · 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") +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 -slide = P.add_slide(prs) -P.apply_brand(slide, "inner") -P.page_title(slide, "目录") +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(slide, 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, - item, 22, color=P.INK, anchor=MSO_ANCHOR.MIDDLE, - name=f"agenda_{i}") + 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) —— 浅色背景 + 大字编号 +## L3 · 章节分隔 (Section Divider) —— 渐变整页 + 大字编号(白字) ```python from pptx.enum.text import MSO_ANCHOR -from pptx.enum.shapes import MSO_SHAPE -slide = P.add_slide(prs) -P.apply_brand(slide, "section") # 整页浅灰 + 主色左竖条 + 强调装饰 -# 大编号 (主色;font=EN_FONT 让数字走 Arial) -P.add_textbox(slide, 1.1, 2.0, 4, 2.5, "01", 160, bold=True, - color=P.PRIMARY, font=P.EN_FONT, name="sec_num") -# 章节名 -P.add_textbox(slide, 5.5, 2.8, 7, 1.0, "背景与现状", - 44, bold=True, color=P.INK, anchor=MSO_ANCHOR.MIDDLE, - name="sec_title") -# 引言 -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") +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) —— 圆点 + 文字,无大块色 +## L4 · 要点 (Bullets) —— 圆点 + 文字;≥3 条建议升级成卡片(见 L11) ```python from pptx.enum.text import MSO_ANCHOR -slide = P.add_slide(prs) -P.apply_brand(slide, "inner") -P.page_title(slide, "核心结论") +s = P.add_slide(prs) +P.apply_brand(s, "inner") +P.page_title(s, "核心结论") bullets = [ "结论一:用一句话讲清楚", @@ -152,141 +168,235 @@ bullets = [ ] for i, b in enumerate(bullets): y = 2.0 + i * 0.95 - P.add_dot(slide, 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, - b, 22, color=P.INK, anchor=MSO_ANCHOR.MIDDLE, - name=f"bullet_{i}") + 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) —— 中线分隔,小色块标签 +## L5 · 双栏对比 (Two-Column) —— 两张卡片,左中右灰 ```python from pptx.enum.text import PP_ALIGN, MSO_ANCHOR -slide = P.add_slide(prs) -P.apply_brand(slide, "inner") -P.page_title(slide, "现状 vs 改进后") +s = P.add_slide(prs) +P.apply_brand(s, "inner") +P.page_title(s, "现状 vs 改进后") -mid_x = P.SLIDE_W / 2 - -# 中间细分隔线 (替代两块大矩形) -P.add_rect(slide, mid_x - 0.02, 2.0, 0.04, 4.8, P.HAIRLINE, "divider") - -# 左栏小标签 (色块只占小区域) -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: 描述"] +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): - P.add_dot(slide, P.SAFE_LEFT + 0.05, 2.7 + i * 0.7 + 0.18, color=P.GREY) - P.add_textbox(slide, P.SAFE_LEFT + 0.45, 2.7 + i * 0.7, - mid_x - P.SAFE_LEFT - 0.7, 0.55, p, 18, color=P.INK, - anchor=MSO_ANCHOR.MIDDLE, name=f"l_pt_{i}") - -# 右栏小标签 -P.add_rect(slide, mid_x + 0.3, 2.0, 0.8, 0.35, P.PRIMARY, "right_tag") -P.add_textbox(slide, mid_x + 0.3, 2.0, 0.8, 0.35, "改进后", 14, bold=True, - color=P.WHITE, align=PP_ALIGN.CENTER, anchor=MSO_ANCHOR.MIDDLE, - shrink=False, name="right_label") -right_pts = ["改善 A: 描述", "改善 B: 描述", "改善 C: 描述"] + 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): - P.add_dot(slide, mid_x + 0.35, 2.7 + i * 0.7 + 0.18, color=P.ACCENT) - P.add_textbox(slide, mid_x + 0.75, 2.7 + i * 0.7, - P.SAFE_RIGHT - mid_x - 0.75, 0.55, p, 18, color=P.INK, + 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) —— 标题 + 一句结论 + 大图 +## L6 · 图表为主 (Chart-focus) —— 标题 + 一句结论 + 大图嵌卡片 ```python from pptx.util import Inches from pptx.enum.text import PP_ALIGN -# chart.png 已用 matplotlib 生成 (见 design_principles.md §7) -slide = P.add_slide(prs) -P.apply_brand(slide, "inner") -P.page_title(slide, "季度营收持续增长") -# 一句话结论 -P.add_textbox(slide, P.SAFE_LEFT, P.SAFE_TOP + 1.1, P.SAFE_W, 0.5, +# 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") -# 图表 (居中,占 8.9 寸宽,高度自适应 —— 只给 width 等比缩放) -slide.shapes.add_picture("/slides/chart.png", Inches(2.2), - Inches(2.4), width=Inches(8.9)) -# 数据来源 (右下角弱化) -P.add_textbox(slide, P.SAFE_LEFT, 6.95, P.SAFE_W, 0.4, - "数据来源: 公司年报 2025", 11, color=P.GREY_LIGHT, - align=PP_ALIGN.RIGHT, shrink=False, name="source") +# 图表衬一张白卡片(浮起,比裸图精致) +P.add_card(s, 2.0, 2.4, 9.3, 4.3, fill=P.SURFACE) +s.shapes.add_picture("/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) —— 文字在图旁,不压图 - -> 之前用满铺图 + 半透明遮罩,效果不稳定。改成"图占 60% + 文字独立区"。 +## L7 · 图片为主 (Image-focus) —— 图占 58%,文字独立区 ```python from pptx.util import Inches from pptx.enum.shapes import MSO_SHAPE -slide = P.add_slide(prs) -# 左侧图占 60% 宽 (只给 width 或 height 一项,避免变形;此处图需正好铺满左 8 寸高 7.5 寸时按素材比例取舍) -slide.shapes.add_picture("/slides/hero.jpg", Inches(0), Inches(0), - height=Inches(7.5)) -# 右侧浅灰背景区放文字 -P.add_rect(slide, 8, 0, 5.33, 7.5, P.BG, "text_panel") -P.add_rect(slide, 8.4, 1.0, 0.06, 0.8, P.ACCENT, "deco_bar") # 装饰短线 -P.add_textbox(slide, 8.4, 2.0, 4.6, 1.6, "走进未来", 36, - bold=True, color=P.INK, name="img_title") -P.add_textbox(slide, 8.4, 3.8, 4.6, 1.5, - "用一两句话点出主旨,不要把演讲稿搬上来。", +s = P.add_slide(prs) +P.add_bg(s, P.WHITE) +# 左侧图(只给 height 等比铺满,避免变形) +s.shapes.add_picture("/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(slide, MSO_SHAPE.RIGHT_ARROW, 8.4, 6.5, 0.7, 0.35, P.ACCENT, - "img_cta") +P.add_shape(s, MSO_SHAPE.RIGHT_ARROW, 8.1, 6.4, 0.7, 0.35, P.ACCENT, "img_cta") ``` --- -## L8 · 金句 / 大字 (Quote) —— 留白主导,装饰极简 +## L8 · 金句 / 大字 (Quote) —— 留白主导 ```python from pptx.enum.text import MSO_ANCHOR -slide = P.add_slide(prs) -P.apply_brand(slide, "inner") -# 左上大引号 (用字形;font=EN_FONT 走 Arial) -P.add_textbox(slide, 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(slide, 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(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") +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 都必须以这页收尾。无论是汇报、提案、路演,缺尾页等于"话没说完"。 +> **不是可选** —— 任何 deck 都必须以这页收尾。 ```python from pptx.enum.text import PP_ALIGN -slide = P.add_slide(prs) -P.apply_brand(slide, "end") # 整页浅灰 + 顶/底强调短线 -P.add_textbox(slide, 0, 2.5, P.SLIDE_W, 1.6, "Thank You", 80, bold=True, +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(slide, 0, 4.3, P.SLIDE_W, 0.6, "欢迎提问与讨论", - 22, color=P.ACCENT, align=PP_ALIGN.CENTER, name="qa") -P.add_textbox(slide, 0, 6.2, P.SLIDE_W, 0.5, - "联系方式 / 邮箱 / 公众号", 14, color=P.GREY_LIGHT, - align=PP_ALIGN.CENTER, name="contact") +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` 拉到 `/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) -对比类 (前/后, A/B) → L5 (Two-Column) -要点 ≤ 5 条 → L4 (Bullets) -转场 / 换章 → L3 (Section Divider) -首页 → L1 (Cover) -末页 → L9 (Q&A) -有大图 / 视觉优先 → L7 (Image-focus) -观点强调 / 名言 → L8 (Quote) +封面 → 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.7 in 高的框。`add_textbox` 内置 `assert_inside` + shrink-to-fit 兜底;但根本解法是**字数压缩**(见 design_principles.md §字数预算) -2. **标题占两行** —— 标题在 0.7 in 高的框里,32pt 单行高约 0.45 in,**两行就溢出**。中文标题 ≤ 30 字 -3. **图片不等比拉伸** —— `add_picture(width=, height=)` 同时给会变形;**只给 width 或 height 一项** +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` 看不清。 ``` diff --git a/skills/ppt/scripts/pptx_helpers.py b/skills/ppt/scripts/pptx_helpers.py index e087914..153c8a6 100644 --- a/skills/ppt/scripts/pptx_helpers.py +++ b/skills/ppt/scripts/pptx_helpers.py @@ -1,15 +1,13 @@ -"""pptx_helpers.py — PPT skill 的共享版式工具箱。 +"""pptx_helpers.py — PPT skill 的共享版式工具箱(卡片式视觉系统)。 -逐页生成时**每页一个 run_python**(载入已有 .pptx → append 一页 → save), -这些 helper 以前要在每页里重新默写一遍 —— 既烧 token 又会在长 deck 里漂移 -(第 7 页的 apply_brand 坐标和第 2 页写得不一样)。收进本模块后,每页只 import。 +整 deck 在一个 `build_deck.py` 里构建,每页一个小函数,这些 helper 统一在 +`P.` 命名空间下调用 —— 既省 token,又保证长 deck 里坐标/配色不漂移。 -用法(在 run_python block 顶部): +用法(在 build_deck.py 顶部): import sys; sys.path.insert(0, "/scripts") # 用 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="/...spec.md") # 默认商务红;spec 覆盖了才需要 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) prs.save("/.pptx") - # —— 后续页(追加)—— - prs = P.load("/.pptx") # 从文件实际尺寸回填画布常量 - P.set_palette(spec_path="/...spec.md") # 每页都重读 spec(同 SKILL.md 规则) - slide = P.add_slide(prs) - ... - prs.save("/.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 *` 会把旧绑定拷进页面命名空间,覆盖配色不生效。 @@ -33,7 +31,7 @@ import re from pathlib import Path 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.enum.text import PP_ALIGN, MSO_ANCHOR, MSO_AUTO_SIZE from pptx.enum.shapes import MSO_SHAPE @@ -54,6 +52,14 @@ HAIRLINE = RGBColor(0xDD, 0xDD, 0xDD) # 细分隔线 BG = RGBColor(0xFA, 0xFA, 0xFA) # 背景近白 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 = "微软雅黑" # 中文字形走 槽位 EN_FONT = "Arial" # 拉丁字形走 槽位 @@ -118,16 +124,44 @@ def add_slide(prs: Presentation): # ============================================================ -# 配色覆盖 (默认商务红;spec 写了别的色才调) +# 配色覆盖 (默认商务红;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.22) + 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: - """覆盖主题色 / 字体。逐页生成时每页都调一次(对齐 SKILL.md「每页重读 spec」)。 + """覆盖主题色 / 字体,并重算派生色阶。整 deck 设一次。 - 显式传 primary/secondary/accent(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 if 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) 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 @@ -229,6 +265,7 @@ 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() @@ -239,6 +276,7 @@ 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() @@ -269,13 +307,244 @@ def add_badge(slide, x, y, num, diameter=0.7, fill=None, fg=None): return c +# ============================================================ +# 视觉质感:投影 / 圆角 / 渐变 / 描边 +# ============================================================ +def set_shadow(shape, blur=0.10, dist=0.045, dir_deg=90, alpha=0.26, + color="000000") -> None: + """给形状加柔和外投影(写 )。 + + 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="项目汇报"): - add_textbox(slide, SAFE_LEFT, SAFE_TOP, SAFE_W, 0.7, text, +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, 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: add_textbox(slide, SAFE_LEFT, SLIDE_H - 0.5, 6, 0.4, 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"): """统一品牌锚点。每个版式第一行调用。 - cover —— 左侧主色长竖条 + 顶部短横 + 底部细灰线 - inner —— (默认) 左侧主色窄条 + 底部细灰线 - section —— 整页浅灰 + 左侧主色竖条 + 强调色粗竖条 - end —— 整页浅灰 + 顶/底强调色短线 + + cover —— 右侧主色→深主色渐变大块 + 左侧细强调短线(现代封面) + inner —— (默认) 近白底 + 左侧主色窄条 + 底部细灰线 + section —— 主色→深主色整页渐变 + 强调装饰条(章节大色块) + end —— 浅底 + 顶/底强调短线 """ btm = SLIDE_H - 0.32 if kind == "cover": - add_rect(slide, 0, 0, 0.18, SLIDE_H, PRIMARY, "brand_left_bar") - add_rect(slide, 0.7, 0.6, 0.8, 0.06, PRIMARY, "brand_top_line") - add_rect(slide, SAFE_LEFT, btm, SAFE_W, 0.02, HAIRLINE, - "brand_btm_hairline") + 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_rect(slide, 0, 0, SLIDE_W, SLIDE_H, BG, "brand_bg") - add_rect(slide, 0, 0, 0.18, SLIDE_H, PRIMARY, "brand_left_bar") - add_rect(slide, 0.7, SLIDE_H / 3, 0.08, SLIDE_H / 3, ACCENT, + 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_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_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") diff --git a/skills/ppt/scripts/quality_check.py b/skills/ppt/scripts/quality_check.py index c398ced..ca4c12f 100644 --- a/skills/ppt/scripts/quality_check.py +++ b/skills/ppt/scripts/quality_check.py @@ -24,6 +24,7 @@ from __future__ import annotations import argparse +import colorsys import re import sys 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: - """灰阶/黑/白判定:R/G/B 极差 ≤ 12 即视为中性色(三色制里不计入彩色)。""" +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: - 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): 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: @@ -132,8 +165,8 @@ def check_pptx(path: Path, spec: dict) -> tuple[list, list]: for idx, slide in enumerate(prs.slides, 1): title_text = None - bullet_count = 0 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): @@ -211,7 +244,11 @@ def check_pptx(path: Path, spec: dict) -> tuple[list, list]: break if first_size_pt: 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)) est_lines = 0 for para in tf.paragraphs: @@ -233,14 +270,26 @@ def check_pptx(path: Path, spec: dict) -> tuple[list, list]: 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 - if len(ptxt) > 1 and ptxt != title_text: - bullet_count += 1 for run in para.runs: - if run.font.size: + # 标签类(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: @@ -258,9 +307,15 @@ def check_pptx(path: Path, spec: dict) -> tuple[list, list]: 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( - f"第 {idx} 页 bullet {bullet_count} 条 (上限 5),建议拆页或转图表" + f"第 {idx} 页单列 bullet {max_col} 条 (上限 5),建议拆页或转图表" ) if small_font_count > 0: @@ -287,20 +342,23 @@ def check_pptx(path: Path, spec: dict) -> tuple[list, list]: f'{alab}("{ahead}") × {blab}("{bhead}")' ) - # 三色制按"非灰阶色"判定:灰/黑/白不计 (design_principles §2「其他全部用灰阶」) - chromatic = {c for c in seen_colors if not _is_neutral(c)} - if len(chromatic) > 3: + # 三色制按"色系数"判定:同色系深浅(主色/深红渐变/浅红卡片底)收敛成一桶, + # 低饱和浅色/灰阶不计。这样卡片式设计的派生色阶不会被误报超 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( - f"非灰阶色 {len(chromatic)} 种 (三色制上限 3): " - f"{', '.join('#' + c for c in sorted(chromatic))};收敛到主/辅/强调三色" + f"彩色色系 {len(families)} 个 (三色制上限 3): " + f"{', '.join('#' + c for c in sorted(chromatic))};收敛到主/辅/强调三色系" ) if spec_colors: - spec_chromatic = {c for c in spec_colors if not _is_neutral(c)} - extra = chromatic - spec_chromatic + 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 之外的色系 {', '.join('#' + c for c in sorted(extra))};" f"擅自换色 / 非主题色 (spec 定的是 " f"{', '.join('#' + c for c in sorted(spec_chromatic))})" )