@clawhub-scottliu007-9ceeea3634
聚合国内常用搜索引擎:默认用 DuckDuckGo HTML(cn-zh)零 API Key 抓取可解析结果,并输出必应中国、百度、搜狗、360 的直达搜索链接。当用户需要中文网页检索、国内信息、政策/舆情、对比多个搜索引擎、或 Brave/Google 不可用时使用。依赖 Python 3 标准库与 scrip...
---
name: multi-search-cn
description: 聚合国内常用搜索引擎:默认用 DuckDuckGo HTML(cn-zh)零 API Key 抓取可解析结果,并输出必应中国、百度、搜狗、360 的直达搜索链接。当用户需要中文网页检索、国内信息、政策/舆情、对比多个搜索引擎、或 Brave/Google 不可用时使用。依赖 Python 3 标准库与 scripts/search_cn.py。
---
# Multi-Search-CN(国内多搜索引擎)
## 依赖
- **Python 3.8+**(macOS / Ubuntu / WSL 通常已有)
- 本 Skill 内脚本:`scripts/search_cn.py`(无 pip 依赖)
## 快速使用
在项目根或任意目录:
```bash
python3 .cursor/skills/multi-search-cn/scripts/search_cn.py "深圳 天气"
```
仅打印各引擎**搜索页链接**(不联网解析):
```bash
python3 .cursor/skills/multi-search-cn/scripts/search_cn.py "关键词" --urls-only
```
机器可读 JSON:
```bash
python3 .cursor/skills/multi-search-cn/scripts/search_cn.py "OpenClaw" --json
```
## Agent 行为约定
1. **默认**先跑 `search_cn.py`(不带 `--urls-only`),把 DDG 解析结果 + 各引擎直达链接一并交给用户。
2. 若解析失败或无结果:**明确说明**,并给出 `--urls-only` 的链接列表,建议用户浏览器打开百度/必应。
3. **不要**假装已「爬遍」百度/必应正文;纯 curl 常被风控,本 Skill 不以解析百度 HTML 为承诺。
4. 高频请求前提醒用户注意频率与合规。
## 与 Brave / Baidu Search 等 Skill 的关系
- 有 **Brave API Key** 时优先用官方 Brave Search Skill。
- 无 Key 或需**国内结果**时,用本 Skill 的 DDG(cn-zh)+ 国内引擎直达链接。
## 更多
- 排错与 OpenClaw 说明见 [reference.md](reference.md)。
FILE:reference.md
# Multi-Search-CN 参考
## 设计取舍
| 方式 | 说明 |
|------|------|
| **DuckDuckGo HTML** | 无 API Key、纯 Python3 标准库可解析 `result__a`,对中文设 `kl=cn-zh` |
| **必应/百度/搜狗/360 直达链接** | 仅生成搜索 URL;**不保证**用 curl 能解析正文(风控/JS/验证码) |
## 依赖
- Python 3.8+(无第三方包)
## 与 OpenClaw / 小爪
在 Ubuntu 上(`openclaw` 所在机器)可直接:
```bash
python3 /path/to/multi-search-cn/scripts/search_cn.py "你的关键词"
```
若需飞书/定时任务里调用,注意频率,避免对搜索引擎造成过高请求。
## 故障排查
1. **DDG 无结果**:升级脚本;检查网络;换 `--urls-only` 用浏览器打开各引擎。
2. **企业网络拦截**:代理环境下再试。
3. **合规**:遵守各搜索引擎 robots/服务条款;本 Skill 面向个人研究与自动化辅助,非批量抓取。
FILE:scripts/search_cn.py
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Multi-Search-CN:零 API Key,优先用 DuckDuckGo HTML 抓取可解析结果;
同时输出国内常用搜索引擎直达链接,便于人工或浏览器打开。
仅依赖 Python 3 标准库。
"""
from __future__ import annotations
import argparse
import html
import json
import re
import sys
import urllib.error
import urllib.parse
import urllib.request
from typing import Iterable
UA = (
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
)
def search_urls(query: str) -> dict[str, str]:
"""各引擎搜索页(UTF-8 编码 query)。"""
q = query.strip()
enc = urllib.parse.quote(q)
return {
"duckduckgo_html": f"https://html.duckduckgo.com/html/?{urllib.parse.urlencode({'q': q, 'kl': 'cn-zh'})}",
"bing_cn": f"https://cn.bing.com/search?q={enc}",
"baidu": f"https://www.baidu.com/s?wd={enc}",
"sogou": f"https://www.sogou.com/web?query={enc}",
"so_360": f"https://www.so.com/s?q={enc}",
}
def fetch(url: str, timeout: int = 25) -> str:
req = urllib.request.Request(
url,
headers={
"User-Agent": UA,
"Accept": "text/html,application/xhtml+xml;q=0.9,*/*;q=0.8",
"Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
},
method="GET",
)
with urllib.request.urlopen(req, timeout=timeout) as resp:
return resp.read().decode("utf-8", errors="replace")
def decode_ddg_redirect(href: str) -> str:
"""duckduckgo.com/l/?uddg= 真实 URL。"""
if href.startswith("//"):
href = "https:" + href
parsed = urllib.parse.urlparse(href)
qs = urllib.parse.parse_qs(parsed.query)
uddg = qs.get("uddg", [""])[0]
if uddg:
return urllib.parse.unquote(uddg)
return href
def parse_ddg_html(html_doc: str, limit: int) -> list[tuple[str, str]]:
"""解析 DDG HTML 版结果:标题 + 落地 URL。"""
blocks = re.findall(
r'<a[^>]*class="result__a"[^>]*href="([^"]+)"[^>]*>([^<]*)</a>',
html_doc,
flags=re.I,
)
out: list[tuple[str, str]] = []
for href, title in blocks:
title = html.unescape(re.sub(r"\s+", " ", title).strip())
real = decode_ddg_redirect(href)
if not real or real.startswith("https://duckduckgo.com"):
continue
out.append((title or real, real))
if len(out) >= limit:
break
return out
def run_ddg(query: str, limit: int) -> list[tuple[str, str]]:
"""抓取 DuckDuckGo HTML(cn-zh)。"""
q = urllib.parse.urlencode({"q": query.strip(), "kl": "cn-zh"})
url = f"https://html.duckduckgo.com/html/?{q}"
html = fetch(url)
return parse_ddg_html(html, limit=limit)
def main(argv: Iterable[str] | None = None) -> int:
p = argparse.ArgumentParser(description="Multi-Search-CN(国内检索聚合,零 API Key)")
p.add_argument("query", help="搜索关键词")
p.add_argument("--limit", type=int, default=8, help="DDG 解析条数上限(默认 8)")
p.add_argument(
"--urls-only",
action="store_true",
help="仅输出各搜索引擎直达链接,不发起网络请求",
)
p.add_argument("--json", action="store_true", help="JSON 输出(urls-only 与 DDG 结果)")
args = p.parse_args(list(argv) if argv is not None else None)
urls = search_urls(args.query)
if args.urls_only:
if args.json:
print(json.dumps({"engines": urls}, ensure_ascii=False, indent=2))
else:
print("# 各引擎搜索页(复制到浏览器)\n")
for name, u in urls.items():
print(f"- {name}: {u}")
return 0
rows: list[tuple[str, str]] = []
err: str | None = None
try:
rows = run_ddg(args.query, limit=args.limit)
except urllib.error.HTTPError as e:
err = f"HTTP {e.code}: {e.reason}"
except urllib.error.URLError as e:
err = f"网络错误: {e.reason}"
except Exception as e: # noqa: BLE001
err = f"未知错误: {e}"
if args.json:
payload = {
"query": args.query.strip(),
"engines": urls,
"ddg_results": [{"title": t, "url": u} for t, u in rows],
"error": err,
}
print(json.dumps(payload, ensure_ascii=False, indent=2))
return 0 if not err else 1
print(f"查询: {args.query.strip()}\n")
print("## DuckDuckGo HTML(cn-zh)解析结果\n")
if err:
print(f"(抓取失败: {err})\n")
elif not rows:
print("(无解析结果,可能被限流或页面结构变化。请改用下方直达链接。)\n")
else:
for i, (title, u) in enumerate(rows, 1):
print(f"{i}. {title}\n {u}\n")
print("## 国内常用搜索引擎直达\n")
for name, u in urls.items():
print(f"- {name}: {u}")
return 0
if __name__ == "__main__":
sys.exit(main())
使用 yt-dlp 从哔哩哔哩公开视频提取已有字幕或自动字幕(不下载整段视频)。当用户提到 B 站、bilibili、BV 号、视频字幕、拉字幕、做摘要、根据视频内容回答问题时使用。v1 仅支持平台已提供字幕轨道的视频;无字幕视频需换源或后续用 Whisper 等方案。
--- name: bilibili-subtitles description: 使用 yt-dlp 从哔哩哔哩公开视频提取已有字幕或自动字幕(不下载整段视频)。当用户提到 B 站、bilibili、BV 号、视频字幕、拉字幕、做摘要、根据视频内容回答问题时使用。v1 仅支持平台已提供字幕轨道的视频;无字幕视频需换源或后续用 Whisper 等方案。 --- # B 站字幕提取(yt-dlp) ## 依赖 - 已安装 **yt-dlp**(推荐):`brew install yt-dlp` - 保持较新版本:`brew upgrade yt-dlp`(B 站接口常变) ## 何时用本 Skill | 场景 | 是否适合 v1 | |------|----------------| | 视频有 UP 上传字幕或 B 站自动字幕 | ✅ | | 无任何字幕轨 | ❌(需换有字幕视频,或另做音频转写) | ## 标准流程 ### 1. 查看有哪些字幕 ```bash yt-dlp --list-subs "https://www.bilibili.com/video/BVxxxxxxxxxx/" ``` 记下语言代码(如 `zh-Hans`、`zh-CN`)。 ### 2. 只下载字幕(不下载视频) ```bash yt-dlp --write-subs --write-auto-subs --skip-download \ --sub-langs "zh-Hans,zh-CN,zh,zh-Hant,en" \ -o "bilibili_%(id)s.%(ext)s" \ "https://www.bilibili.com/video/BVxxxxxxxxxx/" ``` - `--write-auto-subs`:含 B 站自动生成的字幕(若有)。 - 输出多为 `.vtt` 或 `.srt`,与视频同目录或当前目录。 ### 3. 把 VTT 收成纯文本(便于喂给模型) ```bash # 简单去时间轴与标记(按需调整路径) sed -e '/^WEBVTT/d' -e '/^NOTE/d' -e '/^[0-9][0-9]:/d' -e '/^$/d' -e 's/<[^>]*>//g' \ "某文件.zh-Hans.vtt" | sed '/^$/d' > bilibili_subtitles_plain.txt ``` ## 若出现 HTTP 412 / 无法下载网页 B 站可能对匿名请求限流。按顺序尝试: 1. **用浏览器 Cookie(推荐)** ```bash yt-dlp --cookies-from-browser chrome --list-subs "URL" ``` 可将 `chrome` 换成 `safari`、`firefox`(本机需已登录 bilibili.com)。 2. **导出 Netscape 格式 cookies.txt**,再: `yt-dlp --cookies /path/to/cookies.txt ...` 3. **升级 yt-dlp** 后重试。 详见 [reference.md](reference.md)。 ## 对 Agent 的提示 - 先 `--list-subs`,无可用语言则明确告知用户「该 BV 无字幕轨」,不要假装已提取。 - 提取成功后,优先读 `.srt`/`.vtt` 再总结;长文本可先落盘再分段阅读。 - 勿在回复中粘贴完整 Cookie 或账号秘密。 ## ClawHub / OpenClaw 发布说明 本 Skill 为 **纯文档 + 系统命令**,无额外二进制。发布时在描述中写明:依赖 `yt-dlp`、可能需 Cookie、v1 不做无字幕转写。 FILE:reference.md # B 站字幕 — 参考与排错 ## 官方与工具 - [yt-dlp](https://github.com/yt-dlp/yt-dlp) — 提取器含 BiliBili。 - B 站接口与风控会变,**优先保持 yt-dlp 最新**。 ## 常见错误 ### HTTP 412 Precondition Failed - **原因**:匿名或异常请求被拦;部分网络环境更易触发。 - **处理**: 1. `yt-dlp -U` 或 `brew upgrade yt-dlp` 2. `--cookies-from-browser chrome`(或本机常用浏览器) 3. 使用从浏览器导出的 `cookies.txt` + `--cookies cookies.txt` 4. 换网络/VPN 再试(若政策允许) ### 没有字幕语言列出 - 该稿件可能确实无字幕;换有「CC」或 UP 注明字幕的稿件测试。 ### 只有繁体/英文 - 调整 `--sub-langs`,例如 `zh-Hant,zh-Hans,en`。 ## v2 可能扩展(未实现) - 无字幕时:下载音频 + 本地 Whisper / 已有 `openai-whisper` skill。 - 统一封装 CLI:`bili-subs BVxxx`(若单独开源可再挂 ClawHub)。 ## 合规 - 仅处理**用户有权访问**的公开或已授权内容;遵守平台条款与版权。 FILE:scripts/fetch-subs.sh #!/usr/bin/env bash # 用法: ./fetch-subs.sh <B站视频URL> [可选: cookies.txt路径] set -euo pipefail URL="?用法: $0 <bilibili-url> [cookies.txt]" COOKIES="-" if ! command -v yt-dlp &>/dev/null; then echo "请先安装: brew install yt-dlp" >&2 exit 1 fi ARGS=(--write-subs --write-auto-subs --skip-download) ARGS+=(--sub-langs "zh-Hans,zh-CN,zh,zh-Hant,en") ARGS+=(-o "bilibili_%(id)s.%(ext)s") if [[ -n "$COOKIES" ]]; then ARGS+=(--cookies "$COOKIES") else # 若本机已登录 B 站,可取消下面两行注释,改用浏览器 Cookie # ARGS+=(--cookies-from-browser chrome) : fi yt-dlp "ARGS[@]" "$URL"
通过 Chrome Debug 模式(CDP)读取当前页面的表单结构并自动填写。由用户显式调用(/auto-fill),不自动触发。用户负责导航和点击,Agent 负责识别字段、填写内容、截图确认。
--- name: auto-fill description: 通过 Chrome Debug 模式(CDP)读取当前页面的表单结构并自动填写。由用户显式调用(/auto-fill),不自动触发。用户负责导航和点击,Agent 负责识别字段、填写内容、截图确认。 --- # auto-fill 帮你填表。你来点击导航,我来识别字段和填写内容。 ## 使用方式 ``` /auto-fill 公司名: ACME, 邮箱: [email protected], 备注: 测试订单 ``` 数据格式自由,键值对 / 自然语言描述都行,我来匹配字段。 ## 重要:必须使用 playwright-cdp 工具集 所有浏览器操作必须使用 **`playwright-cdp` 的工具**(连接真实 Chrome),不要使用 `cursor-ide-browser` 的内置浏览器工具。 - ✅ 用:`playwright-cdp` 提供的 `browser_navigate`、`browser_snapshot`、`browser_fill` 等 - ❌ 禁止:`cursor-ide-browser` 的同名工具(沙盒浏览器,没有登录态) --- ## 工作流程 ### 第一步:检查 Chrome debug 是否在线 ```bash curl -s http://127.0.0.1:9222/json/version ``` - ✅ 有响应 → 继续 - ❌ 无响应 → **直接用 Shell 启动 Chrome**,不要让用户手动跑命令 **直接执行(后台启动):** ```bash nohup /Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome \ --remote-debugging-port=9222 \ --user-data-dir="/tmp/chrome_debug_profile" \ > /tmp/chrome_debug.log 2>&1 & ``` 等 2 秒后重新 curl 确认启动成功,再继续。 ### 第二步:导航到目标页面 根据用户的描述推断目标 URL,**直接 `browser_navigate` 过去,不要问用户要链接**。 - 「打开 Google」→ `https://www.google.com` - 「去 Wise 注册」→ `https://wise.com/register` - 「打开 Creem」→ `https://creem.io` - 模糊描述 → 用常识判断最合理的 URL,导航后截图确认 只有完全无法推断时,才问用户要链接。 ### 第三步:读取页面结构 ``` browser_snapshot ``` 获取无障碍树,识别所有可填写字段(input、textarea、select 等)。 ### 第四步:匹配字段 把用户提供的数据与页面字段对应: - 字段名/placeholder/label → 语义匹配,不要求精确 - **不确定的字段:列出来问用户,不要乱填** - 没有对应数据的字段:跳过,保持原值 ### 第五步:填写 使用 `browser_fill` 逐字段填入。 **规则:** - 密码类字段:填前确认 - 下拉框(select):用 `browser_select_option` - 文件上传:用 `browser_upload_file`,需用户确认路径 ### 第六步:截图确认 ``` browser_take_screenshot ``` 展示填写结果,明确告知:**「填完了,请你来点提交」**。 --- ## 边界规则 | 操作 | Agent 做 | 用户做 | |------|---------|--------| | 识别表单字段 | ✅ | | | 填写内容 | ✅ | | | 截图确认 | ✅ | | | 点击导航 / 翻页 | | ✅ | | 点击提交按钮 | 除非明确说「帮我提交」 | ✅ 默认 | | 处理弹窗 / 验证码 | | ✅ | --- ## 环境配置(首次) 如果 `~/.cursor/mcp.json` 里没有 `playwright-cdp` 配置,添加: ```json "playwright-cdp": { "command": "npx", "args": ["-y", "@playwright/mcp@latest", "--cdp-endpoint", "http://127.0.0.1:9222"] } ``` 添加后提示用户重载 MCP(Cursor 设置 → MCP → Reload)。
通过 Chrome Debug 模式(CDP)自动化操作真实浏览器——导航、点击、填表、提取数据、截图、执行多步流程。当用户说"帮我在网页上操作"、"打开浏览器"、"帮我点"、"帮我填"、"帮我抓取"、"帮我截图"、"auto-browser"时使用。
---
name: auto-browser
description: 通过 Chrome Debug 模式(CDP)自动化操作真实浏览器——导航、点击、填表、提取数据、截图、执行多步流程。当用户说"帮我在网页上操作"、"打开浏览器"、"帮我点"、"帮我填"、"帮我抓取"、"帮我截图"、"auto-browser"时使用。
---
# auto-browser
你的浏览器遥控器。导航、点击、填表、抓数据、截图——说一句话就行。
## 使用方式
```
/auto-browser 打开 GitHub 看看我的 notifications
/auto-browser 去 Amazon 搜索 mechanical keyboard 截图前三个结果
/auto-browser 登录后台,把订单列表导出来
/auto-browser 帮我在这个页面点「下一步」然后填写地址表单
```
自然语言描述意图即可,不需要写代码。
## 必须使用 playwright-cdp 工具集
所有浏览器操作使用 **`user-playwright-cdp`** 的工具(连接真实 Chrome,保留登录态)。
- ✅ 用:`user-playwright-cdp` 的 `browser_navigate`、`browser_snapshot`、`browser_click` 等
- ❌ 禁止:`cursor-ide-browser` 的同名工具(沙盒浏览器,无登录态)
---
## 核心工作流
### 0. 确保 Chrome Debug 在线
```bash
curl -s http://127.0.0.1:9222/json/version
```
- ✅ 有响应 → 继续
- ❌ 无响应 → 直接启动,不问用户:
```bash
nohup /Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome \
--remote-debugging-port=9222 \
--user-data-dir="/tmp/chrome_debug_profile" \
> /tmp/chrome_debug.log 2>&1 &
```
等 2 秒后重新 curl 确认。
### 1. 感知:snapshot 先行
**每次操作前**都先 `browser_snapshot`,获取页面无障碍树。这是你的「眼睛」。
- 用 snapshot 中的 `ref` 来定位元素
- 不确定页面状态时,先 snapshot 再决定下一步
### 2. 行动:选择合适的操作
根据用户意图选择操作,可自由组合:
| 意图 | 工具 | 说明 |
|------|------|------|
| 打开网页 | `browser_navigate` | 推断 URL,直接导航 |
| 后退 | `browser_navigate_back` | |
| 点击按钮/链接 | `browser_click` | 用 snapshot 的 ref |
| 填写输入框 | `browser_type`(追加)/ `browser_fill_form`(清空后填) | |
| 选择下拉框 | `browser_select_option` | |
| 上传文件 | `browser_file_upload` | 需绝对路径 |
| 按键 | `browser_press_key` | Enter、Escape、Tab 等 |
| 悬停 | `browser_hover` | 展开菜单、tooltip |
| 拖拽 | `browser_drag` | startRef → endRef |
| 处理弹窗 | `browser_handle_dialog` | alert/confirm/prompt |
| 等待加载 | `browser_wait_for` | 等时间或等文本出现/消失 |
| 执行 JS | `browser_evaluate` | 页面没有暴露 UI 时的后备手段 |
| 管理标签页 | `browser_tabs` | list/new/close/select |
| 调整窗口 | `browser_resize` | 测试响应式布局 |
### 3. 确认:截图反馈
操作完成后 `browser_take_screenshot` 截图给用户确认结果。
---
## 操作原则
### URL 推断
根据用户描述直接推断 URL 并导航,不要反问:
- 「打开 Google」→ `https://www.google.com`
- 「去 GitHub」→ `https://github.com`
- 「看看 V2EX」→ `https://www.v2ex.com`
- 模糊描述 → 用常识判断,导航后截图确认
只有完全无法推断时才问。
### 多步操作
复杂任务拆成步骤,每步遵循:**snapshot → 操作 → 等待 → snapshot 确认**。
```
示例:「登录后台导出订单」
1. navigate 到登录页 → snapshot
2. 如果已登录跳过,否则填写账号密码 → click 登录
3. wait_for 页面加载 → snapshot
4. click 导航到订单页 → snapshot
5. click 导出按钮 → 截图确认
```
### 等待策略
页面变化后(导航、点击、提交),用短间隔等待 + snapshot 确认:
```
browser_wait_for time=2 → snapshot → 检查是否就绪
→ 没好?再 wait_for time=2 → snapshot
```
不要一次等太久。2-3 秒一轮,最多重试 3 次。
### 数据提取
需要从页面抓取数据时:
1. `browser_snapshot` 获取页面结构
2. 从无障碍树中提取所需信息
3. 信息不够时用 `browser_evaluate` 执行 JS 提取
4. 结果太长写入文件,回复给摘要 + 路径
### 表单填写
填表场景遵循 auto-fill 的规则:
- 语义匹配字段,不要求精确
- 不确定的字段列出来问用户
- 密码类字段填前确认
- 多字段优先用 `browser_fill_form` 批量填写
- 填完截图确认
---
## 安全边界
| 操作 | Agent 直接做 | 需用户确认 |
|------|-------------|-----------|
| 导航、浏览、截图 | ✅ | |
| 点击普通按钮/链接 | ✅ | |
| 填写非敏感字段 | ✅ | |
| 读取/提取页面数据 | ✅ | |
| 填写密码 | | ✅ |
| 点击「提交」「付款」「删除」 | | ✅(除非用户明确说"帮我提交") |
| 发送消息/邮件 | | ✅ |
| 关闭标签页 | | ✅(除非用户要求) |
**原则:只读操作自由做,写入/不可逆操作先确认。**
---
## 错误处理
| 问题 | 处理 |
|------|------|
| 元素找不到 | 重新 snapshot,ref 可能变了 |
| 页面加载慢 | `browser_wait_for` 等待,不要盲目重试 |
| 弹窗阻断 | `browser_handle_dialog` 处理 |
| 需要登录 | 告诉用户,等用户手动登录后继续 |
| 验证码/人机验证 | 截图告知用户,等用户处理 |
| JS 报错 | `browser_console_messages` 查看错误日志 |
---
## 环境配置(首次)
如果 `~/.cursor/mcp.json` 里没有 `playwright-cdp` 配置,添加:
```json
"playwright-cdp": {
"command": "npx",
"args": ["-y", "@playwright/mcp@latest", "--cdp-endpoint", "http://127.0.0.1:9222"]
}
```
添加后提示用户重载 MCP(Cursor 设置 → MCP → Reload)。
Create PowerPoint presentations (PPTX) using Python and python-pptx. Handles timelines, charts, diagrams, slide layouts, custom colors, shapes, connectors, t...
---
name: create-pptx
description: >
Create PowerPoint presentations (PPTX) using Python and python-pptx. Handles
timelines, charts, diagrams, slide layouts, custom colors, shapes, connectors,
text formatting, and slide transitions. Knows WPS compatibility pitfalls.
Use when the user asks to: generate a PPT/PPTX file, create a presentation,
make a slide deck, draw a timeline, visualize data as slides, or export
something to PowerPoint. Also use when the user says "做成 PPT"、"生成幻灯片"、
"做个演示文稿"、"做个 pptx".
---
# Create PowerPoint (python-pptx)
## Setup
```bash
pip install python-pptx # or: uv pip install python-pptx
```
Output scripts to a logical location (e.g., `前端开发/demo/` or project folder),
then run with `python3 <script.py>` and open the result.
## Core helpers
Read and import `scripts/pptx_helpers.py` for ready-made drawing primitives:
background, horizontal/vertical lines, textboxes, ovals, diagonal connectors,
and fade transitions. Copy or `import` as needed.
Key units: **EMU** (English Metric Units). 1 pt = 12700 EMU, 1 cm ≈ 360000 EMU.
Standard 16:9 slide = 12192000 × 6858000 EMU.
## Workflow
1. **Understand the content** — milestones, categories, colors, # slides
2. **Plan layout** — compute X/Y positions in EMU up front; avoid magic numbers
3. **Build shapes** — use helpers or `slide.shapes.add_shape/add_textbox`
4. **Add transitions** — always call `add_fade_transition(slide)` from helpers
5. **Run and open** — `python3 script.py && open output.pptx`
### Multi-slide instead of click animations (WPS-safe default)
WPS does not reliably support click-triggered PowerPoint animations.
**Always use multiple slides** to reveal content progressively:
```
Slide 1 → skeleton / structure only
Slide 2 → skeleton + first data layer
Slide 3 → skeleton + all data layers
```
Add a fade transition (`add_fade_transition`) to each slide for smooth switching.
If the user explicitly asks for animations AND they are using Microsoft PowerPoint
(not WPS), you may attempt XML-based animations — but read `references/wps-compat.md`
first for the XML structure and known pitfalls.
## Common patterns
### Colors & theme
Define all colors as `RGBColor` constants at the top. Dark backgrounds look
premium — use near-black (`0x06, 0x0D, 0x1E`) with bright accents.
### Timeline layout
```python
TL_L, TL_R = 850000, 11950000 # left/right margins (EMU)
TL_W = TL_R - TL_L
M_STEP = TL_W // 11 # 12 months → 11 intervals
def month_x(m): # 1-based month → EMU x-position
return TL_L + M_STEP * (m - 1)
```
### Collision resolution
When multiple cards share the same or nearby X position, spread them:
```python
def resolve_collisions(events, card_w, gap):
events.sort(key=lambda e: e['cx'])
need = card_w + gap
for _ in range(120):
moved = False
for i in range(len(events) - 1):
a, b = events[i], events[i+1]
if b['cx'] - a['cx'] < need:
push = (need - (b['cx'] - a['cx'])) / 2
a['cx'] -= push; b['cx'] += push; moved = True
if not moved:
break
```
### Shape IDs for animation
`python-pptx` assigns shape IDs automatically. To retrieve them after creation:
```python
shp = slide.shapes.add_shape(...)
shape_id = shp.shape_id # use this in animation XML
```
For connectors added via raw XML, read back the max existing ID first:
```python
def _max_existing_id(slide):
return max((int(el.get('id')) for el in slide.element.iter()
if el.get('id') and el.get('id').isdigit()), default=1)
```
## Template assets
Ready-to-use `.pptx` base files in `assets/`. Use them as the starting
`Presentation()` object to inherit their design/theme:
```python
from pptx import Presentation
prs = Presentation('/Users/scott/.cursor/skills/create-pptx/assets/business-dark.pptx')
```
| File | Style | Source |
|------|-------|--------|
| `assets/business-dark.pptx` | 深色商务 · Pitch Deck 风格 · 60 slides | Slidesgo "Product Vision Pitch Deck" (Attribution required) |
| `assets/education.pptx` | 明亮教育 · 笔记本课程风格 · 多 slides | Slidesgo "Notebook Lesson XL" (Attribution required) |
> **Attribution**: Free Slidesgo templates require keeping the attribution slide.
> When using these files, do NOT delete the last "Credits" slide.
## Reference files
Read the relevant file based on the task:
- **`references/pptx-patterns.md`** — EMU 单位速查、预设形状 ID、连接器 XML、
过渡 XML、多段落文字框、典型脚本结构
- **`references/charts.md`** — python-pptx 原生图表 API:柱状、折线、饼图、散点、
多系列、样式设置(当用户需要数据图表时读此文件)
- **`references/standard-slides.md`** — 标准商务幻灯片函数库:标题页、目录页、
要点页、图文并排、数据页、章节分隔页、结尾页(当用户需要完整 PPT 结构时读此文件)
- **`references/wps-compat.md`** — WPS 动画兼容性踩坑记录(当用户提到 WPS 或
动画效果异常时读此文件)
FILE:scripts/pptx_helpers.py
"""
pptx_helpers.py — Reusable drawing primitives for python-pptx scripts.
Copy this file next to your generation script, then:
from pptx_helpers import bg_rect, h_line, v_line, textbox, oval_shape, \
diag_connector, add_fade_transition
All position/size arguments are in EMU (English Metric Units).
1 pt = 12700 EMU
1 cm ≈ 360000 EMU
16:9 = 12192000 × 6858000 EMU
"""
from pptx.util import Pt, Emu
from pptx.dml.color import RGBColor
from pptx.enum.text import PP_ALIGN
from pptx.oxml import parse_xml
from pptx.oxml.ns import qn
# ── Internal helpers ─────────────────────────────────────────────────────────
def _max_existing_id(slide) -> int:
"""Return the highest shape id currently on the slide."""
return max(
(int(el.get('id')) for el in slide.element.iter()
if el.get('id') and el.get('id').isdigit()),
default=1,
)
# ── Background ────────────────────────────────────────────────────────────────
def bg_rect(slide, color: RGBColor, sw: int, sh: int):
"""Fill the entire slide with a solid color."""
shp = slide.shapes.add_shape(1, Emu(0), Emu(0), Emu(sw), Emu(sh))
shp.fill.solid()
shp.fill.fore_color.rgb = color
shp.line.fill.background()
return shp
# ── Lines ─────────────────────────────────────────────────────────────────────
def h_line(slide, x1: int, y: int, x2: int, color: RGBColor, width_pt: float = 1.5):
"""Draw a horizontal line as a thin filled rectangle."""
h = max(int(width_pt * 12700), 6000)
shp = slide.shapes.add_shape(1, Emu(x1), Emu(y - h // 2), Emu(x2 - x1), Emu(h))
shp.fill.solid()
shp.fill.fore_color.rgb = color
shp.line.fill.background()
return shp
def v_line(slide, x: int, y1: int, y2: int, color: RGBColor, width_pt: float = 0.8):
"""Draw a vertical line as a thin filled rectangle."""
w = max(int(width_pt * 12700), 4000)
shp = slide.shapes.add_shape(1, Emu(x - w // 2), Emu(y1), Emu(w), Emu(y2 - y1))
shp.fill.solid()
shp.fill.fore_color.rgb = color
shp.line.fill.background()
return shp
def diag_connector(slide, x1: int, y1: int, x2: int, y2: int,
color_rgb: tuple, width_pt: float = 0.6) -> int:
"""
Add a diagonal connector line using raw XML.
color_rgb: (r, g, b) integers 0-255.
Returns the shape id of the connector.
"""
cid = _max_existing_id(slide) + 1
r, g, b = color_rgb
dx, dy = abs(x2 - x1) or 1, abs(y2 - y1) or 1
lx, ly = min(x1, x2), min(y1, y2)
flip_h = 'flipH="1"' if (x2 < x1) != (y2 < y1) else ''
xml = (
f'<p:cxnSp xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main"'
f' xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"'
f' xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">'
f'<p:nvCxnSpPr>'
f'<p:cNvPr id="{cid}" name="conn{cid}"/>'
f'<p:cNvCxnSpPr/><p:nvPr/>'
f'</p:nvCxnSpPr>'
f'<p:spPr>'
f'<a:xfrm {flip_h}><a:off x="{lx}" y="{ly}"/><a:ext cx="{dx}" cy="{dy}"/></a:xfrm>'
f'<a:prstGeom prst="line"><a:avLst/></a:prstGeom>'
f'<a:ln w="{max(int(width_pt * 12700), 6350)}">'
f'<a:solidFill><a:srgbClr val="{r:02X}{g:02X}{b:02X}"/></a:solidFill>'
f'<a:round/></a:ln>'
f'</p:spPr>'
f'</p:cxnSp>'
)
slide.shapes._spTree.append(parse_xml(xml.encode()))
return cid
# ── Text ──────────────────────────────────────────────────────────────────────
def textbox(slide, x: int, y: int, w: int, h: int,
text: str, size: float, color: RGBColor,
bold: bool = False, align=PP_ALIGN.LEFT):
"""Add a non-wrapping textbox."""
txb = slide.shapes.add_textbox(Emu(x), Emu(y), Emu(w), Emu(h))
tf = txb.text_frame
tf.word_wrap = False
p = tf.paragraphs[0]
p.alignment = align
run = p.add_run()
run.text = text
run.font.size = Pt(size)
run.font.color.rgb = color
run.font.bold = bold
return txb
# ── Shapes ────────────────────────────────────────────────────────────────────
def oval_shape(slide, cx: int, cy: int, size: int, color: RGBColor) -> int:
"""Add a filled circle centered at (cx, cy). Returns shape_id."""
shp = slide.shapes.add_shape(9, Emu(cx - size // 2), Emu(cy - size // 2),
Emu(size), Emu(size))
shp.fill.solid()
shp.fill.fore_color.rgb = color
shp.line.fill.background()
return shp.shape_id
def rounded_rect(slide, x: int, y: int, w: int, h: int,
fill: RGBColor, border: RGBColor,
border_pt: float = 1.0, radius_emu: int = 60000):
"""Add a rounded rectangle. Returns the shape."""
shp = slide.shapes.add_shape(5, Emu(x), Emu(y), Emu(w), Emu(h)) # 5 = roundRect
shp.fill.solid()
shp.fill.fore_color.rgb = fill
shp.line.color.rgb = border
shp.line.width = Pt(border_pt)
# Adjust corner radius via XML
prstGeom = shp.element.spPr.find(qn('a:prstGeom'))
if prstGeom is not None:
avLst = prstGeom.find(qn('a:avLst'))
if avLst is not None:
# radius as percentage of shorter dimension; cap at 50000 (50%)
gd = parse_xml(
f'<a:gd xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"'
f' name="adj" fmla="val {radius_emu}"/>'
)
avLst.append(gd)
return shp
# ── Transitions ───────────────────────────────────────────────────────────────
def add_fade_transition(slide, spd: str = 'med'):
"""
Inject a cross-fade slide transition.
Works in both Microsoft PowerPoint and WPS.
spd: 'slow' | 'med' | 'fast'
"""
PNS = 'xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main"'
ANS = 'xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"'
xml = (
f'<p:transition {PNS} {ANS} spd="{spd}" advClick="1">'
f'<p:fade/>'
f'</p:transition>'
)
elem = parse_xml(xml.encode())
sld = slide.element
old = sld.find(qn('p:transition'))
if old is not None:
sld.remove(old)
cSld = sld.find(qn('p:cSld'))
cSld.addnext(elem)
FILE:agents/openai.yaml
interface:
display_name: "Create PowerPoint"
short_description: "Generate PPTX presentations with python-pptx"
default_prompt: "帮我创建一个 PowerPoint 演示文稿"
FILE:references/pptx-patterns.md
# PPTX 常用模式参考
## Table of Contents
- [EMU 单位速查](#emu)
- [预设形状 ID](#shapes)
- [对角线连接器 XML](#connector)
- [淡入过渡 XML](#transition)
- [文字框多段落](#textbox)
- [典型脚本结构](#template)
---
## EMU 单位速查 {#emu}
| 描述 | EMU |
|------|-----|
| 1 磅 (pt) | 12700 |
| 1 英寸 | 914400 |
| 1 厘米 | 360000 |
| 16:9 幻灯片宽度 | 12192000 |
| 16:9 幻灯片高度 | 6858000 |
| 4:3 幻灯片宽度 | 9144000 |
| 4:3 幻灯片高度 | 6858000 |
```python
from pptx.util import Pt, Emu, Inches, Cm
Pt(12) # 12磅
Inches(1) # 1英寸
Cm(2.5) # 2.5厘米
Emu(914400) # 直接指定 EMU
```
---
## 预设形状 ID {#shapes}
`slide.shapes.add_shape(shape_type_id, left, top, width, height)`
| ID | 形状 |
|----|------|
| 1 | 矩形 (rect) |
| 5 | 圆角矩形 (roundRect) |
| 9 | 椭圆 / 圆形 (ellipse) |
| 13 | 三角形 (triangle) |
| 17 | 六边形 (hexagon) |
| 24 | 右箭头 (rightArrow) |
完整列表见 `pptx.enum.shapes.MSO_SHAPE_TYPE`。
---
## 对角线连接器 XML {#connector}
通过 `pptx_helpers.diag_connector()` 生成,内部结构:
```xml
<p:cxnSp xmlns:p="..." xmlns:a="..." xmlns:r="...">
<p:nvCxnSpPr>
<p:cNvPr id="{id}" name="conn{id}"/>
<p:cNvCxnSpPr/><p:nvPr/>
</p:nvCxnSpPr>
<p:spPr>
<a:xfrm [flipH="1"]>
<a:off x="{left_emu}" y="{top_emu}"/>
<a:ext cx="{width_emu}" cy="{height_emu}"/>
</a:xfrm>
<a:prstGeom prst="line"><a:avLst/></a:prstGeom>
<a:ln w="{width_emu}">
<a:solidFill><a:srgbClr val="{RRGGBB}"/></a:solidFill>
<a:round/>
</a:ln>
</p:spPr>
</p:cxnSp>
```
`flipH="1"` 规则:`(x2 < x1) XOR (y2 < y1)` 为真时加入,控制连线斜向。
---
## 淡入过渡 XML {#transition}
```xml
<p:transition xmlns:p="..." xmlns:a="..." spd="med" advClick="1">
<p:fade/>
</p:transition>
```
- `spd`:`slow` | `med` | `fast`
- `advClick="1"`:点击前进,`advTm="3000"` 可自动前进(毫秒)
- 插入位置:必须在 `<p:cSld>` 之后,用 `cSld.addnext(elem)` 插入
其他过渡效果:`<p:wipe dir="l"/>` 左划、`<p:push dir="l"/>` 推入、`<p:zoom/>` 缩放。
---
## 文字框多段落 {#textbox}
```python
from pptx.util import Pt, Emu
from pptx.enum.text import PP_ALIGN
shp = slide.shapes.add_shape(1, left, top, width, height)
tf = shp.text_frame
tf.word_wrap = True
tf.margin_left = tf.margin_right = Emu(55000)
tf.margin_top = tf.margin_bottom = Emu(28000)
for i, line in enumerate(text.split('\n')):
p = tf.paragraphs[0] if i == 0 else tf.add_paragraph()
p.alignment = PP_ALIGN.CENTER
p.space_before = p.space_after = Pt(0)
run = p.add_run()
run.text = line
run.font.size = Pt(10)
run.font.color.rgb = RGBColor(0xFF, 0xFF, 0xFF)
run.font.bold = True
```
---
## 典型脚本结构 {#template}
```python
#!/usr/bin/env python3
from pptx import Presentation
from pptx.util import Emu
from pptx.dml.color import RGBColor
from pptx_helpers import bg_rect, h_line, textbox, add_fade_transition
SW, SH = 12192000, 6858000 # 16:9
prs = Presentation()
prs.slide_width = Emu(SW)
prs.slide_height = Emu(SH)
layout = prs.slide_layouts[6] # blank(无占位符)
def draw_skeleton(slide):
bg_rect(slide, RGBColor(0x06,0x0D,0x1E), SW, SH)
# ... 骨架元素
def draw_layer_a(slide):
pass # 第一组数据
def draw_layer_b(slide):
pass # 第二组数据
# 多幻灯片渐进展示(WPS 兼容)
for show_a, show_b in [(False, False), (True, False), (True, True)]:
s = prs.slides.add_slide(layout)
draw_skeleton(s)
if show_a: draw_layer_a(s)
if show_b: draw_layer_b(s)
add_fade_transition(s)
prs.save('output.pptx')
print('✓ Saved: output.pptx')
```
FILE:references/standard-slides.md
# 标准商务幻灯片模板
## Table of Contents
- [整体结构](#structure)
- [标题页](#title-slide)
- [目录页](#agenda)
- [内容页(文字+要点)](#content)
- [内容页(图文并排)](#split)
- [数据页(图表+说明)](#data)
- [过渡页(章节分隔)](#section)
- [结尾页](#end)
- [通用设计规范](#design)
---
## 整体结构 {#structure}
标准商务 PPT 通常包含:
```
1. 标题页 — 主题、副标题、日期/公司
2. 目录页 — 章节概览
3. 过渡页 — 章节分隔(可选)
4. 内容页 × N — 文字要点 / 图文 / 数据图表
5. 结尾页 — Thank You / 联系方式 / Q&A
```
---
## 标题页 {#title-slide}
```python
def title_slide(prs, title, subtitle, date='', company=''):
"""深色渐变背景的封面页。"""
SW, SH = int(prs.slide_width), int(prs.slide_height)
slide = prs.slides.add_slide(prs.slide_layouts[6])
# 背景
bg = slide.shapes.add_shape(1, Emu(0), Emu(0), Emu(SW), Emu(SH))
bg.fill.solid()
bg.fill.fore_color.rgb = RGBColor(0x0A, 0x14, 0x2E)
bg.line.fill.background()
# 左侧强调色竖条
bar = slide.shapes.add_shape(1, Emu(0), Emu(0), Emu(80000), Emu(SH))
bar.fill.solid()
bar.fill.fore_color.rgb = RGBColor(0x00, 0x7A, 0xFF)
bar.line.fill.background()
# 主标题
txb = slide.shapes.add_textbox(Emu(300000), Emu(SH//2 - 700000),
Emu(SW - 500000), Emu(600000))
tf = txb.text_frame
p = tf.paragraphs[0]
run = p.add_run()
run.text = title
run.font.size = Pt(36)
run.font.bold = True
run.font.color.rgb = RGBColor(0xFF, 0xFF, 0xFF)
# 副标题
txb2 = slide.shapes.add_textbox(Emu(300000), Emu(SH//2 - 50000),
Emu(SW - 500000), Emu(400000))
tf2 = txb2.text_frame
p2 = tf2.paragraphs[0]
run2 = p2.add_run()
run2.text = subtitle
run2.font.size = Pt(18)
run2.font.color.rgb = RGBColor(0x88, 0xBB, 0xFF)
# 日期 / 公司(右下角)
if date or company:
info = f'{company} {date}'.strip()
txb3 = slide.shapes.add_textbox(Emu(SW - 2500000), Emu(SH - 400000),
Emu(2300000), Emu(280000))
tf3 = txb3.text_frame
p3 = tf3.paragraphs[0]
from pptx.enum.text import PP_ALIGN
p3.alignment = PP_ALIGN.RIGHT
run3 = p3.add_run()
run3.text = info
run3.font.size = Pt(10)
run3.font.color.rgb = RGBColor(0x66, 0x88, 0xAA)
add_fade_transition(slide)
return slide
```
---
## 目录页 {#agenda}
```python
def agenda_slide(prs, sections: list[str], highlight_index: int = -1):
"""
目录页,sections 为章节标题列表。
highlight_index:当前章节(-1 = 无高亮,用于总目录)。
"""
SW, SH = int(prs.slide_width), int(prs.slide_height)
slide = prs.slides.add_slide(prs.slide_layouts[6])
# 背景
bg = slide.shapes.add_shape(1, Emu(0), Emu(0), Emu(SW), Emu(SH))
bg.fill.solid()
bg.fill.fore_color.rgb = RGBColor(0x0A, 0x14, 0x2E)
bg.line.fill.background()
# 页面标题
txb = slide.shapes.add_textbox(Emu(500000), Emu(300000),
Emu(SW - 1000000), Emu(500000))
tf = txb.text_frame
run = tf.paragraphs[0].add_run()
run.text = 'AGENDA'
run.font.size = Pt(28)
run.font.bold = True
run.font.color.rgb = RGBColor(0xFF, 0xFF, 0xFF)
# 分割线
sep = slide.shapes.add_shape(1, Emu(500000), Emu(880000),
Emu(SW - 1000000), Emu(18000))
sep.fill.solid()
sep.fill.fore_color.rgb = RGBColor(0x00, 0x7A, 0xFF)
sep.line.fill.background()
# 章节列表
item_h = 600000
start_y = 1100000
for i, sec in enumerate(sections):
is_active = (i == highlight_index)
y = start_y + i * item_h
# 序号圆圈
dot_clr = RGBColor(0x00,0x7A,0xFF) if is_active else RGBColor(0x33,0x44,0x55)
dot = slide.shapes.add_shape(9, Emu(500000), Emu(y + 50000),
Emu(280000), Emu(280000))
dot.fill.solid()
dot.fill.fore_color.rgb = dot_clr
dot.line.fill.background()
# 序号文字
num_txb = slide.shapes.add_textbox(Emu(500000), Emu(y + 30000),
Emu(280000), Emu(320000))
from pptx.enum.text import PP_ALIGN
num_p = num_txb.text_frame.paragraphs[0]
num_p.alignment = PP_ALIGN.CENTER
num_run = num_p.add_run()
num_run.text = str(i + 1)
num_run.font.size = Pt(11)
num_run.font.bold = True
num_run.font.color.rgb = RGBColor(0xFF, 0xFF, 0xFF)
# 章节标题
txt_clr = RGBColor(0xFF,0xFF,0xFF) if is_active else RGBColor(0x88,0x99,0xAA)
txt_txb = slide.shapes.add_textbox(Emu(850000), Emu(y + 20000),
Emu(SW - 1400000), Emu(350000))
txt_run = txt_txb.text_frame.paragraphs[0].add_run()
txt_run.text = sec
txt_run.font.size = Pt(14 if is_active else 13)
txt_run.font.bold = is_active
txt_run.font.color.rgb = txt_clr
add_fade_transition(slide)
return slide
```
---
## 内容页(文字+要点) {#content}
```python
def bullet_slide(prs, title: str, bullets: list[str],
subtitle: str = '', accent_color=None):
"""
标准要点页。bullets 支持两级缩进:以 ' ' 开头为二级要点。
"""
from pptx.enum.text import PP_ALIGN
SW, SH = int(prs.slide_width), int(prs.slide_height)
accent = accent_color or RGBColor(0x00, 0x7A, 0xFF)
slide = prs.slides.add_slide(prs.slide_layouts[6])
# 背景
bg = slide.shapes.add_shape(1, Emu(0), Emu(0), Emu(SW), Emu(SH))
bg.fill.solid()
bg.fill.fore_color.rgb = RGBColor(0x0A, 0x14, 0x2E)
bg.line.fill.background()
# 顶部色条
top_bar = slide.shapes.add_shape(1, Emu(0), Emu(0), Emu(SW), Emu(120000))
top_bar.fill.solid()
top_bar.fill.fore_color.rgb = accent
top_bar.line.fill.background()
# 页面标题
t_txb = slide.shapes.add_textbox(Emu(400000), Emu(150000),
Emu(SW - 800000), Emu(480000))
t_run = t_txb.text_frame.paragraphs[0].add_run()
t_run.text = title
t_run.font.size = Pt(24)
t_run.font.bold = True
t_run.font.color.rgb = RGBColor(0xFF, 0xFF, 0xFF)
# 副标题(可选)
content_top = 820000
if subtitle:
s_txb = slide.shapes.add_textbox(Emu(400000), Emu(680000),
Emu(SW - 800000), Emu(300000))
s_run = s_txb.text_frame.paragraphs[0].add_run()
s_run.text = subtitle
s_run.font.size = Pt(12)
s_run.font.color.rgb = RGBColor(0x88, 0x99, 0xAA)
content_top = 1050000
# 要点列表
item_h = 550000
for i, bullet in enumerate(bullets):
is_sub = bullet.startswith(' ')
text = bullet.strip()
y = content_top + i * item_h
indent = 800000 if is_sub else 400000
# 项目符号
if not is_sub:
dot = slide.shapes.add_shape(9, Emu(indent - 200000), Emu(y + 130000),
Emu(120000), Emu(120000))
dot.fill.solid()
dot.fill.fore_color.rgb = accent
dot.line.fill.background()
# 文字
b_txb = slide.shapes.add_textbox(Emu(indent + 50000), Emu(y),
Emu(SW - indent - 500000), Emu(item_h - 50000))
b_tf = b_txb.text_frame
b_tf.word_wrap = True
b_run = b_tf.paragraphs[0].add_run()
b_run.text = text
b_run.font.size = Pt(11 if is_sub else 13)
b_run.font.color.rgb = (RGBColor(0x88,0x99,0xAA) if is_sub
else RGBColor(0xDD, 0xEE, 0xFF))
add_fade_transition(slide)
return slide
```
---
## 内容页(图文并排) {#split}
```python
def split_slide(prs, title: str, left_content_fn, right_content_fn):
"""
左右分栏页。left_content_fn(slide, area) 和 right_content_fn(slide, area)
接收 slide 和区域 dict {x, y, w, h}(EMU),负责在该区域内绘制内容。
"""
SW, SH = int(prs.slide_width), int(prs.slide_height)
slide = prs.slides.add_slide(prs.slide_layouts[6])
# 背景
bg = slide.shapes.add_shape(1, Emu(0), Emu(0), Emu(SW), Emu(SH))
bg.fill.solid()
bg.fill.fore_color.rgb = RGBColor(0x0A, 0x14, 0x2E)
bg.line.fill.background()
# 标题
t_txb = slide.shapes.add_textbox(Emu(400000), Emu(150000),
Emu(SW - 800000), Emu(480000))
t_run = t_txb.text_frame.paragraphs[0].add_run()
t_run.text = title
t_run.font.size = Pt(22)
t_run.font.bold = True
t_run.font.color.rgb = RGBColor(0xFF, 0xFF, 0xFF)
# 分割线
mid_x = SW // 2
div = slide.shapes.add_shape(1, Emu(mid_x - 9000), Emu(780000),
Emu(18000), Emu(SH - 1000000))
div.fill.solid()
div.fill.fore_color.rgb = RGBColor(0x22, 0x33, 0x55)
div.line.fill.background()
# 回调绘制左右内容
padding = 200000
left_area = {'x': padding, 'y': 820000,
'w': mid_x - padding * 2, 'h': SH - 1000000}
right_area = {'x': mid_x + padding, 'y': 820000,
'w': mid_x - padding * 2, 'h': SH - 1000000}
left_content_fn(slide, left_area)
right_content_fn(slide, right_area)
add_fade_transition(slide)
return slide
```
---
## 数据页(图表+说明) {#data}
```python
def chart_slide(prs, title: str, chart_type, chart_data,
insight: str = '', notes: list[str] = None):
"""图表页:左侧大图表,右侧关键洞察。"""
from pptx.util import Inches
SW, SH = int(prs.slide_width), int(prs.slide_height)
slide = prs.slides.add_slide(prs.slide_layouts[6])
# 背景
bg = slide.shapes.add_shape(1, Emu(0), Emu(0), Emu(SW), Emu(SH))
bg.fill.solid()
bg.fill.fore_color.rgb = RGBColor(0x0A, 0x14, 0x2E)
bg.line.fill.background()
# 标题
t_txb = slide.shapes.add_textbox(Emu(400000), Emu(150000),
Emu(SW - 800000), Emu(480000))
t_run = t_txb.text_frame.paragraphs[0].add_run()
t_run.text = title
t_run.font.size = Pt(22)
t_run.font.bold = True
t_run.font.color.rgb = RGBColor(0xFF, 0xFF, 0xFF)
# 图表(占左侧 65%)
chart_w = int(SW * 0.62)
gf = slide.shapes.add_chart(
chart_type,
Emu(300000), Emu(820000),
Emu(chart_w), Emu(SH - 1100000),
chart_data,
)
ch = gf.chart
ch.plot_area.format.fill.background()
ch.chart_area.format.fill.background()
if ch.has_legend:
ch.legend.font.size = Pt(9)
# 右侧洞察文字
right_x = chart_w + 500000
right_w = SW - right_x - 200000
if insight:
ins_txb = slide.shapes.add_textbox(Emu(right_x), Emu(900000),
Emu(right_w), Emu(500000))
ins_tf = ins_txb.text_frame
ins_tf.word_wrap = True
ins_run = ins_tf.paragraphs[0].add_run()
ins_run.text = insight
ins_run.font.size = Pt(13)
ins_run.font.bold = True
ins_run.font.color.rgb = RGBColor(0xFF, 0xFF, 0xFF)
if notes:
for i, note in enumerate(notes):
ny = 1500000 + i * 500000
n_txb = slide.shapes.add_textbox(Emu(right_x), Emu(ny),
Emu(right_w), Emu(420000))
n_tf = n_txb.text_frame
n_tf.word_wrap = True
n_run = n_tf.paragraphs[0].add_run()
n_run.text = f'• {note}'
n_run.font.size = Pt(11)
n_run.font.color.rgb = RGBColor(0xAA, 0xBB, 0xCC)
add_fade_transition(slide)
return slide
```
---
## 过渡页(章节分隔) {#section}
```python
def section_slide(prs, section_num: int, section_title: str,
accent_color=None):
"""章节分隔页,大号数字 + 标题,视觉节奏感强。"""
SW, SH = int(prs.slide_width), int(prs.slide_height)
accent = accent_color or RGBColor(0x00, 0x7A, 0xFF)
slide = prs.slides.add_slide(prs.slide_layouts[6])
# 背景
bg = slide.shapes.add_shape(1, Emu(0), Emu(0), Emu(SW), Emu(SH))
bg.fill.solid()
bg.fill.fore_color.rgb = RGBColor(0x06, 0x0E, 0x22)
bg.line.fill.background()
# 大号序号(水印风格,右下角)
num_txb = slide.shapes.add_textbox(Emu(SW - 2500000), Emu(SH - 2800000),
Emu(2200000), Emu(2500000))
num_p = num_txb.text_frame.paragraphs[0]
from pptx.enum.text import PP_ALIGN
num_p.alignment = PP_ALIGN.RIGHT
num_run = num_p.add_run()
num_run.text = f'{section_num:02d}'
num_run.font.size = Pt(200)
num_run.font.bold = True
num_run.font.color.rgb = RGBColor(0x15, 0x25, 0x45)
# 章节标题(居中偏左)
t_txb = slide.shapes.add_textbox(Emu(500000), Emu(SH//2 - 400000),
Emu(SW - 1000000), Emu(600000))
t_run = t_txb.text_frame.paragraphs[0].add_run()
t_run.text = section_title
t_run.font.size = Pt(40)
t_run.font.bold = True
t_run.font.color.rgb = RGBColor(0xFF, 0xFF, 0xFF)
# 强调线
line = slide.shapes.add_shape(1, Emu(500000), Emu(SH//2 + 260000),
Emu(600000), Emu(25000))
line.fill.solid()
line.fill.fore_color.rgb = accent
line.line.fill.background()
add_fade_transition(slide)
return slide
```
---
## 结尾页 {#end}
```python
def end_slide(prs, message: str = 'Thank You',
contact: str = '', logo_path: str = ''):
"""结尾页。"""
from pptx.enum.text import PP_ALIGN
SW, SH = int(prs.slide_width), int(prs.slide_height)
slide = prs.slides.add_slide(prs.slide_layouts[6])
# 背景
bg = slide.shapes.add_shape(1, Emu(0), Emu(0), Emu(SW), Emu(SH))
bg.fill.solid()
bg.fill.fore_color.rgb = RGBColor(0x00, 0x5A, 0xCC)
bg.line.fill.background()
# 主文字
m_txb = slide.shapes.add_textbox(Emu(0), Emu(SH//2 - 700000),
Emu(SW), Emu(700000))
m_p = m_txb.text_frame.paragraphs[0]
m_p.alignment = PP_ALIGN.CENTER
m_run = m_p.add_run()
m_run.text = message
m_run.font.size = Pt(48)
m_run.font.bold = True
m_run.font.color.rgb = RGBColor(0xFF, 0xFF, 0xFF)
# 联系方式
if contact:
c_txb = slide.shapes.add_textbox(Emu(0), Emu(SH//2 + 100000),
Emu(SW), Emu(400000))
c_p = c_txb.text_frame.paragraphs[0]
c_p.alignment = PP_ALIGN.CENTER
c_run = c_p.add_run()
c_run.text = contact
c_run.font.size = Pt(14)
c_run.font.color.rgb = RGBColor(0xCC, 0xDD, 0xFF)
# Logo(如果提供路径)
if logo_path:
from pptx.util import Inches
slide.shapes.add_picture(logo_path,
Emu(SW - 1500000), Emu(SH - 600000),
Emu(1200000), Emu(400000))
add_fade_transition(slide)
return slide
```
---
## 通用设计规范 {#design}
**字体大小体系**
| 用途 | 大小 |
|------|------|
| 封面主标题 | 36–48pt |
| 页面标题 | 22–28pt |
| 正文要点 | 13–15pt |
| 次要文字 | 10–12pt |
| 注释/标签 | 8–10pt |
**颜色体系**
| 用途 | 建议 |
|------|------|
| 背景 | 深蓝黑(深色)或白色(浅色) |
| 强调色 | 1–2 个品牌色,保持一致 |
| 正文文字 | 接近白/黑,对比度 > 4.5:1 |
| 次要文字 | 60–70% 亮度的正文色 |
**完整演示构建示例**
```python
from pptx import Presentation
from pptx.util import Emu
from pptx_helpers import add_fade_transition
# import the functions above
prs = Presentation()
prs.slide_width = Emu(12192000)
prs.slide_height = Emu(6858000)
title_slide(prs, 'Q1 Business Review', '2025 January – March',
date='2025-04-10', company='Acme Corp')
agenda_slide(prs, ['Market Overview', 'Financial Results',
'Product Updates', 'Outlook'])
section_slide(prs, 1, 'Market Overview')
bullet_slide(prs, 'Market Overview',
['Total addressable market grew 18% YoY',
' Asia-Pacific leads with 32% growth',
' North America stable at +8%',
'Competitive landscape remains fragmented',
'New regulations creating entry barriers'])
section_slide(prs, 2, 'Financial Results')
# ... more slides
end_slide(prs, 'Thank You', contact='[email protected]')
prs.save('q1-review.pptx')
```
FILE:references/charts.md
# python-pptx 原生图表 API
python-pptx 图表是**真正的 Office 图表对象**(非手绘),支持 PowerPoint/WPS 内编辑。
## Table of Contents
- [基本流程](#basic)
- [柱状图 / 条形图](#bar)
- [折线图](#line)
- [饼图 / 环形图](#pie)
- [散点图](#scatter)
- [多系列图表](#multi-series)
- [样式设置](#styling)
- [图表位置与大小](#layout)
---
## 基本流程 {#basic}
```python
from pptx import Presentation
from pptx.util import Inches, Pt
from pptx.chart.data import ChartData, CategoryChartData
from pptx.enum.chart import XL_CHART_TYPE
from pptx.dml.color import RGBColor
prs = Presentation()
slide = prs.slides.add_slide(prs.slide_layouts[6])
# 1. 准备数据
chart_data = CategoryChartData()
chart_data.categories = ['Q1', 'Q2', 'Q3', 'Q4']
chart_data.add_series('Sales', (120, 185, 210, 175))
# 2. 添加图表到幻灯片
chart = slide.shapes.add_chart(
XL_CHART_TYPE.COLUMN_CLUSTERED, # 图表类型
Inches(1), Inches(1), # left, top
Inches(8), Inches(4.5), # width, height
chart_data,
).chart
prs.save('output.pptx')
```
---
## 柱状图 / 条形图 {#bar}
```python
from pptx.enum.chart import XL_CHART_TYPE
# 常用类型
XL_CHART_TYPE.COLUMN_CLUSTERED # 簇状柱形图(最常用)
XL_CHART_TYPE.COLUMN_STACKED # 堆积柱形图
XL_CHART_TYPE.COLUMN_STACKED_100 # 百分比堆积柱形图
XL_CHART_TYPE.BAR_CLUSTERED # 簇状条形图(横向)
XL_CHART_TYPE.BAR_STACKED # 堆积条形图
```
```python
chart_data = CategoryChartData()
chart_data.categories = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun']
chart_data.add_series('Revenue', (320, 285, 410, 375, 490, 520))
chart = slide.shapes.add_chart(
XL_CHART_TYPE.COLUMN_CLUSTERED,
Inches(0.5), Inches(1), Inches(9), Inches(5),
chart_data,
).chart
# 显示数据标签
plot = chart.plots[0]
plot.has_data_labels = True
data_labels = plot.data_labels
data_labels.font.size = Pt(10)
data_labels.font.bold = True
```
---
## 折线图 {#line}
```python
XL_CHART_TYPE.LINE # 折线图
XL_CHART_TYPE.LINE_MARKERS # 带数据点的折线图
XL_CHART_TYPE.LINE_STACKED # 堆积折线图
```
```python
chart_data = CategoryChartData()
chart_data.categories = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun']
chart_data.add_series('Product A', (50, 65, 80, 72, 95, 110))
chart_data.add_series('Product B', (30, 42, 55, 61, 78, 85))
chart = slide.shapes.add_chart(
XL_CHART_TYPE.LINE_MARKERS,
Inches(0.5), Inches(1), Inches(9), Inches(5),
chart_data,
).chart
# 设置折线粗细
from pptx.util import Pt
series = chart.series[0]
series.format.line.width = Pt(2.5)
series.format.line.color.rgb = RGBColor(0x4B, 0x9F, 0xFF)
```
---
## 饼图 / 环形图 {#pie}
```python
XL_CHART_TYPE.PIE # 饼图
XL_CHART_TYPE.PIE_EXPLODED # 分离型饼图
XL_CHART_TYPE.DOUGHNUT # 环形图
XL_CHART_TYPE.DOUGHNUT_EXPLODED # 分离型环形图
```
```python
from pptx.chart.data import ChartData
# 饼图用 ChartData(不是 CategoryChartData)
chart_data = ChartData()
chart_data.categories = ['Asia', 'Europe', 'Americas', 'Others']
chart_data.add_series('Market Share', (0.45, 0.28, 0.20, 0.07))
chart = slide.shapes.add_chart(
XL_CHART_TYPE.PIE,
Inches(1), Inches(1), Inches(7), Inches(5),
chart_data,
).chart
# 显示百分比标签
plot = chart.plots[0]
plot.has_data_labels = True
plot.data_labels.number_format = '0%'
plot.data_labels.position = XL_LABEL_POSITION.OUTSIDE_END
# 显示图例
chart.has_legend = True
from pptx.enum.chart import XL_LEGEND_POSITION
chart.legend.position = XL_LEGEND_POSITION.RIGHT
chart.legend.include_in_layout = False
```
---
## 散点图 {#scatter}
```python
from pptx.chart.data import XyChartData
XL_CHART_TYPE.XY_SCATTER # 散点图
XL_CHART_TYPE.XY_SCATTER_LINES # 带直线的散点图
XL_CHART_TYPE.XY_SCATTER_SMOOTH # 带平滑线的散点图
```
```python
chart_data = XyChartData()
series = chart_data.add_series('Group A')
for x, y in [(1, 2.1), (2, 3.5), (3, 2.8), (4, 4.2), (5, 3.9)]:
series.add_data_point(x, y)
chart = slide.shapes.add_chart(
XL_CHART_TYPE.XY_SCATTER,
Inches(1), Inches(1), Inches(8), Inches(5),
chart_data,
).chart
```
---
## 多系列图表 {#multi-series}
```python
chart_data = CategoryChartData()
chart_data.categories = ['Q1', 'Q2', 'Q3', 'Q4']
chart_data.add_series('2023', (100, 120, 140, 130))
chart_data.add_series('2024', (130, 155, 170, 190))
chart_data.add_series('2025 (Forecast)', (160, 185, 210, 230))
chart = slide.shapes.add_chart(
XL_CHART_TYPE.COLUMN_CLUSTERED,
Inches(0.5), Inches(1), Inches(9), Inches(5),
chart_data,
).chart
# 逐系列设置颜色
colors = [RGBColor(0x4B,0x9F,0xFF), RGBColor(0xF5,0xA6,0x23), RGBColor(0x4A,0xDE,0x80)]
for i, series in enumerate(chart.series):
series.format.fill.solid()
series.format.fill.fore_color.rgb = colors[i]
```
---
## 样式设置 {#styling}
```python
# 图表标题
chart.has_title = True
chart.chart_title.text_frame.text = '季度收入趋势'
chart.chart_title.text_frame.paragraphs[0].font.size = Pt(16)
chart.chart_title.text_frame.paragraphs[0].font.bold = True
# 隐藏图表背景(透明,适合深色幻灯片)
chart.plot_area.format.fill.background()
chart.chart_area.format.fill.background()
# 坐标轴标签字体
from pptx.enum.chart import XL_AXIS_CROSSES
value_axis = chart.value_axis
value_axis.tick_labels.font.size = Pt(9)
value_axis.tick_labels.font.color.rgb = RGBColor(0xAA, 0xBB, 0xCC)
value_axis.major_gridlines.format.line.color.rgb = RGBColor(0x33, 0x44, 0x55)
category_axis = chart.category_axis
category_axis.tick_labels.font.size = Pt(9)
category_axis.tick_labels.font.color.rgb = RGBColor(0xAA, 0xBB, 0xCC)
# 数字格式
value_axis.tick_labels.number_format = '#,##0' # 千位分隔
value_axis.tick_labels.number_format = '0.0%' # 百分比
value_axis.tick_labels.number_format = '$#,##0' # 美元
# 图例
chart.has_legend = True
chart.legend.font.size = Pt(9)
```
---
## 图表位置与大小 {#layout}
```python
from pptx.util import Inches, Emu
# 用 Inches(更直观)
left = Inches(0.5)
top = Inches(1.2)
width = Inches(9.0)
height = Inches(4.8)
# 用 EMU(精确对齐其他元素时)
left = Emu(457200)
top = Emu(1097280)
# 添加后修改位置
graphic_frame = slide.shapes.add_chart(chart_type, left, top, width, height, chart_data)
graphic_frame.left = Inches(1) # 重新定位
graphic_frame.width = Inches(10) # 调整大小
```
---
## 注意事项
1. **`CategoryChartData` vs `ChartData`**:柱/折/条用 `CategoryChartData`,饼图用 `ChartData`,散点用 `XyChartData`
2. **透明背景**:深色幻灯片上要同时设置 `plot_area` 和 `chart_area` 为透明
3. **数据更新**:图表创建后数据保存在嵌入的 xlsx 内;重新设置数据需重建图表或操作内部 xlsx
4. **WPS 兼容性**:原生图表对象在 WPS 中完全支持,无兼容性问题
FILE:references/wps-compat.md
# WPS 兼容性踩坑记录
## 结论(先看这里)
**WPS 不可靠地支持 PowerPoint 的 click-triggered 动画。**
最安全的解决方案:**用多张幻灯片替代动画**,每张幻灯片叠加一层内容,
配合淡入过渡(fade transition),视觉效果几乎等同于动画。
---
## 踩过的坑(2025-03 实测)
### 坑 1:`presetClass="entr"` 不自动隐藏形状
**PowerPoint 行为**:带入场动画(`presetClass="entr"`)的形状,在演示模式下会自动隐藏,
直到动画触发时才显示。
**WPS 行为**:WPS 忽略这个约定,所有形状在幻灯片打开时就全部可见,动画完全失效。
**尝试过的修复**:在 `<p:timing>` 里插入显式的 `style.visibility=hidden` 初始块:
```xml
<p:par>
<p:cTn id="..." fill="hold">
<p:stCondLst><p:cond delay="0"/></p:stCondLst>
<p:tnLst>
<p:set>
<p:cBhvr>
<p:cTn id="..." dur="1" fill="hold"/>
<p:tgtEl><p:spTgt spid="{shape_id}"/></p:tgtEl>
<p:attrNameLst><p:attrName>style.visibility</p:attrName></p:attrNameLst>
</p:cBhvr>
<p:to><p:strVal val="hidden"/></p:to>
</p:set>
</p:tnLst>
</p:cTn>
</p:par>
```
**结果**:WPS 同样不支持 `style.visibility` 属性动画,形状依然全部可见。
### 坑 2:`delay="indefinite"` click group 结构
正确的 click-triggered 序列 XML 结构(仅在真正的 PowerPoint 中有效):
```xml
<p:seq concurrent="1" nextAc="seek">
<p:cTn id="2" dur="indefinite" nodeType="mainSeq">
<p:tnLst>
<!-- Click group 1 -->
<p:par>
<p:cTn id="..." fill="hold">
<p:stCondLst><p:cond evt="onBegin" delay="indefinite"/></p:stCondLst>
<p:tnLst>
<!-- staggered shape animations -->
</p:tnLst>
</p:cTn>
</p:par>
<!-- Click group 2 -->
<p:par>...</p:par>
</p:tnLst>
</p:cTn>
<p:prevCondLst><p:cond evt="onPrevClick" delay="0"/></p:prevCondLst>
<p:nextCondLst><p:cond evt="onNextClick" delay="0"/></p:nextCondLst>
</p:seq>
```
WPS 对 `delay="indefinite"` 的处理不正确,所有 click group 会在打开时立即执行。
### 坑 3:`<p:bldLst>` 不影响实际显示
加了 `<p:bldLst>` 让 PP/WPS 知道哪些形状有动画,但这对 WPS 的可见性行为没有影响。
---
## 最终可靠方案
### 多幻灯片 + 淡入过渡
```python
from pptx_helpers import add_fade_transition
# Slide 1: 骨架
s1 = prs.slides.add_slide(layout)
draw_skeleton(s1)
add_fade_transition(s1)
# Slide 2: 骨架 + 第一组数据
s2 = prs.slides.add_slide(layout)
draw_skeleton(s2)
draw_layer_a(s2)
add_fade_transition(s2)
# Slide 3: 完整图
s3 = prs.slides.add_slide(layout)
draw_skeleton(s3)
draw_layer_a(s3)
draw_layer_b(s3)
add_fade_transition(s3)
```
**优点**:
- WPS / PowerPoint / Keynote 全部兼容
- 淡入过渡视觉效果流畅,接近动画体验
- 代码结构更清晰,易于维护
**缺点**:
- 幻灯片数量增加(通常 3-5 张)
- 静态内容(骨架)会在每张幻灯片上重复绘制(可以接受)
---
## 如果用户坚持要动画(仅限真正的 PowerPoint)
当用户明确说"用 Microsoft PowerPoint 演示,不是 WPS"时,可以尝试 XML 动画方案,
但需要注意:
1. 所有入场形状必须通过 `style.visibility=hidden` 显式隐藏(见坑 1)
2. 每个 click group 用 `delay="indefinite"` 包裹
3. 加入 `<p:bldLst>` 注册所有动画形状
4. 测试时必须用真实的演示模式(F5),编辑模式看不出效果
即便如此,不同版本的 PowerPoint 行为也可能有差异。