⚠️ 使用前须知
- 图片数据发送:第一步 VLM 图像分析会将截图发送到配置的 VLM 服务端点(如 OpenClaw image 工具 / MiniMax Vision / GPT-4V 等)。请勿使用本技能处理涉密或敏感的幻灯片。
- 文件系统:技能会生成临时 PNG 文件和输出 PPTX 文件,确保运行环境允许文件读写操作。
- 运行时依赖:需提前安装
npm install -g pptxgenjs;如需复杂形状降级,还需安装 Python + Pillow。
PPT视觉复刻技能 (v1.5)
将静态PPT截图/信息图转换为可编辑PPTX文件的完整工作流。
核心算法 (Images2Slides论文适配)
坐标映射 (Pixel → EMU)
EMU = Pixel × (914400 / DPI)
Slide_X = (Image_X / Image_Width) × Slide_Width_EMU
Slide_Y = (Image_Y / Image_Height) × Slide_Height_EMU
标准16:9幻灯片尺寸: 9144000 × 5143500 EMU
工作流程
阶段1: 图像分析(开放式 VLM 调用)
第一步的图片识别为开放式设计,可灵活接入任意具备图像理解能力的工具——
如 OpenClaw 内置 image 工具(默认,MiniMax Vision VLM)、MCP VLM 服务、GPT-4V 等。
后续坐标映射和 PPT 生成逻辑与具体 VLM 解耦,保持稳定。
必须使用以下增强版 Prompt,以确保符号精度和格式完整性:
const ENHANCED_PROMPT = `分析这张PPT截图,精确输出所有可见区域的信息。
输出格式为JSON数组,每个元素包含:
{
"id": "数字",
"type": "rectangle | roundedRect | text | line | image",
"role": "header_bar | title | subtitle | body | data_card | footer | decorator | bullet_list",
"bounds": { "x": 像素, "y": 像素, "width": 像素, "height": 像素 },
"color": "#十六进制颜色(文字颜色或形状填充色)",
"backgroundColor": "#背景填充色(仅形状适用)",
"lineColor": "#边框颜色(如有边框)",
"lineWidth": 边框宽度,
"content": "文字内容(保持原版的所有标点符号,全角/半角符号原样保留)",
"fontSize": 字号数字(磅),
"fontFamily": "字体名称",
"fontWeight": "normal | bold",
"align": "left | center | right",
"lineSpacing": 行间距倍数(1.0/1.5/2.0),
"charSpacing": 字符间距(磅),
"bulletType": "无bullet则null,有bullet则填 ■ / ✓ / - 等符号",
"cornerRadius": 圆角大小(仅roundedRect适用),
"zIndex": 叠加层级(从0开始)
}
⚠️ 特别注意:
1. content 必须保持原版的所有标点符号,包括全角括号()和半角括号()的区别
2. 不要添加或删除任何字符
3. 项目符号(■、✓、1.、-)必须原样记录在 bulletType 字段
4. 识别所有行间距较大的文本框,填写 lineSpacing
5. 边框矩形必须填写 lineColor,填充矩形填写 backgroundColor
6. zIndex按照视觉层叠顺序从后到前递增`;
阶段2: 结构化数据解析
调用 scripts/coordinate_mapper.js,支持4种JSON提取策略,自动容错:
const { parseAnalysisToRegions, mapAllRegions } = require('./coordinate_mapper');
// 解析分析结果(自动处理全角符号校正和bullet识别)
const regions = parseAnalysisToRegions(analysisText);
// 批量映射坐标
const mappedRegions = mapAllRegions(regions, imageWidth, imageHeight);
新增功能(v1.1):
normalizeSymbols(text) — 全角/半角符号校正
detectBullet(text) — 识别并提取bullet符号类型
- 4策略JSON解析容错(直接解析 → 数组提取 → 对象提取 → 代码块提取)
阶段3: PPT生成
调用 scripts/ppt_generator.js:
const { saveFromRegions } = require('./ppt_generator');
await saveFromRegions(mappedRegions, 'output.pptx', {
imageWidth: 2100,
imageHeight: 1192,
background: '#FFFFFF' // 可选背景色
});
区域类型映射
| VLM输出 | PptxGenJS方法 | v1.1新增支持 |
|---|
| rectangle (filled) | slide.addShape('rect') | backgroundColor / lineColor |
| rectangle (border) | slide.addShape('rect') | lineWidth / dashType |
| roundedRect | slide.addShape('roundRect') | ✅ 新增 cornerRadius |
| text | slide.addText() | ✅ lineSpacing / charSpacing / bullet |
| line | slide.addShape('line') | dashType |
| image | slide.addImage() | data(base64) / path |
| 复杂形状 | slide.addImage() + Python绘制PNG | ✅ v1.2新增 |
复杂形状降级处理策略 (v1.2)
核心思路:pptxgenjs 无法还原的复杂图形,改用 Python/Pillow 绘制精确 PNG(透明背景)→ 嵌入 PPT 的方式替代。
何时触发降级
遇到以下情况时,应将该形状标记为 type: "complex_shape",使用降级策略处理:
| 形状特征 | 判断依据 |
|---|
| 自定义多边形路径 | XML中存在 <a:custGeom> 标签,包含贝塞尔曲线、不规则路径 |
| 透明渐变填充 | 多色渐变(3个以上 stop)或带透明度通道(alpha < 100%)的渐变 |
| 复合渐变方向 | 渐变角度非水平/垂直(非0/90/180/270度),如斜向渐变 |
| 发光/阴影特效 | <a:outerShdw>, <a:glow> 等复杂效果 pptxgenjs 渲染误差大 |
| 不规则遮罩/裁切 | 图片经过自定义路径裁切(<p:spPr><a:custGeom> 包裹图片) |
| VLM识别为渐变箭头/装饰图形 | 从截图分析中识别出类型为装饰性渐变形状 |
降级处理工作流
┌─────────────────────────────────────────────────────┐
│ 1. 判断是否为复杂形状(见上表) │
│ ├── 有原版PPTX → 从XML提取精确路径和颜色参数 │
│ └── 仅截图 → 用VLM估算形状轮廓和渐变颜色 │
│ │
│ 2. 用Python/Pillow绘制透明背景PNG │
│ ├── 绘制精确路径(参考XML坐标 或 VLM估算像素) │
│ ├── 应用渐变/透明/特效 │
│ └── 输出 RGBA 透明背景 PNG │
│ │
│ 3. 嵌入PPT(addImage) │
│ └── 坐标与原版对应(EMU换算或比例缩放) │
└─────────────────────────────────────────────────────┘
从原版XML提取形状参数(有源文件时优先)
当有原版 .pptx 可解包时,可获取最精确的参数:
# 解包PPTX
unzip original.pptx -d pptx_unpacked/
# 查看形状XML
cat pptx_unpacked/ppt/slides/slide1.xml
关键字段提取:
<!-- 自定义路径形状 -->
<a:custGeom>
<a:pathLst>
<a:path w="5725" h="1294"> <!-- 路径画布尺寸(EMU相对坐标)-->
<a:moveTo><a:pt x="2852" y="0"/></a:moveTo> <!-- 起点 -->
<a:cubicBezTo>...</a:cubicBezTo> <!-- 贝塞尔曲线 -->
<a:lnTo>...</a:lnTo> <!-- 直线段 -->
</a:path>
</a:pathLst>
</a:custGeom>
<!-- 渐变填充 -->
<a:gradFill>
<a:gsLst>
<a:gs pos="0"><a:srgbClr val="FFFFFF"/></a:gs> <!-- 白色,pos=0% -->
<a:gs pos="100000"><a:srgbClr val="C00000"/></a:gs> <!-- 红色,pos=100% -->
</a:gsLst>
<a:lin ang="16200000" scaled="0"/> <!-- ang=16200000 = 270°(EMU角度单位:60000/度)-->
</a:gradFill>
<!-- 形状在组合(grpSp)中的位置 -->
<a:off x="696" y="2680"/> <!-- 相对于组的偏移 -->
<a:ext cx="5725" cy="1294"/> <!-- 形状尺寸 -->
EMU角度转换公式:
实际角度(度)= EMU角度值 / 60000
例: 16200000 / 60000 = 270°(即从上到下的渐变方向)
组合坐标转绝对坐标公式:
// grp = { off: {x, y}, ext: {cx, cy}, chOff: {x, y}, chExt: {cx, cy} }
function absPos(grp, shape) {
const scaleX = grp.ext.cx / grp.chExt.cx;
const scaleY = grp.ext.cy / grp.chExt.cy;
return {
x: grp.off.x + (shape.x - grp.chOff.x) * scaleX,
y: grp.off.y + (shape.y - grp.chOff.y) * scaleY,
cx: shape.cx * scaleX,
cy: shape.cy * scaleY,
};
}
Python/Pillow 绘制复杂形状 PNG(模板)
"""
复杂形状PNG生成模板
适用于:自定义多边形 + 渐变填充 + 透明背景
"""
from PIL import Image, ImageDraw
import numpy as np
# ========== 配置区 ==========
OUTPUT_PATH = "complex_shape.png"
# 画布尺寸(可按EMU比例或屏幕像素设定,建议宽度1000+)
CANVAS_W = 1200
CANVAS_H = 280
# 渐变颜色(从上到下,可扩展多个停止点)
GRADIENT_STOPS = [
(0.0, (255, 255, 255)), # 白色 #FFFFFF
(1.0, (192, 0, 0)), # 深红 #C00000
]
# 形状路径点(归一化坐标 0~1,按原版路径节点填写)
# 说明:从XML的 custGeom 路径坐标除以路径画布宽高即可归一化
SHAPE_POINTS_NORMALIZED = [
(0.498, 0.0), # 顶部尖角
(1.0, 0.25), # 右上
(1.0, 1.0), # 右下
(0.0, 1.0), # 左下
(0.0, 0.25), # 左上
]
# ========== 配置区结束 ==========
def lerp_color(c1, c2, t):
return tuple(int(c1[i] + (c2[i] - c1[i]) * t) for i in range(3))
def gradient_color(y_ratio, stops):
"""根据y比例插值渐变颜色"""
for i in range(len(stops) - 1):
p0, c0 = stops[i]
p1, c1 = stops[i + 1]
if p0 <= y_ratio <= p1:
t = (y_ratio - p0) / (p1 - p0)
return lerp_color(c0, c1, t)
return stops[-1][1]
# 创建透明画布
img = Image.new("RGBA", (CANVAS_W, CANVAS_H), (0, 0, 0, 0))
# 构建蒙版(形状区域)
mask = Image.new("L", (CANVAS_W, CANVAS_H), 0)
draw_mask = ImageDraw.Draw(mask)
pixel_points = [(int(x * CANVAS_W), int(y * CANVAS_H)) for x, y in SHAPE_POINTS_NORMALIZED]
draw_mask.polygon(pixel_points, fill=255)
# 逐行填充渐变色
pixels = img.load()
mask_pixels = mask.load()
for row in range(CANVAS_H):
y_ratio = row / CANVAS_H
color = gradient_color(y_ratio, GRADIENT_STOPS)
for col in range(CANVAS_W):
if mask_pixels[col, row] > 0:
pixels[col, row] = (*color, mask_pixels[col, row])
img.save(OUTPUT_PATH)
print(f"已生成: {OUTPUT_PATH} ({CANVAS_W}x{CANVAS_H})")
贝塞尔曲线采样(针对含 cubicBezTo 的复杂路径):
完整实现已打包在 scripts/complex_shape.py,支持命令行直接调用:
python scripts/complex_shape.py \
--output complex_shape.png \
--width 1200 --height 280 \
--gradient-stops "0.0:#FFFFFF,1.0:#C00000" \
--polygon "0.498,0.0 1.0,0.25 1.0,1.0 0.0,1.0 0.0,0.25"
嵌入PPT(addImage)
const PptxGenJS = require('pptxgenjs');
// EMU → 英寸(pptxgenjs坐标单位为英寸)
const EMU_PER_INCH = 914400;
function emuToInch(emu) { return emu / EMU_PER_INCH; }
// 嵌入生成的PNG(坐标来自absPos计算结果)
slide.addImage({
path: './complex_shape.png', // 或 data: base64String
x: emuToInch(absPos.x),
y: emuToInch(absPos.y),
w: emuToInch(absPos.cx),
h: emuToInch(absPos.cy),
});
仅有截图时的降级策略
当没有原版PPTX只有截图时:
- 用VLM分析形状:在分析Prompt中要求输出形状类型为
complex_shape,描述渐变颜色和方向
- 坐标估算:从像素坐标按比例转换为PPT EMU坐标(精度约±5%)
- 颜色提取:要求VLM输出渐变起止色的十六进制值
- 形状近似:若路径复杂,可用多边形近似(顶点数≥6通常足够)
更新VLM分析Prompt中的type枚举,增加:
"type": "complex_shape",
"shapeDescription": "渐变方向/颜色/形状类型的文字描述,供Python绘制参考",
"gradientStops": [{"pos": 0, "color": "#FFFFFF"}, {"pos": 1, "color": "#C00000"}],
"gradientAngle": 270
短期优化清单(v1.1已实现)
| 优先级 | 问题 | 解决方案 |
|---|
| P0 | 括号全角/半角差异 | 增强Prompt要求保留原版符号 |
| P0 | bullet符号丢失或错误 | 新增 detectBullet() + bulletType字段 |
| P0 | 行间距不一致 | 支持 lineSpacing 参数映射 |
| P0 | 字符间距丢失 | 支持 charSpacing 参数映射 |
| P1 | JSON解析失败 | 4策略容错解析 |
| P1 | 圆角矩形显示为直角 | 新增 roundedRect 类型 |
快速执行命令
const { parseAnalysisToRegions, mapAllRegions } = require('./coordinate_mapper');
const { saveFromRegions } = require('./ppt_generator');
async function main(analysisResult, imageWidth, imageHeight, outputPath) {
// 1. 解析区域(含符号校正)
const regions = parseAnalysisToRegions(analysisResult);
// 2. 坐标映射
const mapped = mapAllRegions(regions, imageWidth, imageHeight);
// 3. 生成PPTX
await saveFromRegions(mapped, outputPath, { imageWidth, imageHeight });
}
输出规范
生成的PPTX必须包含:
- 所有可见区域精确复刻
- 正确的叠加顺序(zIndex)
- 准确的颜色值
- 可编辑的文本内容(含正确的符号和行间距)
依赖
- Node.js + pptxgenjs
- 图像理解工具(开放式,默认 OpenClaw 内置
image 工具 / MiniMax Vision MCP)
- coordinate_mapper.js(坐标转换 + 符号校正)
- ppt_generator.js(PPT生成 + 格式支持)
方案B:基于原版PPTX的内容替换(ZIP直接操作法)
适用场景:用户有原版 .pptx 文件,只需替换文字内容,保留所有样式、母版、配色、custGeom、媒体文件。
优点:100%保留原版视觉效果,无需重建任何形状,文件结构完整可靠。
与方案A的选择原则:有原版PPTX → 优先方案B;仅有截图 → 方案A(从零生成)。
核心思路
PPTX 本质是一个 ZIP 压缩包。直接用 Python zipfile 操作:
- 只替换
ppt/slides/slide1.xml(幻灯片内容)
- 完整保留其他所有文件(母版/布局/主题/媒体/标签等)
原版PPTX (89个文件)
└── ppt/slides/slide1.xml ← 只替换这一个文件
└── ppt/slideMasters/ ← 保留
└── ppt/slideLayouts/ ← 保留
└── ppt/theme/ ← 保留
└── ppt/media/ ← 保留(图片、SVG等)
└── [Content_Types].xml ← 保留
└── ...所有其他文件 ← 保留
⚠️ 关键注意事项
- 必须用完整原版PPTX为基础,不能用手动解包再重新打包的目录(极易缺失文件)
- 中文文本被拆散在多个
<a:r> 标签中,不能直接字符串替换,需整段 <a:p> 替换
- XML中特殊字符必须转义:
< → <,> → >,& → &
- 替换前先用
xmllint --noout 或 xml.etree.ElementTree 验证XML有效性
完整替换脚本模板
import zipfile, re, io
src = '/path/to/original.pptx' # 原版完整PPTX
out = '/path/to/output.pptx' # 输出路径
# 1. 读取 slide1.xml
with zipfile.ZipFile(src, 'r') as z:
xml = z.read('ppt/slides/slide1.xml').decode('utf-8')
# =============================================
# 2. 文本替换(两种策略,按情况选用)
# =============================================
# --- 策略A:简单单Run替换(文本完整在一个 <a:t> 标签内)---
# 格式:(旧文本含尖括号定位符, 新文本含尖括号定位符)
simple_replacements = [
('>旧文本1<', '>新文本1<'),
('>旧文本2<', '>新文本2<'),
# ⚠️ 如果新文本含 < 或 > 必须转义:
('>旧文本3<', '>误差<1%<'),
]
for old, new in simple_replacements:
xml = xml.replace(old, new)
# --- 策略B:整段替换(中文被拆散在多个 <a:r> 中时使用)---
def replace_para_containing(xml_str, search_text, new_text, rPr_override=None):
"""
找到包含 search_text 的整个 <a:p> 段落,替换为只含 new_text 的新段落。
保留原段落的 pPr(段落格式)和 rPr(文字格式)。
"""
idx = xml_str.find(f'>{search_text}<')
if idx < 0:
print(f'⚠️ 未找到文本:{search_text}')
return xml_str
start = xml_str.rfind('<a:p>', 0, idx)
end = xml_str.find('</a:p>', idx) + 6
old_para = xml_str[start:end]
# 提取段落格式
pPr_match = re.search(r'<a:pPr.*?</a:pPr>', old_para, re.DOTALL)
pPr = pPr_match.group() if pPr_match else ''
# 提取文字格式(优先自闭合 rPr)
if rPr_override:
rPr = rPr_override
else:
rPr_match = re.search(r'<a:rPr(?:\s[^>]*)*/>', old_para)
if not rPr_match:
rPr_match = re.search(r'<a:rPr.*?</a:rPr>', old_para, re.DOTALL)
rPr = rPr_match.group() if rPr_match else '<a:rPr/>'
new_para = f'<a:p>{pPr}<a:r>{rPr}<a:t>{new_text}</a:t></a:r></a:p>'
return xml_str.replace(old_para, new_para, 1)
# 调用示例
xml = replace_para_containing(xml, '原始文本片段', '替换后的完整文本')
# 如果连 search_text 的定位符也找不到,说明文本被拆散更细,
# 此时需检查XML原文,找到任意未被拆散的子串作为 search_text
# =============================================
# 3. 验证XML有效性(替换后必做)
# =============================================
import xml.etree.ElementTree as ET
try:
ET.fromstring(xml)
print('✅ XML验证通过')
except ET.ParseError as e:
print(f'❌ XML解析错误:{e}')
# 定位错误行列,检查是否有未转义的特殊字符
# =============================================
# 4. 打包输出(保留原文件所有内容)
# =============================================
buf = io.BytesIO()
with zipfile.ZipFile(src, 'r') as zin:
with zipfile.ZipFile(buf, 'w', zipfile.ZIP_DEFLATED) as zout:
for item in zin.infolist():
if item.filename == 'ppt/slides/slide1.xml':
zout.writestr(item, xml.encode('utf-8'))
else:
zout.writestr(item, zin.read(item.filename))
with open(out, 'wb') as f:
f.write(buf.getvalue())
print(f'✅ 已生成:{out}')
调试技巧
查找文本在XML中的实际位置:
# 先解包查看原始XML结构
with zipfile.ZipFile(src, 'r') as z:
xml = z.read('ppt/slides/slide1.xml').decode('utf-8')
# 搜索目标文本(注意:可能是片段,不是完整句子)
target = '要查找的文本'
idx = xml.find(target)
if idx >= 0:
print(xml[max(0, idx-200):idx+200]) # 打印前后200字符上下文
else:
# 中文被拆散时,尝试搜索其中一个汉字或标点
print('未找到完整文本,尝试搜索子串')
判断应该用策略A还是B:
文本在XML中是连续的 <a:t>完整文本</a:t> → 用策略A(简单替换)
文本被拆散为多个 <a:r>...<a:t>片</a:t></a:r><a:r>...<a:t>段</a:t></a:r> → 用策略B(整段替换)
常见错误与解决
| 错误 | 原因 | 解决 |
|---|
| PowerPoint无法读取文件 | 打包基础文件不完整(缺media/theme等) | 必须用原版完整PPTX为基础,不能用手动解包目录 |
| XML解析报错(column XXXX) | 替换文本含未转义的 < > & | 转义为 < > & |
| 替换未生效 | 中文文本被拆散在多个 <a:r> 中 | 改用 replace_para_containing() 整段替换 |
| 找不到 search_text | 数字/标点与汉字分开在不同 run | 搜索纯汉字或标点子串定位,再整段替换 |
| slideMaster.xml.rels 为空 | 解包时内容丢失(此问题仅在手动解包时出现) | 从原版PPTX直接操作,不会有此问题 |
变更日志
v1.5.0 (2026-03-27)
- 修复 ClawHub 审查问题:新增
requires metadata 声明运行时依赖(npm:pptxgenjs, python:Pillow)
- 新增完整脚本:
scripts/complex_shape.py 提供可直接执行的复杂形状 PNG 生成工具
- 新增使用前须知:明确图片数据发送至外部 VLM 的隐私说明、文件系统读写说明、运行时依赖
v1.4.0 (2026-03-26)
- VLM 调用方式升级:移除
minimax-understand-image 技能依赖,改为开放式 VLM 调用设计,默认 OpenClaw 内置 image 工具
- 适配 MiniMax 作为模型提供商时的内置 Vision 能力
v1.3.0 (2026-03-26)
- 新增"方案B:基于原版PPTX的内容替换(ZIP直接操作法)"
- 明确方案A(从零生成)vs 方案B(原版替换)的选择原则
- 提供完整的 Python
zipfile 操作模板(只替换 slide1.xml,保留所有其他文件)
- 提供两种替换策略:策略A(简单单Run替换)+ 策略B(整段
<a:p> 替换)
- 提供
replace_para_containing() 通用函数,解决中文文本被拆散在多个 <a:r> 的问题
- 提供调试技巧(XML上下文打印、策略A/B判断方法)
- 提供常见错误排查表(文件损坏/XML解析报错/替换未生效等)
- 总结关键注意事项:必须用完整原版PPTX、特殊字符必须转义等
v1.2.0 (2026-03-26)
- 新增"复杂形状降级处理策略"(核心功能升级)
- 明确7类需要降级的形状判断标准(custGeom/透明渐变/斜向渐变/特效/裁切等)
- 提供完整的 Python/Pillow 绘制模板(含多边形 + 逐行渐变 + 透明背景)
- 提供贝塞尔曲线采样函数(适配 XML
cubicBezTo)
- 提供XML参数提取指南(路径坐标、渐变色、EMU角度转换、组合坐标变换)
- 提供
addImage 嵌入代码模板
- 补充仅有截图时的降级策略(VLM估算 + 多边形近似)
- 区域类型映射表新增
complex_shape 行
v1.1.0 (2026-03-26)
- 增强分析Prompt(短期优化P0方案)
- 增加全角/半角符号校正
normalizeSymbols()
- 增加bullet符号识别
detectBullet()
- 增加行间距
lineSpacing 渲染支持
- 增加字符间距
charSpacing 渲染支持
- 增加圆角矩形
roundedRect 类型支持
- 增加JSON解析4策略容错
- 改进多行文本段落渲染
v1.0.0 (2026-03-26)
- 初始版本,基于Images2Slides论文实现基本复刻流程