OfficeCLI 开源项目:往 PPT 里塞三维模型做到了哪一层

2026-08-05

本文基于 OfficeCLI 仓库 commit 459b1a4(2026-08-04)梳理,该项目仍在高频迭代,具体行为以仓库 https://github.com/iOfficeAI/OfficeCLI 最新代码与文档为准。

判断一个文档读写库的格式支持有多深,最省事的探针不是它能不能加一段文字,而是它碰到私有扩展时是抄一段模板了事,还是老老实实把几何算了一遍。 开源项目 OfficeCLI(GitHub 上的一个 Apache-2.0 仓库,不是微软产品)处理 PPT 三维模型的这三块代码,属于后者:它自己解析 GLB 的包围盒、自己推相机距离、自己拼一张兜底 PNG 的字节,连 CRC32 都是手写的。

先做两个消歧。OfficeCLI 是 GitHub 上的一个开源项目(https://github.com/iOfficeAI/OfficeCLI ),许可证 Apache-2.0,NOTICE 文件写明 Copyright 2026 OfficeCLI,由 goworm 创建维护。它不是微软出品,与微软没有任何从属、授权或官方合作关系;本文出现的 Word/Excel/PowerPoint 只指文件格式和打开这些文件的应用。另外它也不是泛指的”用命令行操作 Office”这类做法——下文所有目录、类名、属性名,都指这一个具体仓库里的东西。

站内已有几篇相邻的文章,分工不同:Pascal Editor 的材质与主题 讲的是另一个项目里三维材质在编辑器侧怎么组织,关注点在渲染表现;给 Agent 设计工具的通则 讲的是工具接口抽象,不涉及任何具体文件格式;AI 建站工具横评 是选型视角的对比。本篇只做一件事:把 OfficeCLI 仓库里三维模型这一段的代码路径读透,看它在二进制文件里到底写了什么。

一、这块要解决的问题:一段没有开放标准兜底的私有扩展

.pptx 本质是个 zip 包,里面按 OOXML 规范摆着一堆 XML 分片和二进制资源,这套约定叫”包结构”——每个分片(part)有自己的内容类型,分片之间靠关系(relationship)互相引用,引用时写的是一个 rId 而不是路径。

麻烦在于,PPT 的三维模型不在 OOXML 标准里。它走的是一个私有命名空间,仓库中把它定义成常量 Am3dNs,值是 http://schemas.microsoft.com/office/drawing/2017/model3d;模型分片的关系类型是 http://schemas.microsoft.com/office/2017/06/relationships/model3d;内容类型常量 GlbContentType 写的是 model/gltf.binary,源码里专门留了一行注释说明 PowerPoint 用的是点而不是横杠。这三个字符串错任何一个,文件打开就是”需要修复”。

私有扩展还带来第二个问题:不认识它的阅读器怎么办。OOXML 的答案是 mc:AlternateContent——一个”备选内容”容器,里面放一个声明了所需能力的 mc:Choice,再放一个谁都能渲染的 mc:Fallback。三维模型这段就是靠它撑住的:Choice 上标 Requires="am3d",里面是真正的模型;Fallback 里是一张静态图片。

代码组织上,这块用了 C# 的分部类(partial class,同一个类的定义拆到多个文件里,编译时合并)。src/officecli/Handlers/Pptx/ 下有 64 个 .cs 文件,其中 54 个是 PowerPointHandler.*.cs 形式的分部类切片,三维模型占了其中两个。

组成部分它负责什么仓库位置你什么时候会碰到它
模型插入校验 .glb、嵌入分片、拼 mc:AlternateContent、补命名空间声明src/officecli/Handlers/Pptx/PowerPointHandler.Add.Model3D.cs每次往某一页加模型
包围盒与相机解析 GLB 场景图算 AABB,推 meterPerModelUnitpreTrans、相机 Z 距离同上文件内的 ParseGlbBoundingBox / BuildModel3DElement模型偏心、太大太小、看不全时
兜底图与节点读回手写 1×1 PNG 占位图;把 AlternateContent 反向读成节点src/officecli/Handlers/Pptx/PowerPointHandler.Helpers.Zoom3D.cs用 get/query 读回模型属性时
三维资源来源Three.js 与 GLTFLoader 的镜像优先、CDN 兜底策略src/officecli/Core/ThreeAssets.cs生成 HTML 预览、需要离线时
HTML 预览渲染GLB 内联为 base64、去重、三级降级src/officecli/Handlers/Pptx/PowerPointHandler.HtmlPreview.Shapes.csRenderModel3D想在浏览器里先看一眼效果
属性契约登记哪些属性能 add/set/get、读回什么形状schemas/help/pptx/model3d.json拿不准参数名时
编排规则每页重新加模型、不许克隆、镜头语言skills/morph-ppt-3d/SKILL.md让 Agent 批量生成整套片子时

二、模型插进去:一次写两份,还要改幻灯片的根节点

AddModel3D 的流程是线性的,值得逐段看,因为每一步都对应一个别处踩过的坑。

先取来源。属性里认 path,取不到再认 src,两个都没有就报错说 'src' property is required for 3dmodel type。取到之后交给 OfficeCli.Core.FileSource.Resolve,它同时支持本地路径、HTTP(S) URL 和 data URI,返回一个可定位的流和文件扩展名。扩展名不是 .glb 直接拒绝,错误信息里明说只支持 glTF-Binary。

第二步是父路径校验。正则只认 ^/slide\[(\d+)\]$,也就是模型必须直接挂在某一页上,不接受更深的容器路径,超出页数会报”Slide N not found”。

第三步才是有意思的地方:在把文件写进包之前,先解析 GLB 算包围盒。第四步用 AddExtendedPart 把 .glb 塞进这一页的分片里——这个调用要把关系类型、内容类型、扩展名三样都手工传进去,走的是通用分片通道,而不是像图片那样有现成的强类型部件可用。

第五步生成兜底图。GenerateZoomPlaceholderPng 在 Zoom3D 那个文件里,它不调任何图像库,而是直接按 PNG 规范拼字节:签名、IHDR(1×1、8 位、RGBA)、一段预先算好的 deflate 数据(对应一个浅灰像素)、IEND,每个块的长度按大端写、CRC32 用 0xEDB88320 多项式当场算。注释里写明 PowerPoint 打开文件时会自己重新生成真正的缩略图,所以这里只需要一张”结构合法”的图。

第六步是位置与尺寸。默认 3600000 EMU 见方(EMU 是 OOXML 的长度单位,源码注释标为约 10 厘米),并按幻灯片尺寸居中;width/height/x(别名 left)/y(别名 top)可以覆盖。

最后是拼 XML。这里有两个容易被忽略的细节。一是模型侧用的是 p:graphicFrame 而不是 p:sp,源码注释说明这是与 zoom 元素以及 PowerPoint 原生行为对齐。二是 ChoiceFallback 两边写的是同一个 a16:creationId GUID,挂在 uri 为 {FF2B5EF4-FFF2-40B4-BE49-F238E27FC236} 的扩展节点下——两边视作同一个对象,这对后面的平滑切换很关键。Fallback 里的 p:pic 还把 picLocks 的十个开关全部置 1,从 noGrpnoRotnoCrop,等于告诉降级阅读器”这张图别让用户动”。

写完形状还没结束:还要回到幻灯片根节点上补 am3dmc 的命名空间声明,并把 am3d 追加进 mc:Ignorable 列表——Ignorable 是告诉阅读器”这个前缀你不认识就跳过”的白名单,漏了它,不支持三维的客户端会直接判文件损坏而不是安静降级。函数最后返回新元素的路径,形如 /slide[N]/model3d[M]

三、三维缩放:没有渲染器,怎么把相机放到该在的位置

这是三块里技术含量最高的一段,也是最能看出深度的一段。

ParseGlbBoundingBox 做的是一件正经的图形学工作。GLB 文件开头是二进制头加一个 JSON 块,它先用 BinaryReader 读出 JSON 并解析。第一遍扫 meshes,对每个图元取 attributes.POSITION 指向的 accessor,从 accessor 的 min/max 直接拿局部包围盒——这是 glTF 规范允许省掉遍历顶点的地方。第二遍从 scenes[0] 的根节点开始递归遍历场景图,每个节点的局部变换支持两种写法:直接给 16 个数的 matrix,或者给 translation/rotation/scale 三件套(其中旋转以四元数存储——四个数表示一次绕任意轴的旋转,比欧拉角少了万向锁的麻烦,代码里把它展开成旋转矩阵后再乘缩放和平移)。父子矩阵按 4×4 列主序相乘(列主序指矩阵的十六个数在数组里按列排列,取第 j 列第 i 行要写 m[j*4+i]),把每个 mesh 包围盒的 8 个角点变换到世界空间再累加,得到最终的 AABB(轴对齐包围盒,就是一个能罩住整个模型、边平行于坐标轴的盒子)。整段套在 try/catch 里,任何解析失败都回落到一组中性默认值,而不是抛出去打断整条流水线。

拿到包围盒之后,BuildModel3DElement 用它推三个数:

  • meterPerModelUnit(记作 mpu)取 1 / maxExtent,也就是把模型最长边归一化成 1,写成分子分母都在的比值形式,分母固定 1000000;
  • preTrans 把模型中心平移回原点,位移量是 -中心坐标 × mpu × 36000000
  • 相机 Z 距离用 radius / sin(fov/2),其中 radius 是半尺寸向量的模乘上归一化系数,fov 硬编码为 2700000(这个单位是 1/60000 度,换算下来是 45 度)。

旋转全部以 1/60000 度存储,ParseAngle60k 负责把用户给的度数乘 60000。属性上既接受合并写法 rotation="ax,ay,az",也接受 rotx/roty/rotz 单轴覆盖,源码注释说明合并写法是为了与读回格式对齐——Model3DToNode 读回时正是把三个属性除以 60000 再拼成同样形状的字符串,一个 get 出来的值可以原样 set 回去。

剩下的是照抄原生结构的部分:raster 节点标着渲染器名 Office3DRenderer 与一个固定的版本串,指向那张占位 PNG;ambientLightscrgbClr 写 50000/50000/50000 的灰,照度 500000/1000000;然后三盏 ptLight 的颜色、强度、位置全部是写死的数字,由 AddPointLight 逐一填进去。

这块里最该看一眼的是 objViewportviewportSz。源码注释直说:PowerPoint 是通过一次紧贴包围的三维渲染来算这个值的,而这里没有渲染器,所以取 max(cx, cy) 近似,注释给出的说法是相对 PPT 原生结果误差不超过 6%。这一行是整块代码的诚实标记——它没装作自己有渲染器。

四、资源这一块:Three.js 从哪来,GLB 怎么进 HTML

前两块管的是写进 .pptx 的内容,第三块管的是”在浏览器里先看一眼”。

src/officecli/Core/ThreeAssets.cs 是个只有几十行的静态类,但策略密度很高。它把 Three.js 的版本钉成一个常量,镜像地址与公共 CDN 地址都由这个常量拼出来:镜像在 https://d.officecli.ai/assets/three-<版本> 下,同时托管 build/three.module.js 和整棵 examples/jsm/ 子树;CDN 兜底走 jsdelivr。类注释里点明这个策略和仓库里 KaTeX 的取数策略保持一致——自家镜像优先,公共 CDN 兜底。

ImportMapJson 生成的是一份 import map(浏览器原生的模块地址映射表,让代码里写 import 'three' 这种裸名字也能解析)。它映射两条:three 指向镜像的核心模块,three/addons/ 指向镜像的 examples/jsm/,前缀映射意味着任何 addon 模块都能跟着走。

难点在于 import map 没有”这条失败就换那条”的机制。仓库的处理办法是把重试挪到动态 import() 的调用点:先 await import('three') 走镜像,catch 到失败就改从 jsdelivr 的 /+esm 端点导入。这个端点的产物已经把内部 import 重写成绝对 URL,因此天然绕开 import map,而且核心模块与 GLTFLoader 共享同一张模块图——类注释特意说明这是为了让两边的 THREE 实例保持一致,否则加载器造出来的对象核心模块认不出。

预览侧的 RenderModel3D 还有几处工程细节值得抄走:

GLB 是整个 base64 内联进 HTML 的(window._glbN 这样的全局变量),用内容 SHA256 的前 16 位十六进制做键去重,同一个模型在多页出现只内联一份。缓存 _glbDataCache 和计数器 _model3dCounter 是静态字段,所以配了一个 ResetModel3DRenderState,源码注释直接写明不重置的话第 N+1 次调用会命中上一次的缓存。

渲染容器是三层叠的:一个虚线边框的占位 div,里面居中放一行标签(有文件名时显示成 “3D Model: 文件名”),一个 canvas,以及一张默认隐藏的兜底 img。降级路径有三级:镜像失败换 CDN,CDN 也失败就隐藏 canvas、显示兜底图,WebGL 初始化抛错同样切兜底图。

需要留个心眼的是兜底图的取用条件:预览代码只有在读到的字节数大于 200 时才会把它拼成 data URI。而前面那张手拼的 1×1 占位 PNG,按签名 8 字节加三个块的固定长度数下来只有几十字节。这两个事实摆在一起意味着,自动生成的占位图在 HTML 预览里够不着这个门槛,离线时你看到的会是那个虚线框加文件名标签,而不是一张灰图。你可以自己把那几个块的字节数加一遍核对。

还有一点容易误会:预览里的相机和灯光跟写进 .pptx 的那套不是同一组参数。预览用的是 Three.js 的透视相机(45 度视场)、一盏环境光加三盏方向光,模型按最长边缩放到 2.0,相机固定放在 z=3.2,并且每帧给 Y 轴加一个小增量做自动旋转。预览好看不等于 PowerPoint 里好看,反过来也一样。

五、边界与代价:它明确不管的那些事

这套设计换来的东西很清楚,放弃的东西同样清楚。

只认一种格式。 .fbx.obj.blend.usdz 一律拒绝,连 .gltf(分离式的 glTF,模型 JSON 与贴图分成多个文件)都不收。技能文件 skills/morph-ppt-3d/SKILL.md 给的处理办法是让用户先用 Blender 之类导出成 .glb。好处是省掉了整条资源打包链路,代价是你得在流水线更早的位置解决格式转换。

它不渲染。 写进 .pptx 的 raster 节点指向的是那张 1×1 灰图,真正的缩略图要等 PowerPoint 打开文件时自己生成。这意味着在没装 PowerPoint 的环境里,这个 .pptx 的三维部分只有结构、没有预览成品。viewportSz 取近似值也是同一个原因的延伸。

换模型这条路不通。 schemas/help/pptx/model3d.jsonsrc 登记的是 add 与 set 都可用,但 SetModel3DByPath 的分支里只处理 x/y/left/top/width/height/name/rotx/roty/rotz/rotation,其余键会落进 default 分支进入返回的 unsupported 列表。要换模型,按仓库里的做法是先 remove 再 add。同一份 schema 还写明 src 的 get 为 false——读回一个模型节点时,你拿不到它引用的是哪个文件,也拿不到 relId。

读写的是你磁盘上的真文件。 这个工具直接改原始 .docx/.xlsx/.pptx,不是先复制一份再改。非常驻模式下每次改动都会即时落盘;常驻进程模式下不一样,进程把改动攒在内存里,只在执行 save、关闭、或空闲自动刷盘时才真正写文件——save 命令的说明里写着空闲自动刷盘是 2 到 10 秒的自适应间隔,可以用环境变量 OFFICECLI_RESIDENT_FLUSH 调成 eachauto、具体秒数或 off。这段时间窗里,用别的程序直接读这个文件,读到的还是改动前的版本。批量往一份 deck 上加多页模型时如果中途失败,前面已经写进去的分片和形状都留在文件里,不会回滚——这是改动边界要提前约定清楚这类规矩存在的原因。

外部资源是一个暴露面。 模型来源允许是 HTTP(S) URL,这条路上 FileSource 挂了 SSRF 防护(SSRF 指诱导服务端去访问它本不该访问的地址,比如内网或云元数据接口):连接时强制校验公网 IP、拒绝回环与私有地址、请求超时 30 秒、远程文件上限 100 MB,并且对声称的 Content-Length 和实际读取都做了限制。防护做得挺细,但你仍然要清楚:只要模型路径来自模型自己生成的指令,这就是一次由 LLM 决定目标地址的对外请求。技能文件里那套模型发现流程会去搜索站点、调 Sketchfab 的搜索接口、从 Khronos 的示例仓库直接下载,明确要求下载前先让用户确认,并提醒检查许可证(CC0 / CC BY 可用,CC BY-NC 仅限非商用)。

它不管三维模型本身好不好看。 材质、贴图、拓扑、面数,全在这套代码的射程之外——模型质量得在建模环节解决,这跟三维编辑器里怎么组织材质与主题是完全不同的两层问题。

六、上手与避坑清单

下面几条都能在仓库里找到对应依据,每条给的是”为什么会踩”,不是单纯的提醒。

别克隆带模型的页。 skills/morph-ppt-3d/SKILL.md 用加重语气写了这条:克隆幻灯片会把模型当作冻结的 XML 一起复制,复制出来的模型没法参与平滑切换(Morph,PowerPoint 的一种过渡效果,靠在相邻两页配对同名对象再插值来产生连续位移),而且新页上会出现两个同名的 model3d,PowerPoint 处理不了这个冲突,会在修复文件时把模型内容删掉。会踩是因为”复制一页再改改”是所有人做 PPT 的默认动作。避法是先建空页再逐页加模型;如果非克隆不可,克隆完立刻 remove 掉那个冻结副本再 add 新的。

每页的 name 必须一模一样。 平滑切换靠名字配对,名字对不上就变成整块淡入淡出。会踩是因为循环生成时很容易把序号拼进名字。仓库示例里的做法是全片共用一个固定名字,位置和旋转逐页变。

不要手动改那几个自动算出来的值。 技能文件列了明确的黑名单:meterPerModelUnitpreTrans、相机深度与位置都不要手动设,也不要用 raw-set 去改任何三维变换参数。会踩是因为它们在 XML 里看着就是普通数字,改起来毫无阻力;一旦改了,第三节里那串互相咬合的推导就断了,表现是模型偏出画面或者小得看不见。

位置属性改一处要改两处。 SetModel3DByPath 在改坐标尺寸时,会同时更新 Choice 里的 xfrm 和 Fallback 里 pic 的 xfrm。如果你绕过它直接改 XML,很容易只改一边,结果是支持三维的客户端里位置对,降级客户端里位置错。

旋转别用小步长。 技能文件给的镜头规则是相邻页 Y 轴旋转差控制在 30 到 90 度之间——小于 30 度看起来像抖动,大于 90 度会让人失去方向感;X 轴倾斜限制在 -25 到 +40 度,相邻差不超过 20 度。会踩是因为”每页转 10 度更平滑”是很自然的直觉,实际出来是画面在轻微抽搐。

相邻两页的模型面积要拉开。 同一份文件把这条标成强制:相邻页模型面积比要么大于等于 1.5 倍,要么小于等于 0.67 倍。会踩是因为对齐网格的排版习惯天然会让相邻页尺寸接近。

远程模型先落到本地再插。 走 URL 时超时是 30 秒、上限 100 MB,一次批量生成里每页都重新拉一遍远程地址,既慢又把失败概率乘了 N 次。先下载一次、后续引用本地路径,顺便也让整个流程可离线复现——这一点和给 Agent 设计工具时该怎么切边界是同一个道理:把不确定性收敛到一次调用里。

清理要用 remove,别手删 XML。 PowerPointHandler.Mutations.cs 里删 model3d 的分支会顺带把 blipmodel3d 上 embed 指向的分片一起 DeletePart,也就是 .glb 和占位图会跟着走。手删 XML 只会留下一堆没人引用的孤儿分片,文件越滚越大。

收束:接下来该读哪个文件

如果你要把这套思路搬到自己的工具里,我建议的阅读顺序是:先读 schemas/help/pptx/model3d.json,它是最短的入口,能一眼看清对外契约长什么样;再读 PowerPointHandler.Add.Model3D.csAddModel3D,走一遍完整流程;然后单独精读 ParseGlbBoundingBoxBuildModel3DElement,这两个函数是整块的技术核心;最后去 examples/ppt/ 下看 3d-model.md 与配套的 .sh.py,那是一份可以直接跑起来的八页示例,模型文件就在同目录的 models/ 下。

自检的时候可以问自己三个问题:写出来的文件在不支持三维的阅读器里会不会变成”需要修复”(对应 mc:Ignorable 有没有补上);每页模型的名字是不是完全一致(对应平滑切换能不能配对);有没有哪个数字是你手填进去而本该由包围盒推出来的(对应那份黑名单)。这三个问题答清楚了,这块的坑基本就绕过去了。

顺带一句,这类”用命令行拼 PPT”的路子和市面上那些 AI 生成 PPT 的工具解决的不是同一类需求——前者要的是可复现、可版本化、可被 Agent 精确控制,后者要的是一句话出片。选哪条路,取决于你更怕结果不可控,还是更怕流程太重。

本篇属于一个把开源Office 文档读写套件 OfficeCLI逐层拆开讲的系列,整体地图见 OfficeCLI 是什么:给 AI Agent 用的开源文档读写套件;沿着这条线往下,还可以看 拆解开源项目 OfficeCLI:让 Agent 做出不土的 PPT 动画与平滑切换开源项目 OfficeCLI 的 Word 结构操作:章节、目录、页眉页脚与导航各自成块

想系统学会用 AI?报名体系课或加入会员,照着学、照着用。