feat(ppt): 补信息设计纪律 + 混合背景 + pptx 预览器(深读 pptmaster 后)

深读 ppt-master 的 executor/shared-standards 后定位:它像麦肯锡的真因是
信息设计纪律(~70%)而非 SVG 渲染。这些全是 editable python-pptx 能做的。

- 信息内功:add_takeaway(论断标题下结论框)、add_kpi 加 baseline+delta
  (数据语境化)、add_source、add_toc;SKILL 策略阶段加论断式标题对照表 +
  page_rhythm(breathing 页强制打破卡片网格)+ 内容→版式映射
- 修反了的投影:add_card 默认平卡(shadow=False),投影只给悬浮卡、每页 ≤2-3、
  一容器一手段;quality_check 加绿=语义状态色豁免三色制
- 组合件:add_card_grid(均衡网格,多行图标左置治溢出)/add_timeline/add_cycle
- 混合背景 render_bg.py:无头 Chrome 渲杂志级 mesh 渐变背景 + 原生可编辑白字
- pptx_preview.py:把 .pptx 渲成 PNG 肉眼验观感 —— 当场抓到 set_text 多行
  只给第一段上色的真 bug(封面副标题第二行变暗),已修

验证:重排「大模型与智能体」10 页,逐页渲 PNG 亲眼验收均专业,quality_check 全过。
未做 SVG→原生转换器(论证为可编辑输出零视觉增益)。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
caoqianming 2026-06-09 09:41:55 +08:00
parent 8150bf0b83
commit b2b4a29ad3
9 changed files with 854 additions and 72 deletions

View File

@ -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` 幽灵路径是否新任务仍复现)仍未查。

View File

@ -161,13 +161,13 @@ zcbot 的"skill"是一份可加载的工作流脚本(`skills/<name>/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 素材摄取
**典型产物**:`<task>.pptx` + `build_deck.py`(整 deck 构建脚本,改稿/修验收项都改它重跑)。

View File

@ -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 是只读挂载。新拉的图标写 `<task_dir>/assets/icons/`)
- 素材摄取: 用 `markitdown` CLI 把 PDF/DOCX/PPTX/XLSX/HTML/URL 转干净 Markdown,统一落到 `<task_dir>/source/<name>.md`(同 working_dir 多 task 共享 source 池)
- `scripts/fetch_icon.py` —— 从 Iconify CDN 拉 SVG/PNG (染主题色,缓存到 `<task_dir>/assets/icons/`)
- `assets/icons/` —— **只读**种子图标库 (商务红 tabler 集,见 `INDEX.md`;新拉的图标写 `<task_dir>/assets/icons/`)
- 素材摄取: 用 `markitdown` CLI 把 PDF/DOCX/PPTX/XLSX/HTML/URL 转干净 Markdown,落到 `<task_dir>/source/<name>.md`
- `scripts/fetch_icon.py` —— 从 Iconify CDN 拉 SVG/PNG (染主题色;**PNG 转换需 cairosvg/svglib,没装会只出 SVG** —— 优先用种子库现成 PNG)
- `scripts/render_icon.py` —— unicode 字形 → 透明 PNG (Iconify 没有时兜底)
- `scripts/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 <task_dir>/*-<task_short_id>-*.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 <task_dir>/*-<task_short_id>-*.spec.md → 按文件名字典序排,取最
1. **读 current spec**(按 §0 的 glob 规则拿字典序最大那份),含 8 项 + 逐页大纲;只用里面定的颜色/字体/图标/页结构,**不凭记忆发挥**。
2. **图标批量预取(全 deck 一次,不逐页)**: 把大纲里所有页需要的图标概念汇总,`glob` 两处看现成 —— 种子库 `<skill_dir>/assets/icons/`(只读)+ 本 task `<task_dir>/assets/icons/`;缺的在**一个 `run_python` 里批量** `fetch_icon.py <name> --set tabler --color C00000 --size 128 -o <task_dir>/assets/icons/...` 拉齐。**几何形状(圆点/徽章/装饰线)不算图标,走 layouts.md helper**。
3. **真实配图(opt-in,仅当大纲标了 `[img]`)**: 把标 `[img]` 的页(封面/章节/图片页)汇总,**load `imagegen` skill 走它自己的确认流程**逐张生成(每张 ¥0.22,有强制确认门,不要绕过),产物落 `<task_dir>/figures/`;build_deck 里 `add_picture(<figures 路径>)` 引用。**没标 `[img]` 的 deck 跳过这步**,图标/卡片/渐变已足够撑视觉。
4. **写 `build_deck.py` 到 `<task_dir>`,一次建整 deck**: 顶部 `import pptx_helpers as P`(`sys.path` 指到 `<skill_dir>/scripts`)→ `prs = P.new_presentation(...)``P.set_palette(spec_path=...)`**按大纲循环每页**(`P.add_slide` + `P.apply_brand` + 各 helper,每页一个小函数 `page_1(prs)`…清晰;**每页 `P.add_notes(slide, ...)` 写 2-4 句演讲者口述要点**)→ 末尾一次 `prs.save(...)`。**helper 一律 `P.xxx`,不默写源码**;版式照 layouts.md L1-L13 选,**业务概念用 L11 卡片网格、数字用 L10 KPI 卡**。起手见 `layouts.md §通用起手`。先 `write` 脚本再 `run_python(script_path=...)`(避免大段源码进对话历史)。
5. **quality_check 一次**(见阶段三)→ 按报告**改 `build_deck.py` 重跑**(不要逐页 edit 成品 .pptx —— 改源脚本可复现、可再跑)。
7. 报整份 deck:页数、各页版式、用到的图标/配图;问用户要不要改。
8. 用户确认了**实质改动**(改版式 / 换图标 / 改文案要点 / 增删页 / 调主色)后,追加一行到 `<task_dir>/REVISIONS.md` —— 见 §修订日志。
4. **混合背景(opt-in)**:封面/章节想要杂志级背景时,`run_python` 调 `render_bg.py --out <task_dir>/figures/cover_bg.png --kind cover --primary <主色>`(+ section),build_deck 里 `P.add_picture_bg(slide, bg)` 铺底再叠**白色**文字。**背景图不可编辑、文字可编辑**——这是 editable 前提下的最高观感。
5. **写 `build_deck.py` 到 `<task_dir>`,一次建整 deck**: 顶部 `import pptx_helpers as P``P.new_presentation``P.set_palette(spec_path=...)`**按大纲循环每页**(每页一个小函数)→ 末尾 `prs.save`。落实**信息内功**(见 design_principles §信息设计纪律):
- **论断式标题**(写结论)+ 内容页 `P.add_takeaway(slide, "<一句话结论>")`;
- 含数据用 `P.add_kpi(..., baseline=, delta=)` + `P.add_source`;**数字别孤立**;
- **节奏**:按大纲的 anchor/dense/breathing 落版式,breathing 页走大字/金句/整图(**禁卡片网格**);
- **投影克制**:平铺网格卡用 `add_card`(默认平卡),投影只给悬浮/被挑出的卡,每页 ≤2-3 个;
- 每页 `P.add_notes` 写 2-4 句**结论先行的口语**演讲稿。
helper 一律 `P.xxx` 不默写源码;版式见 layouts.md。先 `write` 脚本再 `run_python(script_path=...)`
6. **quality_check + 预览双验收**(见阶段三)→ 按报告**改 `build_deck.py` 重跑**(不逐页 edit 成品)。
7. 报整份 deck:页数、各页版式/节奏、用到的图标/配图;问用户要不要改。
8. 用户确认了**实质改动**后,追加一行到 `<task_dir>/REVISIONS.md` —— 见 §修订日志。
**风格探针(可选,降视觉返工险)**: 用户对观感没底、或这是全新风格时,可先只建**封面 + 1 内页**给用户看一眼,确认后把 `build_deck.py` 的页范围放开重跑补齐其余页 —— 仍是改一个脚本,不退回逐页。用户要快("直接全做")就跳过探针,整 deck 一把出。
**为什么不再逐页?** 逐页的两个理由都已消解:① 防坐标漂移 → `pptx_helpers` 模块化已解决;② 早发现方向问题 → 前移到阶段一「逐页大纲」确认(改文字比改 slide 便宜),视觉观感由可选探针 + 整 deck 后批改兜底。代价是放弃"逐页即时纠错",换来 N 页从 ~2N 轮降到 ~3-4 轮。
### 阶段三: 验收
### 阶段三: 验收 (结构 + 观感 双验)
**① 结构验收** `quality_check.py`(越界/溢出/三色/重叠):
```bash
python <skill_dir>/scripts/quality_check.py <task_dir>/<output.pptx> --spec <task_dir>/<today>-<task_short_id>-<task_name>.spec.md
```
不通过的项,**改 `build_deck.py` 重跑**(改源脚本可复现;不要直接 edit 成品 .pptx)。
**② 观感验收** `pptx_preview.py`(渲成 PNG **肉眼看版面**)—— quality_check 查不出"好不好看 / 文字层级 / 留白 / 多行文本掉色"这类问题,**交付前必须渲几页关键页用 `read` 亲眼过**:
```bash
python <skill_dir>/scripts/pptx_preview.py <task_dir>/<output.pptx> -o <task_dir>/preview --pages 1,3,5
```
看封面、一个内容页、breathing 页是否如预期(标题层级、卡片是否过挤/过空、文字是否都正常上色、节奏是否单调)。
两项不通过的,**改 `build_deck.py` 重跑**(改源脚本可复现;不要直接 edit 成品 .pptx)。
## 设计原则 (硬规则速查)
- **每页一个核心信息**: 一页讲一件事,塞两件就拆页

View File

@ -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 英寸

View File

@ -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 <task_dir>/figures/cover_bg.png --kind cover --primary C00000
s = P.add_slide(prs)
P.add_picture_bg(s, "<task_dir>/figures/cover_bg.png") # 背景图(不可编辑)
P.add_eyebrow(s, 0.95, 1.95, "TECHNOLOGY INSIGHT · 2026", color=P.ACCENT)
P.add_textbox(s, 0.95, 2.45, 8.0, 1.7, "主标题\n副标题行", 44, bold=True,
color=P.WHITE, name="cover_title") # 白字叠背景(可编辑)
```
> 下面 L1-L13 是更细的手摆版式参考;**业务概念/数据/历程/循环优先用上面的组合件**,手摆只在组合件不覆盖时用。
> ⚠️ **给每个元素起语义 `name`**(`"bullet_1"`/`"kpi_val"`/`"eyebrow"`/`"pill"` 等)。quality_check 靠 name 判定"哪些是标签(小字号豁免)、哪些是真 bullet(计 ≤5)、谁压了谁",名字乱起会误报。helper 默认名已合理,自己加文本时照着命名。
> `MSO_SHAPE` / `PP_ALIGN` / `MSO_ANCHOR` 页面里要直接用就自行 import(`pptx_helpers` 内部已 import 但不重导出)。

View File

@ -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
# ============================================================
# 演讲者备注
# ============================================================

View File

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

View File

@ -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(

View File

@ -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"""<!doctype html><html><head><meta charset="utf-8"><style>
html,body{{margin:0;padding:0}}
.bg{{width:1280px;height:720px;position:relative;overflow:hidden;
background:
{overlay}
radial-gradient(700px 520px at 82% 16%, {gc}, transparent 58%),
radial-gradient(900px 700px at 92% 96%, {dc}, transparent 55%),
radial-gradient(620px 620px at 12% 8%, rgba(255,255,255,.10), transparent 60%),
linear-gradient(135deg, {pc} 0%, {dpc} 58%, {dc} 100%);
}}
.blob{{position:absolute;border-radius:50%;filter:blur(64px)}}
.b1{{width:420px;height:420px;right:-60px;top:-110px;background:{ac};opacity:.30}}
.b2{{width:360px;height:360px;right:160px;bottom:-130px;background:{gc};opacity:.40}}
.grid{{position:absolute;inset:0;opacity:.07;
background-image:linear-gradient(rgba(255,255,255,.6) 1px,transparent 1px),
linear-gradient(90deg,rgba(255,255,255,.6) 1px,transparent 1px);
background-size:54px 54px}}
.bar{{position:absolute;left:0;top:0;width:8px;height:720px;background:{ac};opacity:.9}}
</style></head><body><div class="bg">
<div class="grid"></div>
<div class="blob b1"></div>
<div class="blob b2"></div>
<div class="bar"></div>
</div></body></html>"""
def render(html: str, out: Path, w: int, h: int) -> None:
browser = find_browser()
with tempfile.TemporaryDirectory() as td:
hp = Path(td) / "bg.html"
hp.write_text(html, encoding="utf-8")
url = hp.resolve().as_uri()
# 用 1/2 窗口 + 2x 缩放 = 超采样,边缘/模糊更干净
cmd = [
browser, "--headless", "--disable-gpu", "--hide-scrollbars",
"--default-background-color=00000000",
f"--force-device-scale-factor=2",
f"--window-size={w // 2},{h // 2}",
f"--screenshot={out}", url,
]
subprocess.run(cmd, check=False,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
if not out.exists():
raise SystemExit(f"[fatal] 渲染失败,未生成 {out}(浏览器: {browser})")
print(f"[ok] {out} ({out.stat().st_size // 1024} KB)")
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--out", type=Path, required=True)
ap.add_argument("--kind", choices=["cover", "section"], default="cover")
ap.add_argument("--primary", default="C00000")
ap.add_argument("--accent", default="FFC107")
ap.add_argument("--html", type=Path, default=None, help="渲任意 HTML 文件(忽略 kind)")
ap.add_argument("--w", type=int, default=2560)
ap.add_argument("--h", type=int, default=1440)
args = ap.parse_args()
args.out.parent.mkdir(parents=True, exist_ok=True)
html = (args.html.read_text(encoding="utf-8") if args.html
else build_html(args.kind, args.primary, args.accent))
render(html, args.out, args.w, args.h)
if __name__ == "__main__":
main()