开源项目 OfficeCLI 的文档节点模型:三类文档共用一套接口

2026-08-05

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

这套抽象真正的设计动作,不是去找 Word、Excel、PowerPoint 三种格式的最大公约数,而是先把节点结构缩到只剩八个会序列化的字段,再把所有格式差异塞进节点上那一个字符串键的属性字典,最后用一层带 schema 校验的属性回退兜住字典里没有覆盖的长尾。 公共形状越小,Agent 的心智模型才越可能只有一套。

先做个消歧。OfficeCLI 是 GitHub 上 iOfficeAI/OfficeCLI 这个 Apache-2.0 开源仓库的专有名字,NOTICE 文件写明 Copyright 2026 OfficeCLI、由 goworm 创建维护。它不是微软的产品,与微软没有从属或授权关系,也不是「用命令行操作 Office」这类做法的泛称;文中提到 Word / Excel / PowerPoint 时指的是 .docx / .xlsx / .pptx 这三种文件格式和对应的桌面应用,不是这个项目的归属。规模上,全仓 1201 个受版本控制的文件,src/officecli/ 下 469 个(其中 353 个 .cs),schemas/ 153 个(152 份 json),skills/ 下 11 个技能目录各带一份 SKILL.mdexamples/ 381 个,sdk/ 下 node 与 python 两套,assets/ 37 个,根目录放着 en / zh / ja / ko 四版 README 和一份根级 SKILL.md

站内相邻的几篇分工不同:Pascal Editor 的节点模型讲三维场景编辑器怎么把几何体组织成节点树,是空间域的抽象;结构化输出为什么不稳讲模型侧输出格式的可靠性;AI 参与数据库设计讲 schema 建模本身。本篇只盯一件事——一个已经存在的开源工具,怎么把三种互不相干的二进制文档格式收敛成同一套读写接口。

先说清楚这三种文件到底是什么。.docx / .xlsx / .pptx 都是 OOXML 包:外层是一个 zip 压缩包,里面装着若干份 XML 文件(项目里管这些叫 part,部件),正文、样式表、图表、图片关系各占一份或几份。三种格式共用这个打包约定,但内部 XML 语汇彻底不同——Word 里是段落和 run(一段内格式相同的连续文字片段),Excel 里是工作表和单元格,PowerPoint 里是幻灯片和形状。

对写脚本的人来说这意味着三套 API、三套「怎么定位到那个东西」的语法。对 Agent 更糟:每换一种文件类型就要重新装载一套工具描述、一套参数命名、一套错误语义,上下文预算被同一件事消耗三遍,而三套里任何一套写错都表现为「文件坏了」这种不可诊断的失败。

OfficeCLI 的答案是:三种格式共享同一个节点类型、同一个处理器接口、同一套选择器语法,格式差异只允许出现在两个地方——节点的 Type 字符串,和节点的 Format 属性字典。

一、DocumentNode:八个序列化字段撑住三种文档

src/officecli/Core/DocumentNode.cs 全文只有四十来行,类注释一句话说明定位:文档 DOM 树里的一个节点,是跨 Word / Excel / PowerPoint 的通用抽象。会序列化出去的字段一共八个——path(定位串,形如 /slide[1]/shape[1])、typeparagraphcellshaperevisionsparkline 都是它的取值)、textpreviewstylechildCountformatchildren

关键在 format。它不是强类型模型,是一个 Dictionary<string, object?>:字符串键、装箱值。Excel 的 sparkline 节点往里写 colornegativeColorhighPointlowMarkerColorlineWeight;PowerPoint 的段落节点往里写 eaLnBrklatinLnBrkfontAlgndefTabSz 这些 OOXML 属性名的原样映射。两边的键毫无交集,节点类型却是同一个 C# 类。

还有第九个字段 InternalFormat,标了 [JsonIgnore],不出现在用户可见的 Format 里。注释写得很坦白:用来在图表 Reader 和批处理发射器之间搬运原样的 OOXML 片段(axisTitle.pPrcatTitle.pPr、系列级的 spPr),目的是不污染公共的 DocumentNode 形状。这是这套设计里最值得学的一处——承认有些东西塞不进公共抽象,就单开一条不对外的通道,而不是把公共形状撑大。

序列化直接走这个类。src/officecli/Core/OutputFormatter.csFormatNode--json 就是一句 JsonSerializer.Serialize(node, ...);纯文本模式走 FormatNodeOneline,格式是「路径(类型)“文本” children=N style=X key=val key=val」,注释明说这是为了 grep 友好,每行都是一条自足的记录。

二、IDocumentHandler:三层接口,以及「不支持」怎么如实返回

src/officecli/Core/IDocumentHandler.cs 的接口注释直接把架构写死成三层:

  • 语义层ViewAsText / ViewAsAnnotated / ViewAsOutline / ViewAsStats / ViewAsIssues,另有 ViewAsTextJson / ViewAsOutlineJson / ViewAsStatsJson 三个结构化变体给 --json 模式用(annotated 和 issues 没有单独的 Json 方法,后者本来就返回 List<DocumentIssue>);
  • 查询层Get(path, depth)Query(selector)Set(path, properties)AddRemoveMoveCopyFrom
  • 原始层Raw(partPath, ...)RawSet(partPath, xpath, action, xml),直通底层 XML。

加上 AddPart(新建图表、页眉、页脚这类部件并返回关系 ID 与可访问路径)、Validate(按 OpenXML schema 校验并返回错误列表)、TryExtractBinary(把 ole / picture / media / embedded 节点背后的二进制导出到指定路径)、Save(把内存里的 OOXML 包刷到磁盘但不结束会话),一共就这些。

这个分层对 Agent 的价值在于逃生口是显式的:语义层看不明白就下到查询层,查询层改不动就下到原始层塞 XML,每一层都在同一个 handler 对象上,不用换库、不用换文件句柄。

两个细节体现了「统一接口不等于假装都一样」。其一,Set 的返回值是 List<string>——被拒绝的属性名列表,注释写的是「该元素类型不支持的 prop 名」,它不抛异常。这个列表一路走到 src/officecli/CommandBuilder.Set.cs 被翻成 unsupported_property 警告并附一条 did-you-mean;如果 handler 自己已经在字符串里带了提示(判断依据是文本里含左括号),就不再叠加泛化猜测。默认语义是「能改的改了,不能改的告诉你」,不是整条命令失败。

其二,ViewRangeGuard 这个共享守卫和接口写在同一个文件里,注释说理由是让报错文案在各 handler 之间保持一致。它做的事很小:view text --range 的单元格区间子集只有 xlsx 支持,docx / pptx 传了非空 range 就抛 invalid_value,错误信息里直接给替代方案——用 --start / --end 限定输出范围。统一接口里保留格式专属能力,代价就是要有这种「明确拒绝并指路」的守卫,否则参数会静默失效。

组成部分它负责什么对应仓库位置你什么时候会碰到它
DocumentNode跨三种格式的通用节点;八个序列化字段 + 一个内部字典src/officecli/Core/DocumentNode.cs解析 get / query 的 JSON 输出时
IDocumentHandler语义 / 查询 / 原始三层接口,各格式 handler 实现它src/officecli/Core/IDocumentHandler.cs判断某个操作该走哪一层时
TypedAttributeFallback点号键 元素.属性=值 的通用回退,带 SDK 校验src/officecli/Core/TypedAttributeFallback.cs想设一个 curated 词表里没有的 Word 属性时
AttributeFilter选择器方括号里的属性过滤、布尔表达式、零结果诊断src/officecli/Core/AttributeFilter.csquery 选择器、看到 filter 警告时
GenericXmlQuery单值叶子元素的类型化创建(回退的另一半)src/officecli/Core/GenericXmlQuery.cs设一个「只有一个 val」的开关属性时
PathAliases人类友好段名到 OOXML 本地名的映射src/officecli/Core/PathAliases.cs路径里想写 paragraph 而不是 p
DocumentLimits递归深度、DOM 元素数、解压体积等硬上限src/officecli/Core/DocumentLimits.cs处理超大或来路不明的文件时
能力 schema每格式每元素的 add / set / get / readback 契约schemas/help/{docx,pptx,xlsx}/schemas/help/_shared/想知道某元素到底支持哪些属性时

schemas/ 那一层值得单说。schemas/README.md 写明它是「CLI 支持什么」的唯一事实来源,被运行时的 --help --json 输出(schema 在构建期嵌进二进制,运行时不依赖文件系统路径或网络)、契约测试和未来的 wiki 生成三处消费;标 enforcement: strict 的属性一旦漂移就断 CI,标 report 的只记日志,README 还规定改动 Add / Set / Get 行为的 PR 必须在同一个 PR 里更新对应 schema。

它的目录布局本身就是三格式抽象的一张地图:_shared/ 里 30 个文件放共用元素(paragraph.jsonrun.jsontable.jsonshape.jsonole.json 等),其中还有 chart.docx-pptx.jsonole.pptx-xlsx.jsonpicture.docx-xlsx.json 这种两格式合体的变体——共用的共用,分叉的就明写成哪两家共享;格式专属的各归各家,docx/ 46 个、pptx/ 34 个、xlsx/ 41 个。看一眼 xlsx/ 下的 pivottable.jsonsparkline.jsonpptx/ 下的 animation.jsonmodel3d.json,就明白哪些能力不可能收进公共接口。

三、类型化属性回退:长尾属性怎么写才不产出垃圾 XML

curated(人工整理的)属性词表永远追不上 OOXML 的长尾。src/officecli/Core/TypedAttributeFallback.cs 是这个问题的答案,它接受的形状是 元素名.属性名=值,比如 ind.firstLine=240 解析成父元素下的 <w:ind w:firstLine="240"/>

三个动作值得看清楚。

合并而不是覆盖。 类注释举例:先 set ind.left=720set ind.firstLine=240,结果是一个同时带两个属性的 <w:ind/>,不是两个元素也不是一次覆盖。实现上先在父元素的现有子元素里按本地名找,找到就把校验后的属性逐个 SetAttribute 合并进去。

校验完全交给 OpenXML SDK 往返。 克隆一个空的父元素,把 <前缀:元素 xmlns:前缀="…" 前缀:属性="值"/> 这段字符串塞给它的 InnerXml 让 SDK 解析,然后看结果:解析成 OpenXmlUnknownElement 就拒绝;属性落进 ExtendedAttributes(SDK 不认识的扩展属性桶)而非类型化属性也拒绝;一个类型化属性都没有同样拒绝。注释说明这跟 GenericXmlQuery.TryCreateTypedChild 是同一个招数,两条路径的 schema 规则因此完全一致——只认已知元素加已知属性,不写垃圾 XML,写错一个字母(注释举的是 ind.notAnAttr)会被这道关卡挡下。

嵌套只导航,不创建。 点号数量大于等于 2 时按段名逐层往下走已经存在的子元素,在叶子上设属性;任何一个中间层不存在就返回 false,把活让回给 curated 实现。注释写明了理由——从零创建嵌套 OOXML 结构涉及 schema 顺序和容器歧义,那是需要人工整理的部分。

几处补丁也都留了注释:别名表把用户口径的 font / shading / underline / border / tblp 映射到 rFonts / shd / u / pBdr / tblpPr;属性名以 color 结尾或等于 fill 时前导 # 会被剥掉,因为 OOXML 的十六进制颜色属性从不带 #;新建子元素追加后要走一趟 SchemaOrder.Place 挪到 schema 规定的位置,而已经存在的子元素一律不重排。

这个回退目前只接在 Word handler 上,两处 CONSISTENCY(ooxml-attr-namespace) 注释把限制写死了:WordprocessingML 是 attributeFormDefault="qualified",带前缀的 w:属性= 才对;扩到 xlsx / pptx 要把命名空间从调用方传进来,并照抄 GenericXmlQuery.ProbeTypedValChild 的「先探测再重试」写法——那两套 schema 是 unqualified,带前缀的 val 会被拒。

调用侧的层级在 src/officecli/Handlers/Word/WordHandler.Set.Dispatch.cs 的 default 分支看得最清楚:键里有点号就先走点号属性回退(先拿一个游离的探针对象试,成功了再对真实元素做一遍,避免未命中时凭空创建空的 rPr / pPr);没中再试单值叶子元素路径,pPr 和 rPr 各探一次;全不行才把这个键放进 unsupported。四级:curated 分支 → 点号属性 → 单值子元素 → 如实上报。

四、选择器过滤:同一套查询语法怎么落到属性字典上

src/officecli/Core/AttributeFilter.cs 是这套抽象里代码量最大的一块,它解析 CSS 风格的方括号过滤条件,再拿去匹配 DocumentNode。类注释给的例子是 shape[fill=#FF0000][size>=24pt][text~=报告]

运算符有 =!=~=(包含)、>=<=><,外加无运算符的 [key] 表示「有这个属性」。方括号里还支持 XPath 谓词风格的布尔表达式:cell[value>5000 and value<8000]cell[(type=number or type=date) and value>0]and 优先级高于 or,括号可改写,另有 not(...);同一个方括号里的 AND 必须显式写,隐式 AND 是堆叠方括号那种写法;顶层逗号是并集,按 Path 去重。工程上有个实用决定叫 TryFlatten:纯 AND 的表达式摊平回原来的扁平条件列表走老路径,只有含 or / not / 分组的才走表达式树。

ResolveValue 是把「统一语法」接到「各格式各自的属性字典」上的那根轴,回退顺序是:Format 里大小写不敏感查键 → revision 类型节点允许用短名命中 revision. 前缀的键 → text 回退到 node.Texttype 回退到 node.Typecell 节点的 value 回退到 node.Textstyle 回退到 node.Style。最后一条带 bug 编号注释:Word / PPT 的 handler 把样式写在顶层 Style 属性上而不复制进 Format,没有这条回退,paragraph[style=Normal] 会在每个段落样式确实都是 Normal 的文档上返回 0 条。样式另有一层双键处理——style 同时有 OOXML 的 styleId 和用户可见的显示名两种露出,= / != 会把 node.StyleFormat["style"]Format["styleName"] 都试一遍;要精确就用没有回退的 styleId= / styleName=

失败路径比成功路径更花心思,主线是绝不留下无声的 0 命中:非正则值里出现 * 直接抛 invalid_selector 并建议改用 ~=(注释挂了 bug 编号,说的正是 ole[progId=Excel*] 从前静默返回 0 条);r"..." 原始字符串正则只允许配 ~=,配别的运算符会把连引号的整个 token 当字面量比,宁可报错;r"..." 编译不过时也抛出来而不退化成字面包含。理由注释写得很直白:静默的 0 命中读起来像「数据不存在」。正则匹配另有 5 秒硬超时(DocumentLimits.RegexMatchTimeout),防的是用户模式跑在可能被攻击者影响的文档文本上导致灾难性回溯挂死进程。数值比较同样不做字符串兜底,CompareNumeric 不可比时返回 null、比较运算符当不匹配处理——cell[value>5000] 绝不能命中文本单元格「张三」(按码点排它确实排在 “5000” 之后)。

零结果时它反过来给你体检。DiagnoseEmptyResult 拿剥掉过滤方括号的全集重新评估:键不存在就列出全部可用键,值没命中就列出实际存在的取值(消息里最多 20 个,JSON 的 Available 独立截到 50 个),两种都附一个 did-you-mean,走 EditDistance.Damerau,阈值 max(2, 输入长度/3)并列最优时不给建议;键的近似匹配还会额外拿点号键的最后一段比一次,所以 blod 能提示到 font.bold。另一头,handler 可以把该类型合法的键集合盖在 InternalFormat["declaredKeys"] 上,过滤这种「已声明但未设置」的键得到 0 条是干净的 0 条,不会误报 unknown_key。承载这些的 FilterDiagnostic 同时带人类可读的 Message 和机器可读的 Kind / Key / Available / Suggestion,注释明说后面几个是给 query --json 用的,好让 Agent 不必解析散文——这跟工具返回值该怎么设计讲的是同一件事。

五、边界与代价:这套设计放弃了什么

抽象是有损的,项目自己也承认。 InternalFormat 就是明证——图表轴标题的段落属性这类原样 OOXML 片段进不了公共 Format。同理,Format 的值是装箱的 object?,有些读取器会往里放结构化值(段落的 tabs 是一个 List<Dictionary>),单行文本输出得为它们写专门的格式化分支。拿到 JSON 之后,别假设每个值都是标量。

点号属性回退只覆盖 Word,嵌套结构不会被凭空创建。 前者的命名空间是硬编码的 WordprocessingML,xlsx / pptx 的长尾属性只能靠 curated 词表或原始层;后者是说 pBdr 下还没有 top 时设 pBdr.top.某属性,回退会直接放手。两条都是有意取舍,但你得知道,否则会以为命令生效了。同理,view text --range 只有 xlsx 认——这类格式专属能力都要一条守卫或一条 schema 声明去承载,没覆盖到的地方就是静默的行为差异。

它明确不管的事。 Set 对不支持的属性只发警告不失败,一条「看起来成功」的命令可能只应用了一半属性,判断改没改够是调用方的事。选择器命中与否是机械匹配,它不知道你想改的是不是业务上该改的那一处。批处理有 stopOnError 开关但默认 false,逐条执行逐条记录,外层 success 只在全部成功时为 true,失败的步骤不回滚已经生效的前序步骤

有硬上限,超了就拒。 DocumentLimits 把限额集中在一处,防三类拒绝服务:解压炸弹(解压后总字节上限 2 GiB、条目数上限 100000、压缩比上限 1000)、无界递归(最大嵌套深度 256,另配运行时栈探测,因为常驻/watch 服务跑在约 1 MB 栈的线程池线程上)、灾难性正则回溯(前面那 5 秒)。另有 DOM 元素数上限默认 3000000,可用环境变量 OFFICECLI_MAX_DOM_ELEMENTS 覆盖——几 MB 的 zip 能展开成几百万个微小单元格、把内存吃到几个 GiB 并 OOM 掉长驻服务。数据量真的很大的工作簿,先动这个旋钮。

它会改你磁盘上的真实文件。 写盘走 src/officecli/Core/AtomicPackageWriter.cs:先把完整的包序列化到同目录下一个 .<原文件名>.savetmp-<GUID> 临时文件,做完可选后处理,再用一次 File.Replace 换过去。崩溃时你拿到的要么是旧文件要么是新文件,不会是撕裂的半截——但被替换的就是原文件本身,不是另存的副本。要保原件,自己先复制一份。

常驻进程模式还多一层时间差。src/officecli/Core/ResidentFlushPolicy.cs 定义四种落盘策略,走环境变量 OFFICECLI_RESIDENT_FLUSH(旧别名 OFFICECLI_RESIDENT_IDLE_SAVE_SECONDS):each 每条修改命令返回前刷盘;auto 是默认值,空闲防抖,间隔按实测保存耗时自适应,公式 clamp(4.0 × EMA(保存耗时), 2 秒, 10 秒)——EMA 即指数滑动平均,用一个随每次采样滚动更新的加权平均值代替单次耗时,这里还做了非对称处理:保存变慢时权重取 0.7,估计值立刻抬高,变快时权重只有 0.2,缓慢回落,避免一次偶然的快保存把间隔打回底线、进而变成连续存盘;写整数 N 是固定 N 秒;off0 永不自动刷盘,只有显式 save / close / shutdown 才写磁盘。README 里 officecli close deck.pptx 那行注释正是「把常驻会话刷到磁盘」。命令返回了不等于文件已经变了,这是接 CI 或接编排时最容易踩的时序假设。

拉外部资源的暴露面。 src/officecli/Core/SsrfGuard.cs 列出了会发起远程 HTTP/HTTPS 抓取的入口:ImageSourcepicture=)和 FileSource(表格的 data=model3d=media=)。注释直说了风险模型——作为 Agent 工具运行时,URL 可能来自不可信输入(批处理脚本、嵌在文档里的指令、工具调用参数),没防护的抓取就是一个 SSRF 原语——SSRF 指服务端请求伪造,攻击者自己够不着的地址,借你这台服务器的身份去够,因此能探测内网主机和云元数据端点(注释里点名了 169.254.0.0/16 这类链路本地地址),或把响应体外带进产出的文档里。防护是在连接回调里校验实际连上的 IP 而非提前解析域名,每一跳重定向都验,顺带关掉 DNS 重绑定的时间窗。这道防线拦的是「连到哪」,拦不住「该不该连」——URL 从哪来仍是你的编排层的责任。

六、上手与避坑清单

1. 别用 * 做通配,正则值只配 ~= 会踩是因为 glob 和 SQL 的 LIKE 习惯太深,而 r"..." 看起来又像个普通字符串值。两种写法现在都会直接报 invalid_selector 并在建议里给出正确形式,照着改就行。

2. 值里含空格、括号或 and / or / not 时必须加引号。 会踩是因为表达式解析器把这些当保留字和分隔符,写成 [text~="salt and pepper"]。表头列名带空格或标点时键也要加引号——["Full Name"~=doe],裸标识符读取器只认字母、数字、点和下划线,停在名字中间时的报错会提示你加引号。

3. style 到底填 styleId 还是显示名,先想清楚。 会踩是因为两者都能命中,你不知道自己中的是哪一个,写迁移脚本时这会变成随机的行为差异。要确定性就用 styleId=styleName=

4. 数值过滤前先看一眼实际值。 会踩是因为数值运算符对不可比较的值返回不匹配(这是对的),但结果读起来像「数据没了」。先跑一次不带过滤条件的 query,看节点 format 里到底有哪些键、值长什么样,再加条件;真的 0 条时读警告里 Available 列出的键和值,那就是给你自纠错用的。

5. 常驻模式下别拿命令返回当落盘信号,批处理默认也不会在第一处失败时停下。 前者踩坑是因为默认策略 auto 是 2 到 10 秒的空闲防抖,要确定性就设 OFFICECLI_RESIDENT_FLUSH=each 或在流程末尾显式 close;后者踩坑是因为 stopOnError 默认 false,前面的步骤已经改进内存 DOM 了,要么显式打开它,要么批次结束后用 Validate 复核,别只看外层那个 success 布尔。

6. 想知道某个元素支持什么,先读 schema 而不是猜属性名。 会踩是因为属性命名习惯在三种格式之间并不一致。schemas/help/{docx,pptx,xlsx}/<元素>.json 是唯一事实来源,而且被契约测试盯着;运行时也能用 --help --json 拿到同一份内容。这跟给 Agent 写工具描述是一个道理——能力声明可机读、可校验,Agent 才不用靠猜。


收个尾。这套抽象值得记住的不是「三合一」这个结果,而是达成它的三个动作:公共形状压到最小(八个字段,差异全进字典),装不下的东西单开内部通道InternalFormat),长尾能力用带校验的回退接住而不是无限扩词表(SDK 往返验证,不认识就如实说不支持)。要给 Agent 做多后端统一接口,这三步都能搬。

想继续读,顺序是:先 src/officecli/Core/DocumentNode.cs(十分钟看完,是所有输出的形状),再 src/officecli/Core/IDocumentHandler.cs(知道有哪些逃生口),然后挑你最常用的那个格式,去 schemas/help/ 下对应目录翻两三个元素的 json,对照 src/officecli/Handlers/ 下对应的 handler 目录看一眼——目录名分别是 Word/(54 项)、Excel/(47 项)和 Pptx/(65 项,含一个 EffectTemplates 子目录,注意 PowerPoint 那家目录名用的是格式后缀而不是产品名),用的是 C# 分部类写法,也就是同一个类拆到很多文件里写,所以文件名第二段(.Set..Query..View.)就是功能索引,按需跳读即可。

本篇属于一个把开源Office 文档读写套件 OfficeCLI逐层拆开讲的系列,整体地图见 OfficeCLI 是什么:给 AI Agent 用的开源文档读写套件;沿着这条线往下,还可以看 开源项目 OfficeCLI:152 份 json 撑起的文档即契约设计OfficeCLI 开源项目的写盘底线:原子包写入与临时目录守卫

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