开源项目 OfficeCLI:152 份 json 撑起的文档即契约设计

2026-08-05

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

如果你的工具有几百个参数,让模型少猜的办法不是把帮助文本写得更长,而是让帮助文本不再由人手写。 OfficeCLI 这个开源项目(Apache-2.0,NOTICE 写明 Copyright 2026 OfficeCLI,由 goworm 创建维护,仓库在 iOfficeAI/OfficeCLI)把这件事做成了一层结构:schemas/help/ 下的 152 份 json 是唯一事实源,运行时的帮助输出、参数校验、契约测试都从这里长出来,还额外给整棵树算了一个 CRC32 指纹,让下游能一条命令判断「升级后这份契约变没变」。

先做个消歧。OfficeCLI 是这个开源项目的专有名字,不是泛指「用命令行操作 Office」,也和微软没有任何从属、授权或官方合作关系;下文提到 Word / Excel / PowerPoint,指的是 .docx / .xlsx / .pptx 这三种文件格式和打开它们的应用,不是这个项目的归属。它是一个单文件二进制,机器上不装 Office 也能读写这三种文件。

这一篇跟站内几篇相邻文章分工不同:怎么给一个工具写出模型读得懂的描述文本,在工具描述怎么写里;模型吐出的结构化结果不稳该怎么兜底,在结构化输出不稳里;把能力切成清单式 manifest 的组织法,在组件化 manifest里。本篇只回答一个更靠后的问题:当描述文本多到人已经写不动、也对不齐实现时,怎么把它降格成可编译、可校验、可指纹的数据。

一、152 份 json 的形状:一个元素一份契约

先说这层解决什么。OfficeCLI 的命令面很宽——三种格式,每种格式底下几十种元素(段落、表格单元格、图表系列、条件格式、批注……),每种元素又有一堆属性。如果把这些写进代码里的帮助字符串,实现改了、字符串没改,Agent 拿到的就是过期承诺;而 Agent 读到过期承诺的后果不是报错,是它按承诺发命令、然后拿到一个自己也解释不了的失败。

它的做法是按 (格式, 元素) 一份 json 落盘。目录布局在 schemas/README.md 里写死:

schemas/
  help/
    _schema.json                ← JSON Schema (draft 2020-12) describing the format below
    docx/<element>.json         ← Word per-element capability
    pptx/<element>.json         ← PowerPoint per-element capability
    xlsx/<element>.json         ← Excel per-element capability

自己数一遍:schemas/ 目录共 153 个文件,其中 152 份 json、1 份 README。json 拆开是 docx/ 46 份、xlsx/ 41 份、pptx/ 34 份、_shared/ 30 份,加上顶层那份 _schema.json_shared/ 是跨格式复用的公共部分,具体格式文件靠 extends 字段引它。

_schema.json 是「描述 schema 的 schema」——一份能力 json 必须长什么样,由它规定。它要求每份文件至少有 formatelementoperationsproperties 四个键,并且 additionalProperties: false,多写一个字段就不合法。往下看几个字段,能看出这套设计想覆盖什么:

  • operations:这个元素接受哪些动词,枚举是 add / set / get / query / remove
  • paths:这个元素在命令里怎么被寻址,分 stable(稳定标识,如 /body/p[@paraId=ID])和 positional(位序,如 /body/p[N])两种。
  • properties:每个属性一条记录,带 type(枚举里只有 string / bool / number / color / length / font-size / enum)、aliasesvaluesrequiresreadback,以及分动词的 add / set / get 布尔位。
  • enforcement:值只能是 strictreport。按 schemas/README.md 的说法,标 strict 的属性一旦契约测试发现漂移就断 CI,标 report 的只记日志。_schema.json 里这个字段自己的描述补了一句默认值:新属性默认 strict,历史欠债可以先挂 report 再迁移。

schemas/help/docx/paragraph.jsonlineRule 这一条看实际形状:

"lineRule": {
  "type": "enum",
  "values": ["auto", "exact", "atLeast"],
  "aliases": ["linerule"],
  "add": true, "set": true, "get": true,
  "examples": ["--prop lineSpacing=14pt --prop lineRule=atLeast"],
  "readback": "auto | exact | atLeast",
  "enforcement": "report"
}

(原文件里这条还带一个 description 字段,写的是 auto / exact / atLeast 三个值分别对应倍数行距、固定行高、最小行高,上面为省版式略去了。)

一条记录里同时装了:类型、可选值、输入时容忍的别名、三个动词分别支不支持、一个可直接复制的例子、Get 读回来长什么样、以及这条约束当前的强制级别。人读的帮助、机器读的 json、契约测试要断言的东西,全是这一条的不同投影。

schemas/README.md 还写了一条硬性协作规则:任何改动某元素 Add / Set / Get 行为的 PR,必须在同一个 PR 里改对应的 schema 文件,否则 CI 契约测试会失败。这句是整套设计成立的前提——没有这条,json 三个月后就是一堆考古材料。

整层拆开是这么几块,你可以照着这个粒度去对自己的项目:

组成部分它负责什么对应仓库位置你什么时候会碰到它
元 schema规定一份能力 json 必须长什么样,字段封闭不许多写schemas/help/_schema.json新增元素、想知道字段能写什么的时候
逐元素能力 json每个「格式 + 元素」一份,声明动词、路径写法、属性、别名、强制级别schemas/help/docx/xlsx/pptx/想确认某个元素到底能设哪些属性
共享基底跨格式复用的公共属性,由具体格式文件 extends 引入schemas/help/_shared/三种格式行为一致的属性不想抄三遍
嵌入规则构建时把整棵树塞进程序集,运行时不依赖磁盘目录src/officecli/officecli.csproj好奇单文件二进制为什么不带 schema 目录也能跑
加载与合并解析格式与元素别名、合并 extends、按动词过滤属性src/officecli/Help/SchemaHelpLoader.cs排查「help 认得这个名字吗」
三种渲染人读视图、原始 JSON、一行一记录的扁平视图src/officecli/Help/SchemaHelpRenderer.csSchemaHelpFlatRenderer.cs想把能力树喂给 Agent 或直接 grep
指纹对嵌入的 schema 资源整体算 CRC32src/officecli/Help/SchemaCrc.cs升级二进制后要确认属性面没变

二、编译期嵌进二进制:这份契约怎么送到 Agent 手上

这里解决的是分发问题。单文件二进制的卖点是拷过去就能跑,如果 schema 要靠旁边一个目录才能读到,这个卖点就废了。

src/officecli/officecli.csproj 里一行 MSBuild(.NET 的构建脚本格式)把整棵树塞进程序集:

<EmbeddedResource Include="..\..\schemas\help\**\*.json"
                  LogicalName="schemas/help/%(RecursiveDir)%(Filename)%(Extension)" />

「嵌入资源」就是把文件的字节直接编译进可执行文件,运行时用一个逻辑名取出来,不碰文件系统。csproj 旁边的注释把动机写得很直白:这样 SchemaHelpLoaderAssembly.GetManifestResourceStream 就能读到,单文件安装包只发一个 exe,不需要在磁盘上摊开。

src/officecli/Help/SchemaHelpLoader.cs 是取用侧。它做的事比「按名字取文件」多几层,每一层都对应一类 Agent 会犯的错:

格式别名。 它维护一张不分大小写的映射表,worddocxexcelxlsxppt / powerpointpptx。查不到时不是直接抛「未知格式」,而是先用编辑距离找最接近的候选,把 Did you mean: …? 拼进异常消息。

路径分隔符归一。 MSBuild 在 Windows 上生成资源名时 %(RecursiveDir) 可能用 \ 也可能用 /。加载器建索引时统一把 \ 换成 /,键做成 schemas/help/{format}/{element}.json 这种规范形式,再映射回 MSBuild 实际发出的那个名字。这类跨平台细节不处理,就是 Linux 上跑得好好的、Windows 上「资源找不到」。

元素名的两套命名空间。 命令里的路径写法是 /body/p[N]/Sheet1/col[B],而 schema 文件名叫 paragraph.jsoncolumn.json。加载器允许每份 schema 用顶层 elementAliases 数组把自己的路径形式名字登记出来,于是 help docx phelp docx paragraph 等价。源码注释里还专门处理了 /:因为 set / get / query 都用 / 表示文档根,help 里也把 help xlsx / 映射到 workbookhelp docx / 映射到 documenthelp pptx / 映射到 presentation。注释给的理由很值得抄——不这样做的话,Agent 会从 set / get 的词汇里合理外推出 /,然后撞上「unknown element ’/’」。这是把「模型的合理外推」当成需求,而不是当成用户错误。

继承与合并。 一份 schema 可以用 extends_shared/ 下的基础文件,值可以是单个字符串也可以是数组。合并规则写在 MergeSchemaJson 的文档注释里:顶层标量和数组由 override 覆盖 base;properties 取并集,同名属性由 override 整条替换(不做属性内部的深合并,属性是原子的);合成时把 extendsshared_base 这两个标记字段去掉。属性顺序也有讲究——override 里声明的排前面,base 独有的按 base 顺序追加,保住格式文件作者手写的排列。

错误消息的输出面。 未知元素的报错里会回显用户传进来的字符串,加载器在这里调了一次截断到 64 字符,注释写明理由是防止响应放大:调用方原样回显任意长度的输入。这是把 MCP 场景下的攻击面当成一等公民在处理。

取到 schema 之后有三种渲染出口,都在 src/officecli/Help/ 下:SchemaHelpRenderer.cs 出人读版和原始 JSON,SchemaHelpFlatRenderer.cs 出扁平版。扁平版是专门给 grep 和 Agent 设计的,每条记录自成一行,两种行标签 ELEMPROP,它自己文档注释里的示例长这样:

docx paragraph     ELEM  ops:[asgqr]  paths:/body/p[@paraId=ID];/body/p[N]
docx paragraph     PROP  align        enum    ops:[asg]  values:left|center|...  aliases:alignment  one of values  ex:--prop align=center

src/officecli/CommandBuilder.Help.cs 里打印出来的入口清单,正好是这几层的对外投影:

officecli help <format>                         List all elements
officecli help <format> <element>               Full element detail
officecli help <format> <element> --json        Raw schema JSON
officecli help all                              Flat dump of every (format,element,property) — pipe to grep
officecli help all --jsonl                      Same dump as NDJSON (one JSON object per line)

(NDJSON 就是「一行一个完整 JSON 对象、行与行之间没有外层数组」的格式,好处是可以边读边解析、也能直接按行 grep。)

对你意味着什么:Agent 探索能力时不需要一次性把几百页帮助塞进上下文,可以先 help <format> 拿元素列表,再对单个元素要细节;要做离线索引就一把 help all --jsonl 拉走。同一份数据,三种粒度,零手工同步。

三、CRC 指纹:一条命令回答「能不能盲升级」

src/officecli/Help/SchemaCrc.cs 只干一件事:对嵌入的 schemas/help/** 所有资源算一个 CRC32,输出成八位十六进制。CRC32 是个校验和算法,输入变一个字节结果就变,用来判断「内容一不一样」,不是加密。

实现上有两处细节决定了它靠不靠谱。一是排序:所有资源名先规范化成小写、/ 分隔,再按序数比较排序,这样 MSBuild 在不同平台上的路径分隔符差异不会改变结果。二是喂入内容:每一项先把规范化后的名字按 UTF-8 喂进去,再喂文件原始字节。

entries.Sort((a, b) => string.CompareOrdinal(a.Canonical, b.Canonical));
uint crc = 0xFFFFFFFFu;
foreach (var (canonical, resource) in entries)
{
    crc = Append(crc, System.Text.Encoding.UTF8.GetBytes(canonical));
    ...
}

把名字也算进去,意味着改名、挪目录同样会让指纹变化,而不是只有内容变了才变。用的是标准的反向 CRC32 多项式 0xEDB88320u,表在静态构造里现算。

对外只暴露一个内部命令,在 src/officecli/Program.cs 里,位置排得非常靠前——在几乎所有其它分发逻辑之前:

if (args.Length == 1 && args[0] == "--output-schema-crc")
{
    Console.WriteLine(OfficeCli.Help.SchemaCrc.Compute());
    return 0;
}

Program.cs 的注释把语义定死了:下游自动化把这个值钉住,二进制升级后重算一次,相同就说明属性面完全一致、可以盲升。它还特别声明这是一个 flag 而不是版本号,没有先后顺序语义——你不能从两个 CRC 值判断谁新谁旧,只能判断同不同。

SchemaCrc.cs 类注释里自己划了边界:指纹只覆盖 schema 文件,代码里实现的序列化行为(举的例子是 JSON 字段顺序)不在这个指纹范围内。这句要认真读。它的意思是 CRC 不变,只保证「文档承诺的属性面没变」,不保证「输出的字节没变」。如果你的下游流水线是按字段顺序做正则解析的,CRC 不变照样可能被升级打穿。

四、同名不同物:另一套 schema 管的是写盘顺序

仓库里「schema」这个词有两个所指,混起来会读歪。上面讲的是对外契约。src/officecli/Core/SchemaOrder.cs 里的 schema 是另一回事——OOXML 自身的内容模型顺序。

补一句背景:.docx / .xlsx / .pptx 本质是 zip 包,里面是一堆 XML。OOXML 规范对很多容器规定了子元素必须按固定次序出现,顺序错了,严格的消费方会拒收。这个文件的注释给了两个具体后果:Word 的 <w:kern> 排在 <w:sz> 后面、<w:pStyle> 不在 pPr 第一个,都会被 OOXML 校验拒绝;PowerPoint 遇到次序不对的 DrawingML 元素则是静默丢弃。

天真的做法是给每个容器手维护一张顺序数组。注释里点名了这条路的失败现场:光是 CT_RPr(run 的属性容器)就有约 40 个子元素,之前那个手写 switch 把 kern / w / position 悄悄漏成「追加到末尾」。

它换的路子是反射读 OpenXML SDK 自己的编译期粒子比较器(CompiledParticle.Compare)——就是 SDK 校验器判断顺序时用的那一套。「粒子」是 XML Schema 里描述内容模型的术语,一个粒子就是「这个位置允许出现哪个(哪些)子元素、能出现几次」这条规则;SDK 把每个 CT_* 类型的粒子树编译好带在身上,比较器就是拿它来判断两个子元素谁该排前面。这样顺序表是「由构造保证完整」的,SDK 升级跟着走。放置策略刻意做得很保守:只移动新加进来的那一个子元素,把它挪到第一个它应该排在前面的既有兄弟节点之前,其余节点一律不动。注释解释了为什么不整体排序——比较器会把无法识别的元素排到最前面,mc:AlternateContent、扩展列表这类外来节点一旦被盲排就会错位。

同一个文件还用同样的反射路径读 maxOccurs(某个子元素在父容器里最多能出现几次),用来决定「加同名子元素时是替换还是追加」。读不到时返回 MaxOccursUnknown,调用方按「可重复」处理,也就是宁可多留一个也不误删。

两套 schema 放在一起看,能提炼出这个仓库的一致取向:能让权威源自己回答的,就不要在本地抄一份。对外契约的权威源是 schemas/help/,写盘顺序的权威源是 SDK 的粒子表。你抄这套做法时,真正要找的是「谁是权威源」,而不是「json 还是别的格式」。

五、边界与代价

这套设计不是免费的,也不是万能的,几处代价写在源码注释里,比宣传材料更值得看。

schema 已经不是运行时闸门。 这是最反直觉的一点。src/officecli/Core/TrackingPropertyDictionary.cs 的头部注释写得很明确:它替换了原先在 CLI 入口做的 SchemaHelpLoader.ValidateProperties 预过滤,改成「handler 实际有没有读这个 key」来定义什么叫不支持的属性;schema 不再是运行时的门禁,handler 的真实消费才是。好处是 handler 真懂但 schema 没登记的别名不再被误杀,坏处是判定依赖一个用自定义比较器记录字典访问的技巧,注释自己列了已知漏洞——foreach 遍历不走比较器,所以穷举遍历字典的 handler 什么都不会被标记为已访问,只能靠重写枚举器打补丁。这类机制精巧,但精巧本身就是维护成本。

校验器刻意宽松,宽松就会漏。 ValidateProperties 的策略是:格式或元素不认识就直接返回空(不因为新元素 schema 还没落地而卡住);接受一批点号前缀的子属性命名空间(font.alignment.border. 这些)即使 schema 没逐个列出;接受带序号的形式如 series1.color。源码里有一段值得单独抄的注释:axis. / cataxis. / valaxis. 这些前缀是故意不放进宽松名单的,因为 handler 只支持一小撮固定子集,加一条通配前缀会让 axis.color 这种拼错静默通过、Add 成功而值被丢掉。宽松是有代价的,代价要一处一处地权衡,不能一刀切。

明确不管的事。 schemas/README.md 的「Not here」一节自己划了三条:叙述性的教程和最佳实践不在这里(属于 wiki)、内部实现说明不在这里(属于项目约定和代码注释)、临时的发布说明不在这里(属于 CHANGELOG)。而 README 里列的第三个消费方「发布时生成 wiki」标注的是 future,也就是说这条链路当下还没接上,Agent 直接读 schema。

风险面要按真实情况说。 这个二进制读写的是你磁盘上的真实文件,不是副本。src/officecli/Core/AtomicPackageWriter.cs 做的是崩溃原子的落盘——目标文件被原子替换,而不是就地重写,这样进程中途死掉不会留下半个损坏的包;但「原子」保的是不写坏,不是保留旧版本,改的仍然是你给的那个路径。要留退路只能自己先复制一份。

常驻进程模式下「什么时候真落盘」是可配的,src/officecli/Core/ResidentFlushPolicy.cs 定义了四种模式:each(每条修改命令返回前落盘)、auto(空闲防抖,间隔按实测保存耗时自适应,默认)、固定秒数、off(只有显式 save / close / 关停才写)。默认是 auto,意味着你发完命令去用别的工具打开文件,可能读到的还是旧内容。

批量执行的中途失败也要看清楚。src/officecli/CommandBuilder.Batch.cs 的帮助文本里写了「干净重放」的正确姿势:create 拒绝覆盖已存在的文件(退出 1,错误码 file_exists),也拒绝一个还被常驻进程持有的文件(错误码 file_locked);脚本要是忽略 create 的退出码继续跑,就会把这批命令重放到上一轮的文档上,于是 add style、add bookmark 这类命令报「已存在」。它给的可靠写法是 close -> rm -> create -> batch -> close,并且强调 rm 必须在 create 之前。

最后是外部资源。图片、表格数据、媒体这些属性允许传 URL——schemas/help/_shared/picture.jsonsrc 的描述就写着接受文件路径、URL、data-URI。src/officecli/Core/SsrfGuard.cs 的注释把暴露面说得很清楚:当它作为 Agent 工具运行时,URL 可能来自不受信任的输入(批处理脚本、文档里嵌的指令、工具调用参数),无防护的抓取就是一个 SSRF 原语。SSRF 是「服务端请求伪造」:外部输入能指挥程序自己去发请求,于是攻击者借这台机器的网络位置去够它本来够不着的地址——注释点名的两个后果就是探测内网主机和云元数据端点、或者把它们的响应体塞进产出的文档里带走。它的对策是在连接回调里校验实际 IP、每个重定向跳转都校验,拒绝落到回环、私有、链路本地地址上。这层做了不等于你可以不管——它挡的是地址,挡不住「文档里一段文字诱导 Agent 去拉某个公网地址」这件事本身。

六、上手与避坑清单

别把 --output-schema-crc 当版本号排序。 会踩是因为它长得像个 build id,人本能想比大小。Program.cs 注释明说它是 flag、无顺序语义。正确用法只有一种:升级前后各取一次,相等就继续跑自动化,不等就先回去核对机器可读输出的假设。

别把 CRC 相等理解成输出字节不变。 会踩是因为「指纹一致」听起来包打天下。SchemaCrc.cs 自己声明代码里实现的序列化行为不在覆盖范围。如果你的解析依赖字段顺序,除了比 CRC,还得对代表性输出做一次快照 diff。

别拿 schema 当运行时白名单来推断行为。 会踩是因为文件里明明写着 add: true / set: true。但运行时判定已经换成了 handler 有没有真读这个 key,两者的宽严不完全重合。真要确认某个属性生效没有,靠 Get 读回来对比,别靠读 schema。

元素名先查别名再报错。 会踩是因为命令路径里的写法(pcoltc)和 schema 文件名(paragraphcolumntable-cell)不是一套。加载器已经用 elementAliases 帮你兜了,但兜的是它登记过的;你在 help <format> 的列表里看到的是文件名那一套,按那个列表去查最省事。

扁平 dump 优先于全量 JSON。 会踩是因为习惯性 --json 然后整个塞进上下文。help all 的扁平格式是一条记录一行、自带描述和示例,就是为 grep 设计的;先 grep 命中再取单个元素详情,比整树入上下文省得多。

批量重放前先 close 再 rm。 会踩是因为常驻进程还捏着文件句柄,create --force 能覆盖文件却不释放常驻锁。按帮助文本给的顺序来,别自己发明。

改行为的 PR 顺手改 schema。 会踩是因为改实现时觉得文档可以之后补。schemas/README.md 的编辑规则和 CI 契约测试就是为了让「之后补」这个选项不存在——你在自己项目里抄这套时,这条规则和那 152 份 json 同样重要,少了它,一年后你手上就是一堆自称是事实源的过期文件。

收个尾。这套东西真正可迁移的不是 json 的字段名,是三件事:给能力找一个唯一的、可 diff 的落盘位置;让帮助、校验、测试都从那一处派生而不是各写各的;再给整体加一个廉价的指纹,让下游有办法在不读内容的前提下判断变没变。想照着做,建议按这个顺序读源码——先 schemas/help/_schema.json 看契约长什么样,再 src/officecli/Help/SchemaHelpLoader.cs 看解析和合并怎么做,然后 src/officecli/Help/SchemaCrc.cs 看指纹只有几十行,最后 src/officecli/Core/TrackingPropertyDictionary.cs 看维护者后来为什么把运行时闸门从 schema 挪走了——最后这一步最有价值,因为它记录的是一次自我推翻。

至于参数层面的通用校验策略该放在哪一层、由谁兜底,那是另一个话题,可以接着看参数校验怎么做

本篇属于一个把开源Office 文档读写套件 OfficeCLI逐层拆开讲的系列,整体地图见 OfficeCLI 是什么:给 AI Agent 用的开源文档读写套件;沿着这条线往下,还可以看 OfficeCLI 开源项目仓库导读:353 个 C# 文件的三层切法开源项目 OfficeCLI 的文档节点模型:三类文档共用一套接口

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