diff --git a/PROGRESS.md b/PROGRESS.md index 695f55f..4c18cc4 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -21,6 +21,10 @@ ## 已完成关键能力 +### 2026-06-09 + +- **ppt skill 补「信息设计纪律」+ 混合背景 + pptx 预览器(治"效果还是不太行",深读 pptmaster 后的二次修正)**:用户反馈卡片式 v2 仍不够好,拆其真实产物(`大模型与智能体介绍.pptx`)定位毛病=9 页 4 页雷同卡片网格(全卡=AI 味)、发展历程做成网格(该时间轴)、智能体平铺(该闭环)、图标 0.6 寸太小、投影到处加。**深读 pptmaster 的 executor-base/executor-consultant(-top)/shared-standards 后顿悟**:它像麦肯锡的真因是**信息设计纪律(~70%)**而非 SVG 渲染(~30%),而这些**全是 editable python-pptx 能做的**——之前纠结的"可编辑 vs SVG 转换器"搞错了轴(可编辑都落 DrawingML 同一天花板,转换器零视觉增益)。落地三层:① **信息内功**——`add_takeaway`(论断标题下一句话结论框)、`add_kpi` 加 `baseline+delta`(数据语境化:数字带对比基准+升降色 `GOOD/BAD`)、`add_source`(来源)、`add_toc`(贯通整宽目录);SKILL 策略阶段加论断式标题对照表 + page_rhythm(anchor/dense/**breathing 强制打破卡片网格**)+ 内容→版式映射写进逐页大纲。② **修我搞反的投影**——pptmaster"投影是克制":`add_card` 默认 `shadow=False`(平铺对等卡描发丝边不投影)、每页 ≤2-3 投影、一容器一手段不叠;quality_check 加绿=语义状态色豁免三色制。③ **组合件 + 工具**——`add_card_grid`(均衡网格,2 行改图标左置治"图标顶置挤溢出")/`add_timeline`/`add_cycle`;`render_bg.py`(无头 Chrome 渲杂志级 mesh 渐变背景图,**混合方案**:背景图+原生可编辑白字,封面/章节);**`pptx_preview.py`(把 .pptx 渲成 PNG 肉眼验观感)——quality_check 只查结构,预览补"好不好看",当场抓到 `set_text` 多行只给第一段上色的真 bug(封面副标题第二行变暗看不见)并修复**。验证:重排「大模型与智能体」为 10 页(节奏:封面/目录/章节 anchor · 网格/时间轴 dense · 大字 breathing · 章节/闭环/网格 · 致谢),逐页渲 PNG 亲眼验收均专业,quality_check 全过。改 `skills/ppt/{SKILL.md,references/{design_principles,layouts}.md,scripts/{pptx_helpers,quality_check}.py}` + 新增 `scripts/{render_bg,pptx_preview}.py` + `SKILL_LIST.md`。**未动**:SVG→原生转换器(论证为零增益不做)、live preview server、动画;fetch_icon 的 PNG 后端(cairosvg/svglib)本机未装,暂用种子库 PNG。 + ### 2026-06-08 - **loop 加病理性重复调用守卫(药1,治「不停调用同一脚本」的根因 ①②)**:接续批量化诊断——DB 实测高轮数 task 的浪费大头是「同名同参 + 无产出」的重复(`document_search` 122 次、空 `shell{}` 51 次、反复 `glob` 同一不存在路径),而 `core/loop.py` 主循环原本对此**零防护**照单全收。新增 `_RepeatGuard`(AgentLoop 实例持有、活在单次 run 内不跨 task):按 `(工具名, 精确参数 canonical-json)` 指纹跟踪「无产出重复」计数。**命门是只惩罚无产出、绝不误伤正常迭代**——同参但**结果每次不同**(改脚本后重跑 run_python、修 bug 后重跑构建)算有产出、计数清零永不拦;同参且**结果是 `[Error]` 或与之前一字不差**才累计。两档:累计 ≥`SOFT`(2)在 tool 结果尾部注入 `[重复调用警告]` 软提示(模型当轮即见);≥`HARD`(4)下一次同参调用 `should_block` 直接拦截不执行、回 `[已拦截重复调用]` 硬停消息逼其换路(一个卡死调用最多放过 ~4 次无产出重复)。**顺带堵 `_malformed_tool_calls` 的洞**:大参数畸形退化成合法空 `{}` 时 executor 每次返同一句「缺少必填参数」→ 走 dup 分支被同一机制拦下,无需单独特判空 `{}`。`_execute_tool_call` 接线:执行前 `should_block` 拦截、执行后用**截断后未加提示的原始结果**算指纹 `record`(保证同输出哈希一致)、`warn` 事件上抛拦截/首次软提示。改 `core/loop.py`;新增 `tests/test_loop_repeat_guard.py`(7 用例:同错拦截/空`{}`堵洞/同结果拦截/变化结果不拦/修好清零/SOFT 阈值/异参分别跟踪,全过)。**注**:阈值常数化(SOFT/HARD)便于后续按实跑调;药3(`/home/ubuntu/zcbot` 幽灵路径是否新任务仍复现)仍未查。 diff --git a/SKILL_LIST.md b/SKILL_LIST.md index acbbcc7..bbf1854 100644 --- a/SKILL_LIST.md +++ b/SKILL_LIST.md @@ -161,13 +161,13 @@ zcbot 的"skill"是一份可加载的工作流脚本(`skills//SKILL.md` + | 8 | 图表 / 配图 | 数据图 matplotlib / 少量数字上 KPI 卡;真实配图 opt-in 走 imagegen(每张 ¥0.22) | **核心能力**: -- **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` 验收(越界 / 文本溢出 / 按列 bullet ≤5 / 按色系三色制 / 内容重叠) -- 素材摄取走 markitdown 把 PDF/DOCX/PPTX/XLSX/HTML/URL 统一转 Markdown +- **信息设计纪律(咨询级的真功)**:论断式标题(写结论不写主题)、Takeaway 结论框、数据语境化(数字带对比基准+趋势)、page_rhythm 节奏(anchor/dense/breathing,breathing 页强制打破卡片网格) +- **组合版式件**(一函数一整块):`add_card_grid`(均衡网格)/ `add_timeline`(时间轴)/ `add_cycle`(闭环)/ `add_toc`(目录)/ `add_kpi`(数字卡带对比+升降)/ `add_takeaway` / `add_source` +- **质感工具箱**:`add_card`(圆角卡,投影克制——平铺卡默认平)/ `add_gradient_rect` / `add_icon_tile` / `add_pill` / 派生明暗色阶 + 语义色 `GOOD/BAD` +- **混合背景** `render_bg.py`:无头 Chrome 渲杂志级背景图 + 其上原生可编辑文字(封面/章节) +- **观感验收** `pptx_preview.py`:把 .pptx 渲成 PNG 肉眼验版面(quality_check 查结构,预览查好看) +- 演讲者备注 `add_notes` + 业务图标双层兜底(Iconify → 本地缓存 → unicode) +- `quality_check.py` 结构验收(越界 / 溢出 / 按列 bullet / 按色系三色制 / 重叠)+ markitdown 素材摄取 **典型产物**:`.pptx` + `build_deck.py`(整 deck 构建脚本,改稿/修验收项都改它重跑)。 diff --git a/skills/ppt/SKILL.md b/skills/ppt/SKILL.md index 1ec3920..b5d37ad 100644 --- a/skills/ppt/SKILL.md +++ b/skills/ppt/SKILL.md @@ -10,15 +10,17 @@ description: 生成 PowerPoint 演示文稿 (.pptx) 文件。✅ 触发:用户 进度展示建议:多页 deck 任务用 `task_progress` 标记「摄取素材 / 八条对齐 + 逐页大纲 / 图标预取 / 脚本建 deck / 质量检查 / 交付」等关键阶段;不要把每一页的内部写入都作为进度步骤。 ## 资源 -- `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` 品牌条 +- `scripts/pptx_helpers.py` —— **卡片式视觉工具箱模块**:配色/字体常量 + 派生明暗色阶(`PRIMARY_WASH/SOFT/DARK`)+ 语义色 `GOOD/BAD` + `new_presentation`/`set_palette` + **组合版式件**(一个函数摆一整块):`add_card_grid`(均衡网格)/`add_timeline`(时间轴)/`add_cycle`(流程闭环)/`add_toc`(目录)/`add_kpi`(数字卡,带 baseline+delta)/`add_takeaway`(结论框)/`add_source`(数据来源)+ 质感件 `add_card`(圆角卡,**默认平卡**)/`add_gradient_rect`/`add_icon_tile`/`add_pill`/`add_eyebrow`/`add_picture_bg`(混合背景)+ `add_notes`(演讲者备注)+ 基础件 `add_textbox`/`page_title`/`apply_brand`。`import pptx_helpers as P` 调用,**不默写源码**。⚠️ helper 的 `name=` 会写进形状名,quality_check 靠它判标签/bullet +- `references/design_principles.md` —— **§信息设计纪律(论断标题/Takeaway/数据语境化/page_rhythm)** + 画布/字号/配色/投影克制/字数预算等硬规则。**先读这节** +- `references/layouts.md` —— 13+ 版式与组合件调用示例 + helper API 速查 + 安全区保护 - `references/icons.md` —— 业务图标两层:Iconify (在线/本地缓存) / unicode 字形兜底 -- `assets/icons/` —— **只读**种子图标库 (skill 自带的商务红 tabler 集,见 `INDEX.md`;docker 沙盒里 skills 是只读挂载。新拉的图标写 `/assets/icons/`) -- 素材摄取: 用 `markitdown` CLI 把 PDF/DOCX/PPTX/XLSX/HTML/URL 转干净 Markdown,统一落到 `/source/.md`(同 working_dir 多 task 共享 source 池) -- `scripts/fetch_icon.py` —— 从 Iconify CDN 拉 SVG/PNG (染主题色,缓存到 `/assets/icons/`) +- `assets/icons/` —— **只读**种子图标库 (商务红 tabler 集,见 `INDEX.md`;新拉的图标写 `/assets/icons/`) +- 素材摄取: 用 `markitdown` CLI 把 PDF/DOCX/PPTX/XLSX/HTML/URL 转干净 Markdown,落到 `/source/.md` +- `scripts/fetch_icon.py` —— 从 Iconify CDN 拉 SVG/PNG (染主题色;**PNG 转换需 cairosvg/svglib,没装会只出 SVG** —— 优先用种子库现成 PNG) - `scripts/render_icon.py` —— unicode 字形 → 透明 PNG (Iconify 没有时兜底) -- `scripts/quality_check.py` —— 产物 .pptx 验收 (越界 / 文本溢出 / 颜色一致) +- `scripts/render_bg.py` —— 无头 Chrome 把主题化 HTML 渲成**杂志级背景 PNG**(混合方案:封面/章节背景图 + 其上原生可编辑文字) +- `scripts/pptx_preview.py` —— **把 .pptx 渲成 PNG 预览**(无头 Chrome),交付前**肉眼验收版面**(quality_check 查结构,预览查观感;能抓到多行不上色这类渲染 bug) +- `scripts/quality_check.py` —— 产物 .pptx 结构验收 (越界 / 文本溢出 / 按列 bullet / 按色系三色制 / 重叠) ## 默认主题 — 商务红 (硬约束) @@ -71,19 +73,26 @@ glob /*--*.spec.md → 按文件名字典序排,取最 把这 8 项写进上面那个 task 级 spec 文件,以表格形式给用户预览,问一句"按这个开干?"。**spec 写定后不再改**(要改就走 §0 的「重定调」分支,以 today 为前缀写新版,旧版保留)。 -**8 项之外,spec 还要含一张「逐页大纲」表** —— 这是阶段二一个脚本建整 deck 的输入,也是替代"逐页确认"的前置 checkpoint(改一行文字大纲,比建完一页 slide 再推翻便宜得多): +**8 项之外,spec 还要含一张「逐页大纲」表** —— 阶段二一个脚本建整 deck 的输入,也是替代"逐页确认"的前置 checkpoint。**标题写论断、每页标节奏**(见 design_principles §信息设计纪律): -| 页 | 版式 | 标题 | 核心信息 / 要点(≤5) | 图标 / 图表 / 配图 | -|---|---|---|---|---| -| 1 | L1 封面 | <主标题> | <副标题 / 定位> | 可选 `[img]` 主图 | -| 2 | L11 卡片网格 | <标题> | <要点 1 / 2 / 3> | `target` / `cpu` / `chart-bar` | -| 3 | L10 KPI 卡 | <标题> | <数字 1 / 2 / 3 / 4> | — | -| … | … | … | … | … | -| N | L9 尾页 | Q&A / 致谢 | <联系方式> | — | +| 页 | 节奏 | 版式 | **论断式标题** | 核心信息 / Takeaway | 图标 / 图表 / 配图 | +|---|---|---|---|---|---| +| 1 | anchor | L1 封面 | <主标题> | <副标题 / 定位> | 可选 `[img]` 主图 | +| 2 | anchor | 目录 | 目录 | <5 章 + 各一句副标> | — | +| 3 | dense | 卡片网格 | "大模型靠规模涌现出通用智能" | <3-5 概念 + 一句 takeaway> | `brain`/`cpu`/… | +| 4 | dense | 时间轴 | "六年能力指数跃迁" | <里程碑 + takeaway + 来源> | — | +| 5 | **breathing** | 大字页 | "2 个月,月活破亿" | <单个大数字 + 一句语境对比> | — | +| … | … | … | … | … | … | +| N | anchor | 尾页 | 致谢 / Q&A | <联系方式> | — | -> 版式从 layouts.md 的 L1-L13 里选(见 §选版式速查):**业务概念优先 L11 卡片网格(图标底块,别只摆圆点)**,**2-4 个关键数字优先 L10 KPI 卡(别硬画柱图)**,**单个震撼数字用 L13**。要真实配图的页在「图标/图表/配图」列标 `[img]` + 一句画面描述。 +> **三条硬纪律(大纲阶段就定死)**: +> - **论断标题**:标题列写"结论"不写"主题"("渗透率破 60%" 不是 "行业背景"); +> - **节奏不雷同**:相邻内容页不同版式;**每隔 2-3 页插一个 `breathing` 页**(大字/金句/整图,禁卡片网格)打破"全卡 = AI 味";**卡片网格全 deck ≤2 次**; +> - **内容→版式映射**:历程→时间轴、循环→闭环、2-4 数字→KPI 卡(带对比基准)、并列概念→均衡网格、单震撼数字→breathing 大字。 +> +> 内容页正文优先压成一句 **Takeaway 结论**;含数据的页要有**对比基准 + 来源**。版式见 layouts.md §选版式速查。配图页标 `[img]` + 一句画面。 -大纲连同 8 项一起给用户预览,**BLOCKING 等用户确认整份结构**(页数、每页讲什么、用什么版式/图标)后再进阶段二。用户在这一步推翻方向 = 改表格文字,零 slide 返工。 +大纲连同 8 项一起给用户预览,**BLOCKING 等用户确认整份结构**(页数、每页讲什么、节奏、版式)后再进阶段二。用户在这一步推翻方向 = 改表格文字,零 slide 返工。 ### 阶段二: 执行 (Executor) — 一个脚本建整 deck @@ -93,22 +102,36 @@ 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. **真实配图(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` —— 见 §修订日志。 +4. **混合背景(opt-in)**:封面/章节想要杂志级背景时,`run_python` 调 `render_bg.py --out /figures/cover_bg.png --kind cover --primary <主色>`(+ section),build_deck 里 `P.add_picture_bg(slide, bg)` 铺底再叠**白色**文字。**背景图不可编辑、文字可编辑**——这是 editable 前提下的最高观感。 +5. **写 `build_deck.py` 到 ``,一次建整 deck**: 顶部 `import pptx_helpers as P` → `P.new_presentation` → `P.set_palette(spec_path=...)` → **按大纲循环每页**(每页一个小函数)→ 末尾 `prs.save`。落实**信息内功**(见 design_principles §信息设计纪律): + - **论断式标题**(写结论)+ 内容页 `P.add_takeaway(slide, "<一句话结论>")`; + - 含数据用 `P.add_kpi(..., baseline=, delta=)` + `P.add_source`;**数字别孤立**; + - **节奏**:按大纲的 anchor/dense/breathing 落版式,breathing 页走大字/金句/整图(**禁卡片网格**); + - **投影克制**:平铺网格卡用 `add_card`(默认平卡),投影只给悬浮/被挑出的卡,每页 ≤2-3 个; + - 每页 `P.add_notes` 写 2-4 句**结论先行的口语**演讲稿。 + helper 一律 `P.xxx` 不默写源码;版式见 layouts.md。先 `write` 脚本再 `run_python(script_path=...)`。 +6. **quality_check + 预览双验收**(见阶段三)→ 按报告**改 `build_deck.py` 重跑**(不逐页 edit 成品)。 +7. 报整份 deck:页数、各页版式/节奏、用到的图标/配图;问用户要不要改。 +8. 用户确认了**实质改动**后,追加一行到 `/REVISIONS.md` —— 见 §修订日志。 **风格探针(可选,降视觉返工险)**: 用户对观感没底、或这是全新风格时,可先只建**封面 + 1 内页**给用户看一眼,确认后把 `build_deck.py` 的页范围放开重跑补齐其余页 —— 仍是改一个脚本,不退回逐页。用户要快("直接全做")就跳过探针,整 deck 一把出。 **为什么不再逐页?** 逐页的两个理由都已消解:① 防坐标漂移 → `pptx_helpers` 模块化已解决;② 早发现方向问题 → 前移到阶段一「逐页大纲」确认(改文字比改 slide 便宜),视觉观感由可选探针 + 整 deck 后批改兜底。代价是放弃"逐页即时纠错",换来 N 页从 ~2N 轮降到 ~3-4 轮。 -### 阶段三: 验收 +### 阶段三: 验收 (结构 + 观感 双验) +**① 结构验收** `quality_check.py`(越界/溢出/三色/重叠): ```bash python /scripts/quality_check.py / --spec /--.spec.md ``` -不通过的项,**改 `build_deck.py` 重跑**(改源脚本可复现;不要直接 edit 成品 .pptx)。 +**② 观感验收** `pptx_preview.py`(渲成 PNG **肉眼看版面**)—— quality_check 查不出"好不好看 / 文字层级 / 留白 / 多行文本掉色"这类问题,**交付前必须渲几页关键页用 `read` 亲眼过**: +```bash +python /scripts/pptx_preview.py / -o /preview --pages 1,3,5 +``` +看封面、一个内容页、breathing 页是否如预期(标题层级、卡片是否过挤/过空、文字是否都正常上色、节奏是否单调)。 + +两项不通过的,**改 `build_deck.py` 重跑**(改源脚本可复现;不要直接 edit 成品 .pptx)。 ## 设计原则 (硬规则速查) - **每页一个核心信息**: 一页讲一件事,塞两件就拆页 diff --git a/skills/ppt/references/design_principles.md b/skills/ppt/references/design_principles.md index 0f54fdb..177ed4b 100644 --- a/skills/ppt/references/design_principles.md +++ b/skills/ppt/references/design_principles.md @@ -2,6 +2,43 @@ > 出稿前过一遍。**这些不是建议,是工程约束** —— 模型生成 PPT 最常见的失败模式都是违反这些规则。 +## 信息设计纪律 (比视觉更重要 —— 先把这条吃透) + +> "好看"七成靠**信息设计**、三成靠视觉。同样的红色卡片,标题写"行业背景"还是"渗透率破 60%,行业进入深水区",观感差一个档次。模型最容易堆视觉、忘内功 —— 这一节是把 deck 从"AI 味模板"拉到"咨询级"的关键。 + +### 1. 论断式标题 (Assertion title) —— 标题写结论,不写主题 + +每页标题是**一句可带走的结论**,不是话题名。 + +| 类型 | ❌ 主题式(避免) | ✅ 论断式(推荐) | +|---|---|---| +| 背景 | "行业背景" | "数字渗透率破 60%,行业进入深水区" | +| 现状 | "什么是大模型" | "大模型靠规模涌现出通用智能" | +| 历程 | "发展历程" | "六年从 GPT-1 到推理模型,能力指数跃迁" | +| 竞争 | "竞品分析" | "三家主要对手在渠道覆盖上明显薄弱" | + +### 2. Takeaway 结论框 —— 每页标题下一句话结论 + +内容页标题下加 `P.add_takeaway(slide, "<一句话结论>")`(浅主色底 + 左主色条)。把"这页要讲什么"压成一句。**金字塔原则**:结论先行,再展开 3 条论据。 + +### 3. 数据语境化 —— 数字不要孤立出现 + +每个关键数字配三件:**数值本身(大)+ 对比基准(行业均值/上期/竞品)+ 含义("所以呢")**。 +用 `P.add_kpi(..., baseline="行业均值 82%", delta="+11pt")`(升=绿/降=红,业界约定);含数据的页用 `P.add_source(slide, "<来源>")` 标来源。 +> 例:"97.3%" 下面跟 "行业均值 82% | 领先 15 个点",而不是光一个 "97.3%"。 + +### 4. page_rhythm 节奏 —— 相邻页不许同版式 + +逐页大纲给每页标密度,**breathing 页强制打破卡片网格**(否则每页都退化成卡片网格 = AI 味): + +| 标签 | 版式纪律 | +|---|---| +| `anchor` | 结构页(封面/章节/目录/尾页),走固定品牌版式 | +| `dense` | 信息密集(默认):卡片网格 / KPI / 图表 / 时间轴 / 表格都行 | +| `breathing` | 低密度冲击页:**禁止多卡网格**,用大字 + 留白 + 整图 + 金句。典型:单个大数字 + 一句语境、整图 + 浮层标题、金句 | + +内容→版式映射:历程→时间轴(`add_timeline`)、循环→闭环/流程(`add_cycle`)、2-4 数字→KPI 卡(`add_kpi`)、并列概念→均衡网格(`add_card_grid`,全 deck ≤2 次)、单个震撼数字→breathing 大字页。 + ## 0. 画布 (默认 16:9) | 用途 | 比例 | 宽×高 (英寸) | python-pptx | @@ -55,12 +92,26 @@ - `ACCENT_SOFT`(强调兑 78% 白)—— 渐变深底上的弱化文字 > 白底之上靠卡片(`add_card` 圆角+投影)+ 浅色阶分层,才有"现代咨询风"的层次;纯白底裸贴元素 = 扁平办公模板。 +### 语义状态色 (例外) +趋势/状态用业界约定:**绿 `P.GOOD` = 增长/正向,红 `P.BAD` = 下降/风险,灰 = 持平**。这套语义色**不计入三色制**(quality_check 把绿色当语义色豁免)。只用在 KPI 趋势、表格升降这类语义场景,别拿来当装饰。 + ### 禁忌 -- 红配绿、紫配黄等高对比互补色不要直接用 +- 红配绿、紫配黄等高对比互补色不要直接用(语义升降色除外) - **渐变只用在大色块**(封面右块 / 章节整页,`apply_brand` 已内置);正文/标题/小图形不要渐变 - 一份 deck 主色不要换。封面是 A 色、内页变 B 色 —— 这是大忌 - 渐变深底上文字一律用**白 / `ACCENT_SOFT`**,别用深灰 `INK`(看不清) +## 视觉深度:投影是克制,不是默认 + +> 抄自 pptmaster shared-standards §6 —— "设计感来自'没有',不是'到处都有'"。模型最爱给每张卡都加投影,这恰恰是模板味的来源。 + +- **平卡是常态**:`add_card` 默认平卡(白底描发丝边)。**平铺网格里的对等卡一律平**,不投影。 +- **投影只给真悬浮的**:照片/色块上的卡、被挑出的"推荐"项、浮层/标注。`add_card(..., shadow=True)` 手动开。 +- **每页 ≤2-3 个投影元素**。够第 4 个了,先撤一个。 +- **一个容器只用一种视觉手段**:投影 / 描边 / 渐变底 / 强主色底 —— **四选一,不叠加**(叠加 = 瞬间模板味)。 +- **单一光源**:同页所有投影同方向(默认光从上方,`dy>0`)。 +- 渐变深底上投影会消失,改用 1px 低透明白描边或外发光。 + ## 3. 留白 - 标题与上边距 ≥ 0.4 英寸 diff --git a/skills/ppt/references/layouts.md b/skills/ppt/references/layouts.md index ef99ab7..3351965 100644 --- a/skills/ppt/references/layouts.md +++ b/skills/ppt/references/layouts.md @@ -60,16 +60,25 @@ main() **画布常量**:`P.SLIDE_W` `P.SLIDE_H` `P.SAFE_LEFT/TOP/RIGHT/BOTTOM` `P.SAFE_W` `P.SAFE_H` **色阶工具**:`P.tint(color, pct)` 提亮 / `P.shade(color, pct)` 压暗(自定义中间色用) +**🔥 组合版式件**(一个函数摆一整块 —— 优先用这些,别手摆参差网格/拿卡片硬凑时间线) +- `P.add_card_grid(slide, items, top, height, cols=None, icon_dir=None, accent=None)` → **均衡概念网格**;items=每项 `{icon,title,body}`;自动均衡行列(2×2/2×3,不参差),单行图标顶置、多行图标左置;`icon_dir` 给图标目录(图标名去 `tabler_` 前缀) +- `P.add_timeline(slide, nodes, y=3.2)` → **横向时间轴**;nodes=`{year,title,body}`;发展历程/路线图用,别塞卡片网格 +- `P.add_cycle(slide, steps, cy=4.5, radius=1.55, center_label=)` → **流程闭环**(节点沿环+中心词);循环类用。⚠️文字多时改用横向流程(L12)更稳 +- `P.add_toc(slide, items, top=2.2)` → **目录**(序号+标题+右副标+发丝线,贯通整宽);items=`(title, caption)` +- `P.add_kpi(slide, l, t, w, h, value, label, baseline=, delta=, delta_dir=)` → **KPI 数字卡**;`baseline`=对比基准、`delta`=趋势(升绿降红);**数字别孤立** +- `P.add_takeaway(slide, "<一句话结论>", top=None)` → **结论框**(浅主色底+左条);内容页论断标题下标配 +- `P.add_source(slide, "<来源>")` → 数据来源(右下角弱化);含数据的页必标 +- `P.add_picture_bg(slide, png)` → 整页铺渲染好的高清背景图(混合方案:背景图+原生可编辑文字) + **容器 / 质感**(卡片式核心) -- `P.add_card(slide, l, t, w, h, fill=SURFACE, radius=0.12, shadow=True, border=False, accent=None)` → 圆角卡片(投影/边线/左强调条可选);内容再叠其上,内边距约 0.3-0.4 +- `P.add_card(slide, l, t, w, h, fill=SURFACE, radius=0.12, shadow=False, border=None, accent=None)` → 圆角卡片。**默认平卡**(白底描发丝边);**投影是克制**:平铺对等卡一律平,`shadow=True` 只给真悬浮/被挑出的卡,每页 ≤2-3 个;**一容器一手段**(投影/描边/底色/accent 四选一不叠)。见 design_principles §视觉深度 - `P.add_round_rect(slide, l, t, w, h, fill, radius=0.10)` → 无投影圆角矩形 - `P.add_gradient_rect(slide, l, t, w, h, c1, c2, angle=90, rounded=False)` → 渐变块(封面/章节大色块;原生可编辑非图片) -- `P.set_shadow(shape, 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` 已内置,一般不用手调) +- `P.set_shadow(shape, ...)` / `P.set_line(shape, color, weight)` → 手动投影 / 描边 +- `P.add_bg(slide, color=BG)` → 整页背景(`apply_brand` 已内置) +- 语义色:`P.GOOD`(增长绿)/ `P.BAD`(下降红)—— KPI 趋势用,不计三色制 **组件** -- `P.add_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 @@ -87,6 +96,101 @@ main() - `P.add_notes(slide, text)` → 演讲者备注(正式产物每页给 2-4 句口述要点) - `P.assert_inside(l, t, w, h, name="")` → 手动越界校验(放置 helper 已内置) +--- + +## 🔥 组合件示例 (优先用 —— 一个函数一整块) + +### 内容页范式:论断标题 + Takeaway + 均衡网格 + +> 内容页的"黄金结构"(咨询级):**论断式标题**(写结论)→ **Takeaway 一句话**(浅底框)→ 内容。把它做成本地小函数 `content_header`。 + +```python +from pptx.enum.text import MSO_ANCHOR +def content_header(s, title, takeaway, eyebrow=None): + ty = P.SAFE_TOP + if eyebrow: + P.add_eyebrow(s, P.SAFE_LEFT, ty, eyebrow); ty += 0.4 + P.add_textbox(s, P.SAFE_LEFT, ty, P.SAFE_W, 0.7, title, 28, bold=True, + color=P.PRIMARY, name="title") # 论断标题 + if takeaway: + P.add_takeaway(s, takeaway, top=ty + 0.82) # 结论框 + +s = P.add_slide(prs); P.apply_brand(s, "inner") +content_header(s, "大模型靠规模涌现出通用智能", + "参数突破千亿临界点后,模型从'专用工具'跃升为'通用大脑'", + eyebrow="DEFINITION") +items = [ # 每项 icon 名 + 标题 + 精炼正文(≤18 字) + {"icon": "brain", "title": "超大参数", "body": "千亿参数突破临界点,涌现推理力"}, + {"icon": "cpu", "title": "对话生成", "body": "多轮对话、写代码、摘要改写"}, + {"icon": "cloud-network", "title": "多模态", "body": "文本+图像+音频+视频统一理解"}, + {"icon": "target", "title": "任务规划", "body": "高级推理与链式拆解"}, + {"icon": "bolt", "title": "持续成长", "body": "RLHF、RAG、微调持续打磨"}, +] +P.add_card_grid(s, items, top=2.35, height=4.5, icon_dir=ICONS) # 平卡,自动均衡 +``` + +### 时间轴(发展历程 / 路线图) + +```python +content_header(s, "六年从 GPT-1 到推理模型,能力指数跃迁", + "每一代都在重定义能力边界", eyebrow="TIMELINE") +P.add_timeline(s, [ + {"year": "2018", "title": "GPT-1", "body": "预训练范式确立"}, + {"year": "2020", "title": "GPT-3", "body": "1750 亿参数,few-shot 涌现"}, + {"year": "2022", "title": "ChatGPT", "body": "对话式 AI 引爆全民应用"}, + {"year": "2023", "title": "GPT-4", "body": "多模态 + 强推理"}, +], y=3.9) +P.add_source(s, "OpenAI / 各厂商公开发布") +``` + +### KPI 数字卡(数据语境化:对比基准 + 升降) + +```python +data = [("158%", "实验吞吐同比", "行业均值 90%", "+68pt", "up"), + ("27天", "配方迭代周期", "去年 45 天", "-40%", "up"), + ("92.3%", "中试一次通过率", "行业 81%", "+11pt", "up")] +n, gap = len(data), 0.3; cw = (P.SAFE_W - gap*(n-1))/n +for i,(v,lab,base,delta,d) in enumerate(data): + P.add_kpi(s, P.SAFE_LEFT+i*(cw+gap), 2.6, cw, 2.7, v, lab, + baseline=base, delta=delta, delta_dir=d) +``` + +### breathing 大字页(打破卡片单调 —— 每隔 2-3 页插一个) + +```python +s = P.add_slide(prs); P.apply_brand(s, "inner") +P.add_eyebrow(s, P.SAFE_LEFT, 1.5, "THE INFLECTION POINT") +P.add_textbox(s, P.SAFE_LEFT, 2.15, 9.0, 2.5, "2 个月", 150, bold=True, + color=P.PRIMARY, font=P.EN_FONT, shrink=False, name="big_stat") +P.add_textbox(s, P.SAFE_LEFT, 4.7, 11, 0.7, "ChatGPT 月活突破 1 亿", 30, + bold=True, color=P.INK, name="big_label") +P.add_textbox(s, P.SAFE_LEFT, 5.6, 11, 0.6, + "史上最快 —— 此前纪录是 TikTok 的 9 个月", 18, color=P.GREY, + name="big_ctx") # 数据语境化:大数字必带对比 +``` + +### 目录(贯通整宽) + +```python +P.page_title(s, "目录", eyebrow="AGENDA") +P.add_toc(s, [("什么是大模型", "规模、能力与边界"), + ("发展历程", "六年能力跃迁"), + ("AI 智能体", "从对话到自主行动")], top=2.25) +``` + +### 混合背景封面(杂志级,opt-in) + +```python +# 先 run_python: python render_bg.py --out /figures/cover_bg.png --kind cover --primary C00000 +s = P.add_slide(prs) +P.add_picture_bg(s, "/figures/cover_bg.png") # 背景图(不可编辑) +P.add_eyebrow(s, 0.95, 1.95, "TECHNOLOGY INSIGHT · 2026", color=P.ACCENT) +P.add_textbox(s, 0.95, 2.45, 8.0, 1.7, "主标题\n副标题行", 44, bold=True, + color=P.WHITE, name="cover_title") # 白字叠背景(可编辑) +``` + +> 下面 L1-L13 是更细的手摆版式参考;**业务概念/数据/历程/循环优先用上面的组合件**,手摆只在组合件不覆盖时用。 + > ⚠️ **给每个元素起语义 `name`**(`"bullet_1"`/`"kpi_val"`/`"eyebrow"`/`"pill"` 等)。quality_check 靠 name 判定"哪些是标签(小字号豁免)、哪些是真 bullet(计 ≤5)、谁压了谁",名字乱起会误报。helper 默认名已合理,自己加文本时照着命名。 > `MSO_SHAPE` / `PP_ALIGN` / `MSO_ANCHOR` 页面里要直接用就自行 import(`pptx_helpers` 内部已 import 但不重导出)。 diff --git a/skills/ppt/scripts/pptx_helpers.py b/skills/ppt/scripts/pptx_helpers.py index 153c8a6..e0e79fa 100644 --- a/skills/ppt/scripts/pptx_helpers.py +++ b/skills/ppt/scripts/pptx_helpers.py @@ -51,6 +51,10 @@ GREY_LIGHT = RGBColor(0x88, 0x88, 0x88) HAIRLINE = RGBColor(0xDD, 0xDD, 0xDD) # 细分隔线 BG = RGBColor(0xFA, 0xFA, 0xFA) # 背景近白 WHITE = RGBColor(0xFF, 0xFF, 0xFF) +# 语义状态色(升/降)—— 数据趋势标注用,绿=正/红=负,业界通用约定。 +# 不计入"商务红三色制"(quality_check 把绿色当语义状态色豁免)。 +GOOD = RGBColor(0x1E, 0x9E, 0x62) # 增长 / 正向 +BAD = RGBColor(0xD1, 0x34, 0x38) # 下降 / 风险 # —— 从主/辅/强调派生的明暗色阶 (set_palette 里按当前三色重算) —— # 卡片底色 / 章节渐变 / 标签底 都从这套阶取,避免"白底 + 纯红"两极、缺中间层次。 @@ -154,7 +158,7 @@ def _recompute_ramp() -> None: 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) + PRIMARY_DARK = shade(PRIMARY, 0.42) # 加深:渐变要肉眼看得出深浅,别两端几乎同色 ACCENT_SOFT = tint(ACCENT, 0.78) @@ -230,14 +234,16 @@ def _apply_run_font(run, size, bold, color, latin_font, ea_font) -> None: def set_text(tf, text, size, bold=False, color=INK, align=PP_ALIGN.LEFT, font=None) -> None: - """写单段文本并设样式。font=None → 拉丁 EN_FONT + 东亚 CN_FONT(推荐); - 传 font 则 latin 与 ea 都用它(纯英文大字 / 纯数字时用)。""" + """写文本并设样式。**多行(含 \\n)时每一段都上色** —— 否则 `\\n` 产生的 + 第 2 段会继承主题默认色(踩过:封面副标题第二行变暗色看不见)。 + font=None → 拉丁 EN_FONT + 东亚 CN_FONT;传 font 则两槽都用它(纯英文大字/数字)。""" latin = font or EN_FONT ea = font or CN_FONT tf.text = text - p = tf.paragraphs[0] - p.alignment = align - _apply_run_font(p.runs[0], size, bold, color, latin, ea) + for p in tf.paragraphs: + p.alignment = align + for r in p.runs: + _apply_run_font(r, size, bold, color, latin, ea) def add_textbox(slide, left, top, width, height, text, size, @@ -406,22 +412,27 @@ def add_bg(slide, color=None): # 卡片 (内容页主力容器) + 组件 # ============================================================ def add_card(slide, left, top, width, height, fill=None, radius=0.12, - shadow=True, border=False, accent=None, accent_w=0.07, + shadow=False, border=None, accent=None, accent_w=0.07, name="card"): - """圆角卡片:白面 + 柔和投影 + 可选发丝边 + 可选左侧强调竖条。 + """圆角卡片。**视觉手段单选**(投影 / 描边 / 底色 三选一,不叠加 = 模板味)。 - - fill:卡片底色,默认 SURFACE(白)。想要浅色卡传 PRIMARY_SOFT / PRIMARY_WASH。 - - accent:给个颜色则在卡片左内缘加一条竖强调条(常用 PRIMARY / ACCENT)。 - 内容(标题/正文/图标)再叠到卡片上,自己按 left+0.3 内边距摆。 + - 默认**平卡**:白底卡自动描发丝边定义边界(不投影)。平铺网格里的对等卡都该这样。 + - shadow=True:**只给真正"悬浮"的卡**(照片上的卡、被挑出的推荐项); + pptmaster 铁律:每页 ≤2-3 个投影元素,对等网格卡一律平。 + - fill 传 PRIMARY_WASH/SOFT 等浅底时,底色即是手段,不再描边。 + - accent:左内缘细竖条(语义标记,标"这一张")—— 有它就不再自动描边。 """ - card = add_round_rect(slide, left, top, width, height, - SURFACE if fill is None else fill, radius, name) + is_white = fill is None + fill = SURFACE if fill is None else fill + card = add_round_rect(slide, left, top, width, height, fill, radius, name) if shadow: - set_shadow(card) - if border: - set_line(card, HAIRLINE, 0.75) - if accent is not None: - # 左内缘一条圆角竖强调条(无投影,纯色) + set_shadow(card) # 手段:投影(悬浮卡专用) + else: + if border is None: # 手段:描边(仅白底平卡且无 accent 时自动) + border = is_white and accent is None + if border: + set_line(card, HAIRLINE, 1.0) + if accent is not None: # 手段:左侧语义强调条 add_round_rect(slide, left + 0.18, top + 0.22, accent_w, max(0.4, height - 0.44), accent, radius=0.04, name=name + "_accent") @@ -482,29 +493,65 @@ def add_eyebrow(slide, x, y, text, color=None, size=13, width=4.0): 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 数字卡:大号数字 + 下方标签 + 可选小注(同比/单位)。 +def add_kpi(slide, left, top, width, height, value, label, baseline=None, + delta=None, delta_dir=None, value_color=None, card=True, + value_size=40, name="kpi"): + """KPI 数字卡:大号数字 + 标签 +(对比基准)+(升降趋势)。 - 数据页把"小柱状图 / 一行结论"升级成 2-4 张并排数字卡,信息密度与质感都更高。 - value 走 EN_FONT(数字/百分号更紧致)。 + **数据语境化铁律**(pptmaster):数字不要孤立出现。尽量给: + - baseline:对比基准,如 "行业均值 82%" / "上季 1.0M"(灰色小字) + - delta:趋势标注,如 "12.3%" / "+11pt";delta_dir 'up'/'down'/'flat' 决定升降色 + (绿=正 / 红=负 / 灰=平);不传 delta_dir 则从 delta 开头的 +/-/↑/↓ 推断。 + 数据页优先 2-4 张并排,比小柱图信息密度与质感都高。value 走 EN_FONT。 """ if card: - add_card(slide, left, top, width, height, fill=SURFACE, radius=0.12, - shadow=True, name=name + "_card") + add_card(slide, left, top, width, height, fill=SURFACE, 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, + add_textbox(slide, left + pad, top + pad, width - 2 * pad, height * 0.42, + str(value), value_size, bold=True, color=PRIMARY if value_color is None else value_color, font=EN_FONT, anchor=MSO_ANCHOR.BOTTOM, shrink=False, name=name + "_val") - add_textbox(slide, left + pad, top + pad + 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") + add_textbox(slide, left + pad, top + pad + height * 0.42, width - 2 * pad, + 0.36, label, 15, color=INK, anchor=MSO_ANCHOR.TOP, + name=name + "_label") + yb = top + height - 0.42 + if delta: + if delta_dir is None: + s = str(delta) + delta_dir = ("up" if (s[:1] in "+↑" or "增" in s or "↑" in s) + else "down" if (s[:1] in "-↓" or "降" in s or "↓" in s) + else "flat") + dcol = GOOD if delta_dir == "up" else BAD if delta_dir == "down" else GREY + add_textbox(slide, left + pad, yb, width - 2 * pad, 0.34, str(delta), + 13, bold=True, color=dcol, shrink=False, name=name + "_delta") + yb -= 0.32 + if baseline: + add_textbox(slide, left + pad, yb, width - 2 * pad, 0.32, str(baseline), + 12, color=GREY_LIGHT, shrink=False, name=name + "_base") + + +def add_takeaway(slide, text, top=None, name="takeaway"): + """Takeaway Box:标题下一句话**结论**(浅主色底 + 左主色短条)。 + + 咨询风内容页标配 —— 把"这页要讲什么"压成一句可带走的结论(pyramid 结论先行)。 + 例:"Q4 同比增 158%,创历史新高" 而不是 "营收情况"。 + """ + y = (SAFE_TOP + 1.0) if top is None else top + add_round_rect(slide, SAFE_LEFT, y, SAFE_W, 0.6, PRIMARY_WASH, radius=0.05, + name=name) + add_round_rect(slide, SAFE_LEFT, y, 0.09, 0.6, PRIMARY, radius=0.02, + name=name + "_bar") + add_textbox(slide, SAFE_LEFT + 0.32, y, SAFE_W - 0.6, 0.6, text, 16, + bold=True, color=INK, anchor=MSO_ANCHOR.MIDDLE, + name=name + "_txt") + + +def add_source(slide, text, name="source"): + """数据来源标注(右下角弱化)。**含数据的页必标**(咨询风硬规则)。""" + add_textbox(slide, SAFE_LEFT, SLIDE_H - 0.48, SAFE_W, 0.32, + f"来源:{text}", 11, color=GREY_LIGHT, align=PP_ALIGN.RIGHT, + shrink=False, name=name) def add_chevron(slide, x, y, width=0.55, height=0.5, color=None): @@ -521,6 +568,190 @@ def add_divider(slide, x, y, length, vertical=False, color=None): return add_rect(slide, x, y, length, 0.02, c, "divider") +# ============================================================ +# 组合版式件:均衡网格 / 时间轴 / 流程闭环 / 背景图 +# —— 模型直接调一个函数,别再手摆参差网格 / 用卡片硬凑时间线 +# ============================================================ +import math as _math +import os as _os + +_GRID_COLS = {1: 1, 2: 2, 3: 3, 4: 2, 5: 3, 6: 3, 7: 4, 8: 4, 9: 3} + + +def _unpack(item, keys, defaults): + if isinstance(item, dict): + return [item.get(k, d) for k, d in zip(keys, defaults)] + vals = list(item) + list(defaults) + return vals[:len(keys)] + + +def add_card_grid(slide, items, top, height, cols=None, gap=0.35, + icon_dir=None, icon_color="C00000", accent=None, + title_size=18, body_size=14, name="grid"): + """一次摆 N 张概念卡,**自动均衡行列**(2×2 / 2×3,不再手摆参差 3+2)。 + + items: 每项 {icon, title, body} 或 (icon, title, body);icon 是图标名 + (去 tabler_ 前缀,如 'target'),配 icon_dir 找 PNG;None 则不放图标。 + top/height: 网格纵向区域(如 标题下 ~1.95 → 底部留页脚约 6.9)。 + 布局自适应:**单行**(rows=1)图标顶置成高特征卡;**多行**图标左置成横向卡 + (正文拿到整卡高度,不会被顶置图标挤溢出)。正文请保持精炼(≤ ~18 字/卡)。 + """ + n = len(items) + if cols is None: + cols = _GRID_COLS.get(n, 4) + rows = _math.ceil(n / cols) + cw = (SAFE_W - gap * (cols - 1)) / cols + ch = (height - gap * (rows - 1)) / rows + pad = 0.32 + icon_top = rows == 1 + for i, it in enumerate(items): + icon, title, body = _unpack(it, ("icon", "title", "body"), (None, "", "")) + r, c = divmod(i, cols) + x = SAFE_LEFT + c * (cw + gap) + y = top + r * (ch + gap) + add_card(slide, x, y, cw, ch, accent=accent, name=f"{name}_card_{i}") + has_icon = bool(icon and icon_dir) + png = (_os.path.join(str(icon_dir), f"tabler_{icon}_{icon_color}_128.png") + if has_icon else None) + if icon_top: + tile = max(0.95, min(1.3, cw * 0.30)) + if has_icon: + add_icon_tile(slide, x + pad, y + 0.4, tile, png_path=png, + name=f"{name}_tile_{i}") + ty = y + 0.4 + tile + 0.2 + else: + ty = y + pad + tx, tw = x + pad, cw - 2 * pad + add_textbox(slide, tx, ty, tw, 0.45, title, title_size, bold=True, + color=INK, name=f"{name}_t_{i}") + add_textbox(slide, tx, ty + 0.5, tw, y + ch - 0.25 - (ty + 0.5), + body, body_size, color=GREY, name=f"{name}_b_{i}") + else: + # 多行:图标左置,文字竖直居中(整卡高度给文字,不会被挤) + tile = max(0.72, min(0.95, ch * 0.46, cw * 0.26)) + if has_icon: + add_icon_tile(slide, x + pad, y + (ch - tile) / 2, tile, + png_path=png, name=f"{name}_tile_{i}") + tx = x + pad + tile + 0.26 + else: + tx = x + pad + tw = x + cw - pad - tx + blk = 1.15 # 文字块估高,用于竖直居中 + ty = y + max(pad, (ch - blk) / 2) + add_textbox(slide, tx, ty, tw, 0.42, title, title_size, bold=True, + color=INK, name=f"{name}_t_{i}") + add_textbox(slide, tx, ty + 0.46, tw, y + ch - pad - (ty + 0.46), + body, body_size, color=GREY, name=f"{name}_b_{i}") + + +def add_timeline(slide, nodes, y=3.2, name="tl"): + """横向时间轴:主轴线 + 均布节点(年份 pill 在上,标题/说明在下)。 + + nodes: list of {year, title, body} 或 (year, title, body)。3-6 个最佳。 + 发展历程 / 路线图 / 里程碑类内容用它,**别塞进卡片网格**。 + """ + n = len(nodes) + x0 = SAFE_LEFT + 0.4 + x1 = SAFE_RIGHT - 0.4 + span = x1 - x0 + add_rect(slide, x0, y, span, 0.035, PRIMARY, "tl_axis") + step = span / (n - 1) if n > 1 else 0 + for i, nd in enumerate(nodes): + year, title, body = _unpack(nd, ("year", "title", "body"), ("", "", "")) + cx = x0 + i * step + d = 0.26 + add_shape(slide, MSO_SHAPE.OVAL, cx - d / 2, y + 0.0175 - d / 2, d, d, + PRIMARY, f"tl_dot_{i}") + pw = 1.15 + px = max(0.2, min(cx - pw / 2, SLIDE_W - pw - 0.2)) + add_pill(slide, px, y - 0.66, pw, 0.42, str(year), fill=ACCENT, + fg=INK, size=14, name=f"tl_year_{i}") + bw = min(2.5, step * 0.96) if n > 1 else 3.0 + tx = max(0.2, min(cx - bw / 2, SLIDE_W - bw - 0.2)) + add_textbox(slide, tx, y + 0.42, bw, 0.45, title, 16, bold=True, + color=INK, align=PP_ALIGN.CENTER, name=f"tl_t_{i}") + add_textbox(slide, tx, y + 0.92, bw, 1.4, body, 14, color=GREY, + align=PP_ALIGN.CENTER, name=f"tl_b_{i}") + + +def add_cycle(slide, steps, cx=None, cy=4.5, radius=1.55, center_label=None, + name="cyc"): + """流程闭环:节点沿圆环顺时针均布 + 可选中心词 + 浅环连线。 + + steps: list of {title, body} 或 (title, body)。4-6 个最佳。 + "感知-规划-执行-反馈"这类**循环**用它,别做成平铺卡片(丢了闭环语义)。 + """ + n = len(steps) + if cx is None: + cx = SLIDE_W / 2 + ry = radius * 0.80 # 纵向压扁成椭圆,16:9 上更协调 + ring = add_shape(slide, MSO_SHAPE.OVAL, cx - radius, cy - ry, + 2 * radius, 2 * ry, WHITE, name + "_ring") + ring.fill.background() + set_line(ring, HAIRLINE, 1.5) + if center_label: + cd = radius * 0.80 + add_shape(slide, MSO_SHAPE.OVAL, cx - cd / 2, cy - cd / 2, cd, cd, + PRIMARY_WASH, name + "_hub") + add_textbox(slide, cx - cd / 2, cy - cd / 2, cd, cd, center_label, 17, + bold=True, color=PRIMARY, align=PP_ALIGN.CENTER, + anchor=MSO_ANCHOR.MIDDLE, name=name + "_hublabel") + nd = 1.0 + for i, st in enumerate(steps): + title, body = _unpack(st, ("title", "body"), ("", "")) + ang = _math.radians(-90 + i * 360 / n) + nx = cx + radius * _math.cos(ang) + ny = cy + ry * _math.sin(ang) + add_badge(slide, nx - nd / 2, ny - nd / 2, i + 1, diameter=nd) + lw = 2.1 + lx = max(0.2, min(nx - lw / 2, SLIDE_W - lw - 0.2)) + ly = (ny - nd / 2 - 0.46) if ny <= cy else (ny + nd / 2 + 0.06) + ly = max(0.2, min(ly, SLIDE_H - 0.4)) + add_textbox(slide, lx, ly, lw, 0.4, title, 15, bold=True, color=INK, + align=PP_ALIGN.CENTER, name=f"{name}_t_{i}") + + +def add_toc(slide, items, top=2.2, row_h=None, name="toc"): + """贯通整宽的目录:每行 = 序号 + 标题 +(右侧副标)+ 发丝分隔线。 + + items: 每项 (title, caption) 或 {title, caption} 或纯 title 字符串。 + 比"左侧一列编号圆点"铺满版面、信息更足(副标给每章一句定位)。 + """ + n = len(items) + if row_h is None: + row_h = min(0.95, (SAFE_BOTTOM - 0.2 - top) / n) + for i, it in enumerate(items): + if isinstance(it, str): + title, cap = it, "" + else: + title, cap = _unpack(it, ("title", "caption"), ("", "")) + y = top + i * row_h + add_textbox(slide, SAFE_LEFT, y, 1.05, row_h - 0.18, f"{i + 1:02d}", 34, + bold=True, color=PRIMARY, font=EN_FONT, + anchor=MSO_ANCHOR.MIDDLE, name=f"{name}_n_{i}") + add_textbox(slide, SAFE_LEFT + 1.25, y, 6.3, row_h - 0.18, title, 21, + bold=True, color=INK, anchor=MSO_ANCHOR.MIDDLE, + name=f"{name}_t_{i}") + if cap: + add_textbox(slide, SAFE_LEFT + 8.0, y, SAFE_W - 8.0, row_h - 0.18, + cap, 15, color=GREY, align=PP_ALIGN.RIGHT, + anchor=MSO_ANCHOR.MIDDLE, name=f"{name}_c_{i}") + add_divider(slide, SAFE_LEFT, y + row_h - 0.1, SAFE_W) + + +def add_picture_bg(slide, png): + """整页铺一张渲染好的高清背景图(混合方案:背景图 + 其上原生可编辑文字)。 + + 封面/章节用:先 `add_picture_bg(slide, bg.png)`,再叠 `add_textbox` 文字。 + 背景不可改但文字仍能在 PPT 里编辑 —— editable 前提下拿到的最佳观感。 + """ + if png and Path(str(png)).exists(): + return slide.shapes.add_picture(str(png), Inches(0), Inches(0), + width=Inches(SLIDE_W), + height=Inches(SLIDE_H)) + return None + + # ============================================================ # 演讲者备注 # ============================================================ diff --git a/skills/ppt/scripts/pptx_preview.py b/skills/ppt/scripts/pptx_preview.py new file mode 100644 index 0000000..29298f6 --- /dev/null +++ b/skills/ppt/scripts/pptx_preview.py @@ -0,0 +1,220 @@ +"""pptx_preview.py: 把 .pptx 渲成 PNG 预览图(无头 Chrome),用于**肉眼验收版面**。 + +quality_check 只查"越界/溢出/配色"等结构问题,看不出"好不好看"。本脚本把每页 +按形状坐标还原成 HTML → Chrome 截图 → PNG,让人(或模型用 Read)真看一眼版面层次、 +留白、对齐、配色观感。支持本 skill 用到的形状子集:矩形/圆角矩形/渐变块/文本框/图片。 + +用法: + python pptx_preview.py -o [--pages 1,4,6] +产物:/p01.png p02.png ...(每页一张,2x 超采样) + +依赖:本机 Chrome / Edge(同 render_bg.py)。非本 skill 生成的复杂 pptx 可能还原不全。 +""" +from __future__ import annotations + +import argparse +import html as _html +import subprocess +import tempfile +from pathlib import Path + +from pptx import Presentation +from pptx.enum.dml import MSO_FILL, MSO_COLOR_TYPE +from pptx.enum.shapes import MSO_SHAPE_TYPE +from pptx.enum.text import PP_ALIGN, MSO_ANCHOR +from pptx.oxml.ns import qn + +from render_bg import find_browser # 复用浏览器定位 + +EMU = 914400 +PXI = 96 # px per inch + + +def _rgb(color): + try: + if color.type == MSO_COLOR_TYPE.RGB: + return "#" + str(color.rgb) + except (AttributeError, TypeError, KeyError, ValueError): + pass + return None + + +def _fill_css(shape): + """返回 (css背景, 是否有填充)。支持纯色 / 线性渐变。""" + try: + f = shape.fill + if f.type == MSO_FILL.SOLID: + c = _rgb(f.fore_color) + return (c, True) if c else (None, False) + if f.type == MSO_FILL.GRADIENT: + stops = [] + for gs in f.gradient_stops: + c = _rgb(gs.color) + if c: + stops.append(f"{c} {int(gs.position * 100)}%") + if len(stops) >= 2: + try: + ang = f.gradient_angle + except (AttributeError, ValueError, TypeError): + ang = 90 + # pptx 角度→css:0=左→右 即 css 90deg + return (f"linear-gradient({90 + (ang or 0)}deg,{','.join(stops)})", True) + except (AttributeError, TypeError, KeyError, ValueError): + pass + return (None, False) + + +def _round_px(shape, w, h): + try: + adj = shape.adjustments[0] + return adj * min(w, h) + except (IndexError, AttributeError, ValueError, TypeError): + return 0 + + +def _line(shape): + try: + ln = shape.line + c = _rgb(ln.color) + if c and ln.width is not None and ln.width > 0: + return c, max(1, ln.width / EMU * PXI) + except (AttributeError, TypeError, KeyError, ValueError): + pass + return None, 0 + + +def _anchor_flex(tf): + a = tf.vertical_anchor + if a == MSO_ANCHOR.MIDDLE: + return "center" + if a == MSO_ANCHOR.BOTTOM: + return "flex-end" + return "flex-start" + + +def _align_css(p): + return {PP_ALIGN.CENTER: "center", PP_ALIGN.RIGHT: "right"}.get( + p.alignment, "left") + + +def _para_html(p): + align = _align_css(p) + runs = [] + size = 18 + color = "#1F1F1F" + bold = False + for r in p.runs: + if r.font.size: + size = r.font.size.pt + c = _rgb(r.font.color) + if c: + color = c + bold = bool(r.font.bold) + runs.append(_html.escape(r.text or "")) + txt = "".join(runs) or _html.escape(p.text or "") + if not txt.strip(): + return "" + lh = 1.25 + return (f'
{txt}
') + + +def slide_html(slide, imgdir: Path, idx: int) -> str: + parts = [] + for s_i, sh in enumerate(slide.shapes): + try: + l = sh.left / EMU * PXI + t = sh.top / EMU * PXI + w = sh.width / EMU * PXI + h = sh.height / EMU * PXI + except (TypeError, AttributeError): + continue + base = (f"position:absolute;left:{l:.1f}px;top:{t:.1f}px;" + f"width:{w:.1f}px;height:{h:.1f}px;box-sizing:border-box;") + # 图片 + try: + is_pic = sh.shape_type == MSO_SHAPE_TYPE.PICTURE + except (AttributeError, ValueError): + is_pic = False + if is_pic: + try: + blob = sh.image.blob + ext = sh.image.ext + fp = imgdir / f"p{idx}_{s_i}.{ext}" + fp.write_bytes(blob) + parts.append(f'') + except (AttributeError, KeyError, ValueError): + pass + continue + # 形状填充 / 圆角 / 描边 + css = base + bg, has = _fill_css(sh) + if has: + css += f"background:{bg};" + # 椭圆/圆 → 50% 圆角(badge/dot/hub 才显示成圆,不然是方块) + prst = None + try: + g = sh._element.spPr.find(qn("a:prstGeom")) + prst = g.get("prst") if g is not None else None + except (AttributeError, TypeError): + pass + if prst == "ellipse": + css += "border-radius:50%;" + else: + r = _round_px(sh, w, h) + if r > 0: + css += f"border-radius:{r:.1f}px;" + lc, lw = _line(sh) + if lc: + css += f"border:{lw:.1f}px solid {lc};" + # 文本 + inner = "" + if sh.has_text_frame and (sh.text_frame.text or "").strip(): + tf = sh.text_frame + css += ("display:flex;flex-direction:column;padding:2px 6px;" + f"justify-content:{_anchor_flex(tf)};") + inner = "".join(_para_html(p) for p in tf.paragraphs) + if has or r > 0 or lc or inner: + parts.append(f'
{inner}
') + body = "\n".join(parts) + return (f'
{body}
') + + +def render(html_str: str, out: Path): + browser = find_browser() + with tempfile.TemporaryDirectory() as td: + hp = Path(td) / "s.html" + hp.write_text(f"" + f"{html_str}", + encoding="utf-8") + subprocess.run([browser, "--headless", "--disable-gpu", + "--hide-scrollbars", "--force-device-scale-factor=2", + "--window-size=1280,720", f"--screenshot={out}", + hp.resolve().as_uri()], check=False, + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("pptx", type=Path) + ap.add_argument("-o", "--out", type=Path, required=True) + ap.add_argument("--pages", default=None, help="如 1,4,6;省略=全部") + args = ap.parse_args() + args.out.mkdir(parents=True, exist_ok=True) + imgdir = args.out / "_img" + imgdir.mkdir(exist_ok=True) + prs = Presentation(str(args.pptx)) + want = (set(int(x) for x in args.pages.split(",")) if args.pages else None) + for i, slide in enumerate(prs.slides, 1): + if want and i not in want: + continue + out = args.out / f"p{i:02d}.png" + render(slide_html(slide, imgdir, i), out) + print(f"[ok] {out}" if out.exists() else f"[fail] p{i}") + + +if __name__ == "__main__": + main() diff --git a/skills/ppt/scripts/quality_check.py b/skills/ppt/scripts/quality_check.py index ca4c12f..c2801a4 100644 --- a/skills/ppt/scripts/quality_check.py +++ b/skills/ppt/scripts/quality_check.py @@ -75,6 +75,16 @@ def _hue_family(hex6: str) -> int: return int((h * 360) // 30) +def _is_semantic_status(hex6: str) -> bool: + """语义状态色(绿=正向趋势):业界通用约定,不计入"三色制"。 + 绿色相带(约 95°-175°)且有一定饱和 → 视为趋势/成功色,豁免。""" + try: + h, s, _v = _hsv(hex6) + except (ValueError, IndexError): + return False + return 95 <= h * 360 <= 175 and s >= 0.30 + + def _is_neutral(hex6: str) -> bool: """保留旧名:非彩色(中性)= 不计入三色制。""" return not _is_chromatic(hex6) @@ -215,7 +225,10 @@ def check_pptx(path: Path, spec: dict) -> tuple[list, list]: head = "" if shape.has_text_frame: head = (shape.text_frame.text or "").strip() - if (head or is_pic) and w_in > 0.05 and h_in > 0.05: + # 全幅背景图(覆盖 ≥85% 画布)是混合方案的背景层,文字本就叠其上, + # 不算"内容碰撞",排除出重叠检测,否则误报"图片压住所有文字"。 + is_full_bg = (is_pic and w_in * h_in >= 0.85 * slide_w_in * slide_h_in) + if (head or is_pic) and w_in > 0.05 and h_in > 0.05 and not is_full_bg: content_shapes.append( (left_in, top_in, w_in, h_in, shape_label, head[:18] if head else "[图片]") @@ -344,7 +357,8 @@ def check_pptx(path: Path, spec: dict) -> tuple[list, list]: # 三色制按"色系数"判定:同色系深浅(主色/深红渐变/浅红卡片底)收敛成一桶, # 低饱和浅色/灰阶不计。这样卡片式设计的派生色阶不会被误报超 3 色。 - chromatic = {c for c in seen_colors if _is_chromatic(c)} + chromatic = {c for c in seen_colors + if _is_chromatic(c) and not _is_semantic_status(c)} families = {_hue_family(c) for c in chromatic} if len(families) > 3: warnings.append( diff --git a/skills/ppt/scripts/render_bg.py b/skills/ppt/scripts/render_bg.py new file mode 100644 index 0000000..68c9090 --- /dev/null +++ b/skills/ppt/scripts/render_bg.py @@ -0,0 +1,135 @@ +"""render_bg.py: 用无头 Chrome/Edge 把主题化 HTML 背景渲成高清 PNG。 + +混合方案专用 —— 封面/章节页:先用本脚本渲一张杂志级背景图,build_deck 里 +`P.add_picture_bg(slide, png)` 整页铺,再叠原生可编辑文字。背景不可改但文字能改, +是 editable 前提下能拿到的最高观感(DrawingML 渐变做不出 mesh 渐变 + 模糊光晕)。 + +用法: + python render_bg.py --out cover.png --kind cover --primary C00000 + python render_bg.py --out sec.png --kind section --primary C00000 --accent FFC107 + python render_bg.py --out x.png --html mybg.html # 渲任意 HTML + +依赖:本机装了 Chrome 或 Edge(无需 pip 包)。两者都没有则报错退出。 +产物默认 2560x1440(16:9 高清,2x 超采样),嵌进 13.33in 画布够清晰。 +""" +from __future__ import annotations + +import argparse +import subprocess +import sys +import tempfile +from pathlib import Path + +_CHROME_CANDIDATES = [ + r"C:\Program Files\Google\Chrome\Application\chrome.exe", + r"C:\Program Files (x86)\Google\Chrome\Application\chrome.exe", + r"C:\Program Files\Microsoft\Edge\Application\msedge.exe", + r"C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe", +] + + +def find_browser() -> str: + for c in _CHROME_CANDIDATES: + if Path(c).exists(): + return c + # PATH 兜底 + import shutil + for name in ("chrome", "chrome.exe", "msedge", "msedge.exe"): + p = shutil.which(name) + if p: + return p + raise SystemExit("[fatal] 未找到 Chrome / Edge,无法渲染背景图。改用 DrawingML 渐变背景(apply_brand)。") + + +def _hex(h: str) -> tuple[int, int, int]: + h = h.lstrip("#") + return int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16) + + +def _mix(c, d, t): + return tuple(round(a + (b - a) * t) for a, b in zip(c, d)) + + +def _css(c) -> str: + return f"rgb({c[0]},{c[1]},{c[2]})" + + +def build_html(kind: str, primary: str, accent: str) -> str: + p = _hex(primary) + a = _hex(accent) + dark = _mix(p, (0, 0, 0), 0.55) # 深端 + deep = _mix(p, (0, 0, 0), 0.30) + glow = _mix(p, (255, 255, 255), 0.25) # 亮红光晕 + pc, dc, dpc, gc, ac = _css(p), _css(dark), _css(deep), _css(glow), _css(a) + # 公共:mesh 渐变(多点径向叠加)+ 模糊光斑 + 细点纹理。文字由 build_deck 叠。 + # cover:左侧加暗罩,让左置白字更稳;section:整页深,中心略亮。 + overlay = ( + "radial-gradient(1200px 900px at 18% 50%, rgba(0,0,0,.34), transparent 60%)," + if kind == "cover" else + "radial-gradient(1000px 800px at 50% 42%, rgba(255,255,255,.06), transparent 60%)," + ) + return f"""
+
+
+
+
+
""" + + +def render(html: str, out: Path, w: int, h: int) -> None: + browser = find_browser() + with tempfile.TemporaryDirectory() as td: + hp = Path(td) / "bg.html" + hp.write_text(html, encoding="utf-8") + url = hp.resolve().as_uri() + # 用 1/2 窗口 + 2x 缩放 = 超采样,边缘/模糊更干净 + cmd = [ + browser, "--headless", "--disable-gpu", "--hide-scrollbars", + "--default-background-color=00000000", + f"--force-device-scale-factor=2", + f"--window-size={w // 2},{h // 2}", + f"--screenshot={out}", url, + ] + subprocess.run(cmd, check=False, + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + if not out.exists(): + raise SystemExit(f"[fatal] 渲染失败,未生成 {out}(浏览器: {browser})") + print(f"[ok] {out} ({out.stat().st_size // 1024} KB)") + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--out", type=Path, required=True) + ap.add_argument("--kind", choices=["cover", "section"], default="cover") + ap.add_argument("--primary", default="C00000") + ap.add_argument("--accent", default="FFC107") + ap.add_argument("--html", type=Path, default=None, help="渲任意 HTML 文件(忽略 kind)") + ap.add_argument("--w", type=int, default=2560) + ap.add_argument("--h", type=int, default=1440) + args = ap.parse_args() + args.out.parent.mkdir(parents=True, exist_ok=True) + html = (args.html.read_text(encoding="utf-8") if args.html + else build_html(args.kind, args.primary, args.accent)) + render(html, args.out, args.w, args.h) + + +if __name__ == "__main__": + main()