拆解开源项目 OfficeCLI:让 Agent 做出不土的 PPT 动画与平滑切换
本文基于 OfficeCLI 仓库 commit 459b1a4(2026-08-04)梳理,该项目仍在高频迭代,具体行为以仓库 https://github.com/iOfficeAI/OfficeCLI 最新代码与文档为准。
让 Agent 做出的 PPT 显得土,主要不是审美问题,是「跨页连续性」在文件格式层面根本没有一个 Agent 能直接操作的载体。 OfficeCLI 是 GitHub 上的一个开源项目(Apache-2.0,NOTICE 写明 Copyright 2026 OfficeCLI,由 goworm 创建维护),它给 Agent 提供一个单二进制的 Office 文档读写命令行,不需要本机装 Office 就能改 Word/Excel/PowerPoint 文件。这里说的 Word/Excel/PowerPoint 只是文件格式和对应的应用,跟这个项目的归属没有关系——它不是微软出品,也不是泛指的”用命令行操作 Office”这一类做法。本篇只盯它的 PPT 动效那一小块:动画、切换、效果模板,外加一份专门为平滑切换写的技能包。
站内已有的 Agent 工具设计 讲的是抽象层面怎么切工具边界,AI 写作工具 讲的是内容生成侧的选型,Agent 输出约束 讲的是怎么让模型守住格式;本篇是把这三件事按在一个真实仓库上看:当工具边界、输出约束、格式纪律同时落到 PPTX 动效这个具体场景,代码里到底长什么样。
先约定两个词。OOXML 指 .pptx/.docx/.xlsx 这类文件的内部结构——它们其实是 zip 包,里面按部件(part)分成一堆 XML 文档,一页幻灯片就是一个 XML 文件。平滑切换(PowerPoint 里叫 Morph)指相邻两页之间,同一个元素从 A 位置连续移动/缩放到 B 位置,而不是硬切或淡入淡出。另外,源码里大量用 C# 的分部类(partial class)——同一个类被拆进多个文件写,编译时再合并,所以你会看到 PowerPointHandler.Animations.cs、PowerPointHandler.EffectTemplates.cs 这些文件名都在定义同一个 PowerPointHandler。
一、一条切换字符串背后,其实有三套完全不同的 XML
Agent 写 PPT 切换时给出的是一条极短的字符串,比如 transition=fade-thru-black-med、transition=wheel-8、transition=morph。PowerPointHandler.Animations.cs 里的 ApplyTransition 负责把它拆开:用 - 切段,第一段是类型名,后面每一段按内容判定是方向、速度(slow / medium / med / fast)还是毫秒整数时长。
关键在于,同样是”切换”,写进文件的形态分三类:
一类是标准的 <p:transition>,直接挂在幻灯片根下,fade、cut、dissolve、wipe、push、wheel、zoom、split 这些都走这条路。
第二类是 Office 2010 之后加的效果(源码里体现为 DocumentFormat.OpenXml.Office2010.PowerPoint 命名空间下的类型,如 VortexTransition、FerrisTransition、ConveyorTransition),以及 2013 之后的”华丽”效果——后者在 _p15PrstTokens 字典里列了 13 个:box、fallOver、drape、curtains、wind、prestige、fracture、crush、peelOff、pageCurlDouble、pageCurlSingle、airplane、origami,落盘形态是 <p15:prstTrans prst="..."/>。
第三类就是 morph,写成 <p159:morph option="byObject|byWord|byChar"/>,命名空间是 http://schemas.microsoft.com/office/powerpoint/2015/09/main。
后两类都不能裸挂,必须包进 mc:AlternateContent。这个 mc: 是 OOXML 的兼容性机制:mc:Choice 里放新版本才认识的元素,mc:Fallback 里放老版本的替代品。InsertTransitionWithMcWrapper 这个方法干的就是这件事——mc:Choice[Requires=p159] > p:transition > p159:morph,同时在 mc:Fallback 里塞一个 p:fade 兜底,再往幻灯片根节点补命名空间声明和 mc:Ignorable。也就是说,你要求 morph,文件里实际写下的是”能放 morph 就放,不能就淡入淡出”。
这一层还藏着几处只有读代码才知道的判定。transition=wheel-8 里的 8 不是时长而是辐条数——代码里对 wheel 类型专门做了拦截,1 到 32 之间的整数按辐条数吃掉;UI 里的 Clock 其实就是单辐条的 wheel。circle、diamond、plus、wedge 四个在 OOXML 里没有方向属性,你给它们加方向后缀会直接抛异常,而不是把后缀悄悄丢掉——注释里写明了理由:静默丢弃会给用户返回一个”成功”的信封,但做的根本不是用户要的事。同理,方向词是一个封闭集合,fade-xyz 里的 xyz 不在集合里就报错。p15 那批效果只接受 -in / -out,-out 只写 invX="1",注释说明这是照着 Mac 版 PowerPoint 自己写出来的形态来的,多写一个 invY 会让 PowerPoint 整个丢掉这个元素。
二、动画:三层时间树,和一个”验证先于改动”的顺序问题
形状动画的输入格式同样是一条串:EFFECT[-CLASS][-DIRECTION][-DURATION][-TRIGGER],比如 fade-entrance-300-with。ApplyShapeAnimation 逐段解析,类别词是 entrance/in/entr、exit/out、emphasis/emph,触发方式是 click/onclick、after/afterprevious、with/withprevious,此外还支持 key=value 形式的 delay、easing、easein、easeout、repeat、restart、autoreverse、chartbuild、buildtype。
写进文件的结构是三层嵌套的 <p:par>(并行时间节点):最外层是点击组,延迟为 indefinite(等点击)或 0;中间一层承载 delay;最内层才是真正的效果节点,带 presetId / presetClass / presetSubtype。BuildClickGroup 就是在拼这三层。
几个对使用者有直接影响的细节:
触发方式的自动判定会看有没有 morph。 你不显式写触发方式时,规则是”本页第一个动画 → 点击触发,后续 → 承接上一个”。但代码里加了一条例外:如果本页有 morph 切换,第一个动画也直接用”承接上一个”——判定方式是扫描幻灯片子元素里 mc:AlternateContent 的 InnerXml 是否含 morph 字样。理由写在注释里:morph 本身已经把形状显示出来了,再等点击就等于这个动画看不见。
with(与上一个同时)不是平级写一个兄弟节点。 代码把新建的中间层节点摘出来,塞进上一个点击组的子节点列表里;平级追加的话 PowerPoint 会当成顺序播放。
校验顺序被专门排过。 效果模板查找会对未知效果抛异常,这个查找被刻意放在”删除已有动画”之前——注释里记了这个 bug 的形态:早先 set animation=某个拼错的效果 会先把形状原有的动画链删掉再抛错,留下空的容器,文件校验直接变红。
时长参数只吃纯整数毫秒。 校验函数明确拒绝 2s 这种带单位的写法,报错信息里直接给出 duration=500 或 dur=2000 的正确形态。顺带一提,方法文档注释里写默认时长 500,而代码里的初值是 400——这类文档与实现的小分歧,是你照着注释推参数会踩的坑,以实际行为为准。
图表可以按系列/分类逐个进场。 chartBuild 支持 series、category、seriesEl、categoryEl,实现方式是给同一个图表生成 N 个点击组兄弟节点,共用同一个 grpId,用户在动画窗格里看到的仍是一条”按系列”。步骤枚举里用 -4 作为”整条系列/整个分类”的哨兵值。这个能力有硬边界:chartBuild 用在非图表对象上会直接报错;扩展图表(cx:chart)读不出系列/分类数量,会退回整体进场;触发方式是 with 时也不做展开。
三、效果模板:复杂动效不是”拼”出来的,是录下来的
这是这个项目在 PPT 动效上最实在的一块。PowerPoint 里那些”中等”和”华丽”级别的效果——Boomerang、Pinwheel、Curve Down、Spiral Out、Center Revolve——在 OOXML 里不是一个 <p:animEffect filter="..."> 能表达的,而是一整段 <p:childTnLst>,里面是多个动画原语,带着 ppt_x / ppt_w 这类属性公式、正弦余弦关键帧、运动路径。
PowerPointHandler.EffectTemplates.cs 顶部的注释交代得很直白:这些形态是把 PowerPoint 自己存出来的 deck 打开、把生成的内部 XML 原样抄进 Handlers/Pptx/EffectTemplates/*.xml,作为嵌入资源编译进二进制。应用时做四件事:按 (效果名, 类别) 查注册表拿到资源名和 presetId、读出模板文本、把 {SPID} 换成目标形状 ID、把 {ID0}..{IDn} 占位符换成本页唯一的时间节点 ID;图表逐元素展开时再额外往每个 <p:spTgt/> 里注入 <p:graphicEl><a:chart .../></p:graphicEl>。
这套做法的代价也写在代码里。你传进去的时长只会改写 {ID0} 那个节点的 dur 属性,模板内部的子时长(自动反向的脉冲、各段子关键帧)保持 PowerPoint 当初存出来的原样,也就是说整段动效的节奏不会按比例一起缩放——你把 boomerang 从默认调到两倍长,变长的只是最外层那条时间线,里面的弹跳还是原速。顺带一提,这个文件顶部的总览注释还停留在”时长覆盖尚未参数化、模板自带时长原样保留”的旧描述上,与下面 RenderEffectTemplate 里已经写好的覆盖分支对不上;照注释推断行为会得到错误结论,以实际代码为准。这类注释滞后于实现的情况在这块代码里不止一处,前面动画默认时长的 500 与 400 之争是同一种。另外解析时必须用外层 XML 构造,不能用 InnerXml= 赋值:后者会把包装元素上的命名空间声明剥掉,注入 <a:chart> 时会直接报 a 前缀未声明。
模板目录下现在有 31 个 xml(ls src/officecli/Handlers/Pptx/EffectTemplates/ | wc -l 数得到),拆开看是 15 个 emph_ 开头的强调效果、16 个 exit_ 开头的退出效果——没有一个 entrance 模板。也就是说进场效果目前全部走”预设 ID + filter 字符串”的简单路径,而退出和强调里那些复杂的才用录制模板。注册表里还留着两类历史包袱:bold / boldflash 这两个旧别名被路由到 fillColor 模板上,读回来时统一返回 fillColor;spin 的强调效果早先写的是预设 27,PowerPoint 自己生成的形态是预设 8 加一个 <p:animRot>,注释一句”Template wins”作结。
| 组成部分 | 它负责什么 | 仓库位置 | 你什么时候会碰到它 |
|---|---|---|---|
| 切换解析与写入 | 把一条切换字符串拆成类型/方向/速度/时长,再决定写成标准元素、p15 预设还是 p159 morph,并按需套 mc:AlternateContent | src/officecli/Handlers/Pptx/PowerPointHandler.Animations.cs | 给某一页设置切换效果时 |
| 切换旁属性 | advanceTime / advanceClick 写到哪个 transition 元素上,含被包在 AlternateContent 里的情况 | src/officecli/Handlers/Pptx/PowerPointHandler.Helpers.Transition.cs | 要做自动播放、或禁掉点击翻页时 |
| 形状动画时间树 | 把 fade-entrance-300-with 变成 p:timing 下三层 par 结构,处理触发、延迟、缓动、重复、图表展开 | 同上 PowerPointHandler.Animations.cs(ApplyShapeAnimation / BuildClickGroup) | 给单个形状加进入/退出/强调动画时 |
| 效果模板 | 复杂动效直接使用 PowerPoint 存出来的 OOXML 片段,占位符替换后塞回时间树 | src/officecli/Handlers/Pptx/PowerPointHandler.EffectTemplates.cs + src/officecli/Handlers/Pptx/EffectTemplates/(31 个 xml) | 用 boomerang、pinwheel、pulse 这类效果时 |
| morph 技能包 | 跨页命名纪律、幽灵位、交付闸门、52 套视觉风格参考 | skills/morph-ppt/SKILL.md 与 skills/morph-ppt/reference/ | 让 Agent 做一整套带连续动画的 deck 时 |
四、morph 技能包:把”名字”变成工程纪律
skills/ 下有 11 个技能目录(含 morph-ppt、morph-ppt-3d、officecli-pptx、officecli-docx、officecli-xlsx、officecli-pitch-deck 等),每个目录一份 SKILL.md。morph-ppt 这个目录整体 111 个文件,其中光是 reference/styles/ 就有 52 个风格目录。
它要解决的问题只有一个:PowerPoint 的 morph 引擎是按相邻两页上”形状名完全相同”来配对的,名字对不上就没有动画,静默退化成淡入淡出。 这不是命令行的某个开关,是一条工作流纪律。技能包给出的做法是三段命名空间:!!scene-* 是贯穿全片的装饰、!!actor-* 是跨若干页演进后退场的内容、#sN-* 是第 N 页专属内容。退场不能删除形状——删了就没有配对、动画消失——而是把它挪到画布右边缘之外的 x=36cm(画布本身是 33.87×19.05cm),技能包管这叫 ghost。
CLI 这边有一处与之强耦合的行为,AutoPrefixMorphNames 写得很清楚:给某页设置 morph 之后,当前页和上一页所有有名字的形状都会被自动加上 !! 前缀(已有前缀的、以及 TextBox /Content 开头的默认名会跳过),而且通过 Descendants<Shape>() 遍历,组合内部的形状也会被改到。技能包里对应记了两条:@name= 路径选择器仍然能命中不带前缀的原名,但读回来的名字是带前缀的形态;在 jq(处理 JSON 的命令行工具,这个 CLI 的 --json 输出基本都靠它过滤)里挑”场景演员”时不能用 startswith("!!"),因为自动加前缀之后普通内容也带 !!,得用 startswith("!!actor-") 或 startswith("!!scene-")。
技能包里还有几条判断值得单独拎出来。相邻两页必须有可见差异,位移 ≥ 5cm 或旋转 ≥ 15° 或尺寸变化 ≥ 30%,否则 morph 无可插值,直接塌成淡入淡出;交付闸门比这条经验规则更严,硬性要求至少 3 个不同的 !! 形状各自至少有一项属性变化,并且明确排除了固定不动的品牌页眉页脚。同一对 morph 里所有形状同时运动,没有逐形状的延迟旋钮——要错开就得插一页中间关键帧。以及一条节奏建议:不是每页都加 morph 才叫电影感,12 到 18 页的正式场合建议全片只留 3 到 5 次,放在章节分隔处当标点。
五、边界与代价:它放弃了什么
它不渲染,也不校验运动效果。 技能包里明确写了:morph 只在 PowerPoint 365、Keynote、WPS、PowerPoint Online 里跑得起来,LibreOffice Impress 和 Google Slides 网页端会退化成静态或淡入;项目自带的 HTML 预览是结构性的,morph 是运行时特性,预览里看不到。所以静态截图无法验证 morph 的运动质量,只能证明配对关系对不对。这被归为渲染器行为而非工具缺陷,技能包要求把这句话原样告诉用户。
动效表达能力是”PowerPoint 能录下来什么就有什么”。 简单效果靠预设 ID 加 filter 字符串合成,复杂效果靠录制的 XML 片段。这意味着两件事:模板没覆盖的效果就没有(比如目前没有 entrance 类模板);模板里的时长等参数只能改最外层,内部关键帧改不动。
morph 的连续性完全依赖命名,工具本身不做校验。 项目里的结构检查脚本会数画布外的形状数量(超过 50 个就判定为幽灵堆积并拒绝),但它检测不出某个 !! 形状还赖在可见区域里——技能包直说这一条只有截图审查和逐页 jq 循环能抓到。
它会直接改你磁盘上的真实文件。 这一点必须说清楚:命令作用在你传进去的那个 .pptx 路径上,不是副本;切换写入前会先把幻灯片上已有的 transition 和 AlternateContent 子元素全部移除,transition=none 同样会连 morph 的包装一起删掉,这些都是不可逆的原地修改。项目有常驻进程模式,open 之后编辑都在常驻里,基础技能包写明:save 才把改动刷到磁盘并保持常驻,close 是刷盘并立即释放,只有在非本工具的程序(PowerPoint、渲染器、其他脚本)要读这个文件之前才需要刷。反过来说,没执行到刷盘那一步之前,磁盘上还是旧文件;而多命令的构建脚本跑到一半失败,文件会停在中间状态——已经写进去的那几页切换和动画都在,后面的没有。技能包还提醒了一个具体的踩法:构建脚本运行期间 .pptx 会被反复重写,想看进度用它的监听模式,别在构建途中用系统应用打开文件,会撞上文件锁。
它不管内容和审美。 名字对齐、幽灵清理、闸门全绿,只能保证”动起来了且没有脏东西”,不保证这一页该讲什么。风格库那 52 套的说明也很坦率:坐标是针对各自演示内容手调的,照抄到别的内容上会重叠错位,参考的是配色与设计逻辑,不是模板。
六、上手与避坑清单
别凭命名习惯猜参数名,先查内置帮助。 会踩是因为这类 CLI 的参数名往往和你熟悉的其他库不一样,猜错时有些路径是报错、有些是把无法识别的段落丢进警告里继续执行。技能包给的规矩是:拿不准就先跑 officecli help pptx <元素>,帮助反映的是你装的这个版本,技能文档与帮助冲突时以帮助为准。
时长和速度不要发明独立参数。 会踩是因为 morph.duration= / transition.delay= 这种写法看起来天经地义,但它们会被当成不支持的属性拒绝。正确形态是写在切换值里的组合简写:transition=morph-slow、transition=morph-fast、transition=morph-1500。
shell 里的 !! 和 $ 必须单引号。 会踩是因为 bash/zsh 的历史展开会吃掉未加引号的 !!foo,而双引号里的 $9/mo 会被当成空变量,最后文件里只剩 /mo 或者一个孤零零的句点。做法是所有含这两个字符的属性值一律单引号,批量操作直接用单引号定界的 heredoc 关掉全部展开。
每一页都要重新清一次幽灵。 会踩是因为直觉上”我上一页已经把它挪出去了”,但每一页的形状列表是独立的,第 N 页挪出画布不会延续到第 N+1 页——忘了重挪,它就在原位置重新出现。做法是把”加完内容立刻循环挪走所有不该出现的演员”写进构建循环里。
别用不存在的选择器语法。 会踩是因为 shape[name^=!!actor-] 这种前缀匹配写法在别的查询语言里很常见,但这里的查询只支持 =、!=、~=、>=、<=、>、<,用 ^= 会返回无效选择器错误。做法是改成逐页取子节点再用 jq 的 startswith() 过滤。
自动播放时长要写纯整数毫秒。 会踩是因为 5s 看起来合理,而 OOXML 那个属性只接受非负整数;这个项目现在会直接报错,但注释里记着更早的行为——格式错误的值会静默落盘,PowerPoint 打开时悄悄丢掉。清空定时用 none。
读回来的值和写进去的不一定字面相同。 会踩是因为你会拿写入值去断言读回值。实际上带触发后缀的动画读回来会掉后缀,morph 页上的形状名读回来多了 !! 前缀,bold 读回来是 fillColor。做校验时按语义比对,别按字符串全等。
注意一次改动会波及上一页。 会踩是因为你以为只在改第 N 页,但设置 morph 会连第 N-1 页的形状名一起改写。如果那一页你手工调过名字,先确认改名不会打断别的配对关系。
真正决定”Agent 做的 PPT 土不土”的,不是它会不会调动画命令,而是它有没有一套跨页的对象身份约定,以及有没有人在交付前把幽灵和配对关系检查一遍。OfficeCLI 的做法是把前者放进技能包的命名纪律,把后者放进一组能跑的检查循环,而 CLI 本身只负责把字符串忠实地翻译成 XML 并在翻译不了时报错。
想继续往下读,建议按这个顺序:skills/officecli-pptx/SKILL.md 拿到基础规则(视觉底线、栅格、常驻与刷盘、交付闸门 1–5a),再回到 skills/morph-ppt/SKILL.md 看它在上面加了什么;想弄清某个效果到底写了什么 XML,就去 src/officecli/Handlers/Pptx/EffectTemplates/ 挑一个 xml 直接看——那是 PowerPoint 自己写出来的形态,比任何二手描述都准。做的是更偏内容侧的选型,可以对照站内的 AI PPT 工具 一起看。
本篇属于一个把开源Office 文档读写套件 OfficeCLI逐层拆开讲的系列,整体地图见 OfficeCLI 是什么:给 AI Agent 用的开源文档读写套件;沿着这条线往下,还可以看 拆解开源项目 OfficeCLI:PPT 处理器为什么比 Word 难做 和 OfficeCLI 开源项目:往 PPT 里塞三维模型做到了哪一层。