AI 生成 PPTX:11 页课件编译出 429 个形状
2026 年「让 Agent 写 PPT」成了显学:官方 pptx skill 收敛到「先大纲、再出结构化中间表示、交给渲染器出文件」,Marp、Slidev 这类 code-first 方案也被翻出来。我用同一思路做了份 11 页试讲课件,73.8 KiB 源码编译出 429 个原生形状。
一、先看产物:一条流水线吐出了什么
整套东西不是「一句话生成幻灯片」那种黑盒,而是一条能反复改、能版本回滚的工程流水线。实测产物清单如下。
| 产物 | 数量 | 体积 | 说明 |
|---|---|---|---|
slides/*.slide 课件源码 |
11 个 / 1068 行 | 73.8 KiB | 类 JSX 的声明式 DSL |
| DSL 组件节点 | Box 367 + Text 257 + CodeBlock 5 + SVG 6 | --- | 6 个 SVG 全部手写 |
ppt/slides/*.xml 编译产物 |
11 页 | 435.5 KiB | 未压缩,是源码的 5.90 倍 |
| 打包后 PPTX | 429 个形状 | 68.0 KiB | zip 把 XML 压到 9.9% |
preview/ 出图 |
11 张 PNG | 566.7 KiB | 平均每页 52.8 KiB,用于人眼验收 |
STORY.md 设计文档 |
11 页规则表 | 7.6 KiB | 节奏、版式、字数预算 |
| 试讲逐字稿 | 11 段 / 340 s | 8.3 KiB | 与每页一一对应 |
一句话概括体量关系:73.8 KiB 源码 → 435.5 KiB OOXML → zip 压到 44.3 KiB → 落盘 68.0 KiB 的 PPTX。
二、技术选型:三条出片路线怎么选
| 路线 | 代表做法 | 产物 | 文字可编辑 | 我的判断 |
|---|---|---|---|---|
| 图像合成 | 每页整张图再拼进 PPTX | 位图 | 否 | 好看,但字号改不了、文字选不中,教学课件要反复改,直接否决 |
| 命令式代码 | 手写 PptxGenJS / python-pptx,逐个 addShape |
原生形状 | 是 | 可控,但所有坐标要人肉算,11 页 429 个形状会写疯 |
| 声明式 DSL | 写 <Box> <Text>,编译器负责布局与出形状 |
原生形状 | 是 | 选它:写的是意图,不是坐标 |
决定性的差别在最后一步能不能改。试讲课件要按评委反馈反复调字号、挪色块,图像路线每改一次就重出一遍图,而 DSL 路线改一行源码重新编译即可。
三、核心原理一:DSL 节点到 OOXML 形状的映射
这是整条链路最值得讲的一层。我把 11 页逐个拆开数了一遍:
| 页 | Box | 带 background 的 Box | Text | SVG | <p:sp> |
<p:pic> |
文本字符 |
|---|---|---|---|---|---|---|---|
| P1 | 20 | 3 | 19 | 1 | 26 | 1 | 187 |
| P2 | 36 | 14 | 24 | 0 | 42 | 0 | 292 |
| P3 | 33 | 14 | 22 | 0 | 37 | 0 | 352 |
| P4 | 27 | 11 | 17 | 2 | 29 | 2 | 248 |
| P5 | 48 | 13 | 33 | 1 | 51 | 1 | 419 |
| P6 | 28 | 13 | 25 | 0 | 41 | 0 | 523 |
| P7 | 56 | 14 | 43 | 0 | 65 | 0 | 320 |
| P8 | 38 | 15 | 26 | 0 | 48 | 0 | 396 |
| P9 | 26 | 9 | 20 | 0 | 32 | 0 | 374 |
| P10 | 36 | 16 | 21 | 0 | 38 | 0 | 340 |
| P11 | 19 | 6 | 7 | 2 | 14 | 2 | 118 |
| 合计 | 367 | 128 | 257 | 6 | 423 | 6 | 3569 |
三条实测规律:
- 1 个
<Text>换 1 个可编辑文本框 。11 页里 8 页的「带文字形状数」与 DSL 的<Text>数量完全相等,P6 / P8 / P9 多出的 1~3 个来自CodeBlock内部的行标签,不是误差。 - 367 个 Box 里只有 128 个(34.9%)会落成矩形 ,判定条件是有没有写
background。剩下 239 个<Box style={``{ height: 20 }} />是纯粹的布局占位撑高度/撑间距,编译后一个形状都不留。 - 6 个手写 SVG 原样以
.svg进ppt/media/,不是先光栅化成 PNG,矢量保真;并且以 STORED 方式存包(原始字节 = 压缩后字节),因为 SVG 本身是文本,再压一次收益极小。
整体折算下来:平均每 2.49 行 DSL 产出 1 个形状,每个形状烧掉约 1040 字节 XML,每页平均 39.0 个形状。
顺带看一眼样式侧的落地情况。全篇几何体只有 rect 和 roundRect 两种,roundRect 共 90 个,对应 DSL 里 85 处 borderRadius,圆角没有丢。border 也是一样:DSL 里 42 处描边声明,OOXML 里恰好 42 条带属性的 <a:ln>,一条不差,另外 125 个无边框形状补的是空 <a:ln/> 节点。字体只用三款------Microsoft YaHei 1184 次、Menlo 204 次(代码块)、Consolas 80 次(数字与变量名),没有出现字体回退失败导致的方块字。颜色共 26 种、填充 927 次,用得最多的是正文深灰 #1A2230(153 次)。
四、核心原理二:同一份尺寸要走三套单位
DSL 里写的是前端熟悉的 px,落到 OOXML 全部换单位,这三套换算一旦记错,手写 XML 修图必然全歪。
| 维度 | DSL 里写的 | OOXML 里的值 | 换算关系 |
|---|---|---|---|
| 画布宽 | width: '1280px' |
<a:ext cx="12192000"> |
1 px = 9525 EMU |
| 画布高 | height: '720px' |
<a:ext cy="6858000"> |
同上 |
| 字号 | fontSize: 58 |
sz="4350" |
1 px = 0.75 pt,sz 单位是 1/100 pt,即 px × 75 |
| 字距 | letterSpacing: 3 |
spc="225" |
同上,px × 75 |
所以 DSL 里的 58px 大标题,在 PowerPoint 里显示为 43.5 pt。编译器还会按字符类型切 run:全篇 628 个 run 中 lang="zh-CN" 349 个、lang="en-US" 279 个,中文与英文/数字分开打语言标记,字体回退和拼写检查才不会串。
五、核心原理三:5.9 倍膨胀,两成来自源码内嵌
435.5 KiB / 73.8 KiB = 5.90 倍,这个数一开始让我以为是形状骨架太重。查下去发现另有原因:每页的完整 DSL 源码被 HTML 转义后塞进了 <p:cNvPr id="1" descr="...">。
11 页 descr 合计 87,356 字节,占全部 slide XML 的 19.6% ,单页转义后是原文的 1.15~1.18 倍(< > 
 这些实体在吃体积)。扣掉 descr 后 XML 仍是源码的 4.75 倍,剩下的才是形状骨架税。
这么干是有道理的:descr 让 PPTX 自带可反解的源码,下一轮 Agent 接着改时能读回原始意图,不用猜哪个形状对应哪段 DSL。代价就是下面这条坑。
六、验收闭环:渲染成图,再让「另一双眼睛」看一遍
这条流水线一共分四层,缺任何一层质量都会塌:
- 大纲层 :先写
STORY.md,把受众、目标、页数、每页功能和节奏定下来,人工过一遍; - 内容层 :把大纲翻译成
slides/*.slide,这一步只做内容决策,不碰坐标; - 编译层:确定性渲染器把 DSL 变成 OOXML,不做任何「自由发挥」;
- 验收层 :每页渲成 PNG 放
preview/,由人(或另一个不看生成上下文的 Agent)对照清单挑毛病,只修出问题的页。
第 4 层是最容易省、也最不该省的一层。原因很实在:文字溢出、元素重叠、对比度不够这三类缺陷,只有在渲染成像素之后才存在 ,看 DSL 源码永远发现不了------源码里 height: 54 和 fontSize: 19 两个数单独看都合理,但真渲出来字可能顶到边框。这次 11 页全部出图,合计 566.7 KiB,平均每页 52.8 KiB,最大的 P3 是 59,740 B,最小的 P11 是 33,202 B。
还有一条纪律:生成者和验收者必须是两边。写这页的对象脑子里已经有「它应该长什么样」,会本能地给自己的排版缺陷找理由;换一个没有生成上下文的校验者,才会老老实实按清单逐条查。这也是 2026 年几套成熟方案共同的做法。
七、关键代码
结束页的 DSL 长这样,注意 svg 是内联手写的,Box 有 background 才会变成矩形:
jsx
// slides/11.slide ------ 结束页
<Slide style={{ width: '1280px', height: '720px', background: '#FFFFFF',
padding: 0, fontFamily: 'Microsoft YaHei' }}>
{/* 背景装饰:低透明循环环,SVG 手写,编译后保留矢量 */}
<Box style={{ position: 'absolute', top: 30, left: 660, opacity: 0.07 }}>
<svg width={620} height={620} viewBox='0 0 620 620'>
<path d='M 310 60 A 250 250 0 1 1 486.8 133.2' fill='none'
stroke='#1E4FA8' strokeWidth='36' strokeLinecap='round' />
<polygon points='486.8,133.2 441.5,122.0 475.5,88.0' fill='#1E4FA8' />
</svg>
</Box>
<Text style={{ fontSize: 58, fontWeight: 'bold', color: '#0E3F8C' }}>
请各位评委老师批评指正
</Text>
{/* 只有带 background 的 Box 会落成 PPT 里的矩形,其余都是布局占位 */}
<Box style={{ width: 100, height: 3, background: '#1E4FA8' }} />
</Slide>
「执行过程追踪」那张表也是 Box + Text 一行行拼出来的,没有用任何表格原语:
jsx
// slides/07.slide ------ 追踪表的一行,斑马纹靠手写 background 交替
<Box style={{ width: '100%', height: 54, background: '#F0F5FC',
flexDirection: 'row', alignItems: 'center',
borderBottomWidth: 1, borderBottomStyle: 'solid',
borderBottomColor: '#E5E7EB' }}>
<Box style={{ width: 200, paddingLeft: 20 }}>
<Text style={{ fontSize: 19, color: '#4A5568' }}>循环开始前</Text>
</Box>
<Box style={{ width: 150 }}>
<Text style={{ fontSize: 19, color: '#8B97A8', fontFamily: 'Consolas' }}>---</Text>
</Box>
<Box style={{ width: 200 }}>
<Text style={{ fontSize: 19, fontWeight: 'bold',
color: '#1E4FA8', fontFamily: 'Consolas' }}>0</Text>
</Box>
</Box>
代码演示页用 CodeBlock 组件,它带自己的语法主题与等宽字体,不是普通 Text:
jsx
// slides/06.slide ------ 代码块组件
<CodeBlock
code={`# 例:计算 1 + 2 + ... + 100
total = 0 # ① 建:累加器清零
for i in range(1, 101): # ② 取:i 依次是 1..100
total = total + i # 加:把当前的 i 累加进去
print(total) # ③ 用:循环结束后再取结果`}
language='python'
width={744}
fontSize={21}
theme='github-dark'
/>
增量修订靠 .slidep/state.json 记每页的版本与 commit,这是能反复改的基础:
json
{
"filePath": "...\\Python循环结构-试讲课件.pptx",
"lastCommit": {
"01": { "revisionId": "103cd8dc...", "version": 1, "at": "2026-09-08 12:36:59.786" },
"10": { "revisionId": "44b21f5c...", "version": 10, "at": "2026-09-08 12:46:51.559" },
"11": { "revisionId": "7a0bcb39...", "version": 12, "at": "2026-09-08 12:48:00.375" }
}
}
上面所有数字都是把 PPTX 当 zip 打开后数出来的,脚本核心就这几行:
python
# tools/_measure_pptworker.py(节选)------ 所有结论均来自真实文件
import zipfile, re
z = zipfile.ZipFile(PPTX)
x = z.read('ppt/slides/slide11.xml').decode('utf-8', 'ignore')
print('sp 形状:', len(re.findall(r'<p:sp>', x)),
'pic 图片:', len(re.findall(r'<p:pic>', x)),
'原生表格:', len(re.findall(r'<a:tbl>', x))) # 实测为 0
descr = re.search(r'<p:cNvPr id="1" name="" descr="(.*?)"/>', x, re.S).group(1)
print('源码内嵌:', len(descr), 'B')
八、把设计规则写成可校验的表
STORY.md 最有价值的地方是它把「排版感觉」写成了能跑校验的规则。实测下来全部命中:
- hero 页(视觉重页)3 个:P1 / P3 / P11,占 3/11 = 27.3%,落在 20--30% 预算区间
- 节奏序列 peak 4 个、valley 5 个、transition 2 个,最长连续 valley = 2,没出现连续三页低谷
- 任意两个 hero 之间至少隔 1 页,实测相邻 hero 对数 = 0
- 全部 11 页的关键数字都在文档里留了手算校验:5050、4950、2550、50005000
九、踩坑记录
1. 「表格」根本不是表格。 全篇 <a:tbl> 数量是 0 ,<p:graphicFrame> 是 0,图表引用也是 0。P7 那张追踪表是 43 个独立文本框拼出来的,P7 的 XML 也因此成为最重的一页(58,641 B)。后果是在 PowerPoint 里没法整表选中、改列宽要一个个拖、排序完全做不了。想导出成真表格,得在 DSL 层加 <Table> 原语。
2. 纯布局 Box 选不中。 367 个 Box 里 239 个没有 background,编译后消失。有次我想给某块区域加底色,在 PowerPoint 里怎么点都选不到它------因为它压根不存在,得回 DSL 里补 background 再编译。
3. 单位换算按 100 倍算会全错。 第一次手改 XML 时我把 fontSize: 58 写成了 sz="5800",实际是 4350。记住 px × 75,不是 × 100(1 px = 0.75 pt)。
4. descr 与形状会脱节。 因为源码被内嵌进 PPTX,一旦有人在 PowerPoint 里手工拖过形状,descr 里的 DSL 就和实际布局不一致了,下一轮 Agent 读回源码再编译会把手工改动冲掉。要么全程只改 DSL,要么接受手工改动会在下次编译时丢失。
5. 版本号断号。 .slidep/state.json 记了 11 页,版本号范围是 1...12,但 11 是缺失的 ------中间有一次提交被覆盖。想做「回退到上一版」的功能时,按 version - 1 取会取空。
6. SVG 缓存留了孤儿。 .cache/media/images/ 下有 7 个 SVG,只有 6 个被打进 PPTX,剩下的 5e7acd15....svg(176 B)成了孤儿。清理产物时要按引用表删,不能一股脑拷目录。
7. 逐字稿时间预算超标。 表格里 11 段相加是 340 秒(5:40),而文档标题写的是「总计 5:00」,超了 40 秒、13.3%。试讲卡时严格的话,必须按文档提示把 P2、P7 当提纲页快速带过。
十、实测数据汇总
| 指标 | 实测值 |
|---|---|
| 源码 → slide XML 膨胀 | 5.90 倍(435.5 KiB / 73.8 KiB) |
其中 descr 内嵌占比 |
19.6%(87,356 B) |
| zip 压缩率 | 9.9%(435.5 KiB → 44.3 KiB) |
| 形状总数 / 平均每页 | 429 / 39.0 |
| 文本形状占比 | 59.9%(257 个) |
| 产出效率 | 每 2.49 行 DSL → 1 个形状 |
| 颜色种类 / 填充总次数 | 26 种 / 927 次 |
| 字号档位 | 21 档,13 px ~ 38 px |
| 编译节奏 | 11 分 01 秒出 11 页,平均 60.1 s/页 |
顺带一个提醒:全篇最小字号是 13 px(9.75 pt),只出现 1 次。在 1280 px 画布上 13 px 只占 1.0% 宽度,投到教室投影基本看不清,试讲课件建议正文不低于 18 px。
十一、小结与下一步
回看这套做法,真正省时间的不只是「生成」,而是把课件变成了一份可 diff、可回滚、可校验的工程文件 :设计规则写在 STORY.md 里能跑校验,每页改动记在 state.json 里能定位,出图放在 preview/ 里能人眼验收。
对要自己搭这套流程的同学,我的建议是按顺序做三件事,别一上来就追求「一句话出片」:
- 先把规则写出来:hero 占比、连续低谷页数、每页字数预算、配色数量上限,写成一张能跑校验的表,比事后靠眼睛挑有效得多;
- 再把单位换算刻进肌肉:px × 9525 得 EMU、px × 75 得 sz,这两个数错了,后面所有手工微调都是白工;
- 最后一定留出验收位:渲染成图、换一双眼睛看,这一步的成本远低于在 PPT 里一个个拖形状。
这三条做完,「AI 生成 PPT」才从演示效果变成能反复用的生产方式。我下一步想补的是 <Table> 表格原语(解决第 1 条坑),以及把 descr 拆成外部 sidecar 文件,把那 19.6% 的体积还回来。
完整工程已整理好,含 11 页 DSL 源码、STORY.md 设计文档、5 分钟逐字稿和上文这个实测脚本,需要的同学评论区扣「源码」,我看到会一一回复;也欢迎关注我,后面会把课件工程化这个系列继续更下去。