开源项目 OfficeCLI 排障:用它自带的体检命令定位文档问题
本文基于 OfficeCLI 仓库 commit 459b1a4(2026-08-04)梳理,该项目仍在高频迭代,具体行为以仓库 https://github.com/iOfficeAI/OfficeCLI 最新代码与文档为准。
OfficeCLI 报错的时候,最容易走错的一步是把三类完全不同的失败混成一件事去查:命令自己抛异常、文档不符合 OOXML schema、文档结构合法但内容是坏的。这三类在这个项目里分别由三套独立机制承接,报错落在哪个通道,基本就决定了你该改什么。
先做个消歧。OfficeCLI 是 iOfficeAI 在 GitHub 上的一个 Apache-2.0 开源项目(NOTICE 文件写明 Copyright 2026 OfficeCLI,由 goworm 创建维护),不是微软的产品,也不是”用命令行操作 Office”这件事的泛称。它是一个单二进制的命令行工具,不装 Office 也能读写 .docx / .xlsx / .pptx。仓库 README 把自己定位成”世界上第一个也是最好的、为 AI Agent 设计的 Office 套件”——这是项目自己的说法,不是本文的判断,本文只看代码里能核到的机制。
顺带划一下和站内几篇相邻文章的分工:Agent 失败分类讲的是 Agent 层面怎么给失败归类,Agent 工具调错怎么办讲的是工具调用本身出问题时的通用排查,Pascal Editor 故障排查是另一个项目的同类题目;这一篇只管 OfficeCLI 这一个工具内部的诊断链路——它的错误从哪里冒出来、被谁分类、你该按什么顺序读。
一、三条通道各管什么
先说清楚”OOXML”是什么:.docx / .xlsx / .pptx 本质是一个 zip 包,里面装着一堆 XML 文件(项目里叫 part,部件)和描述它们之间引用关系的 .rels 关系文件。文档坏掉,可能坏在某个 XML 不合法,可能坏在关系指向了不存在的部件,也可能 XML 完全合法但里面的公式是错的。
对应到这个项目:
第一条通道是进程级异常。所有命令的执行体都包在 SafeRun(src/officecli/CommandBuilder.cs)里,它 catch 住任何异常,交给 WriteError 渲染,然后返回退出码 1。这里有个专门的处理:裸的 XmlException 会被重新包成 InvalidDataException,消息改成 Malformed XML in document part: … (the file appears to have a corrupted OOXML part)。原因写在注释里——这里的 SDK 指的是它依赖的 OpenXML 读写库(officecli.csproj 里引的 NuGet 包 DocumentFormat.OpenXml,一个独立的开源库),SDK 抛出来的原始消息是”Data at the root level is invalid. Line 1, position 1.”,看到这句的人会以为是工具 bug,其实是文件本身某个部件烂了。
第二条通道是 validate,schema 体检,判文档合不合规范。
第三条通道是 view <file> issues,按语义给文档问题分类,判文档”合规但不对”。
这三条的输出形态也不一样。异常走 CliException(src/officecli/Core/CliException.cs),它比普通异常多带四个字段:Suggestion(建议的修正)、Help(可以运行哪条帮助命令)、Code(机器可读错误码)、ValidValues(当错误是”选了个非法值”时,把合法值全列出来)。序列化成 JSON 就是 {"success":false,"error":{error,code,suggestion,help,validValues}}。错误码在 Core/OutputFormatter.cs 里成体系地分配:not_found、invalid_path、invalid_value、unsupported_type、unsupported_property、missing_property、duplicate_name、invalid_json、invalid_xpath、io_error、internal_error 等等。你的 Agent 该匹配的是 code,不是消息文本。
这一层的设计取向,和工具返回结构该怎么设计里说的”让调用方能靠字段而不是字符串做分支”是一回事。
二、validate:schema 体检做了哪几步
validate 的命令装配在 src/officecli/CommandBuilder.Check.cs,描述就一句:Validate document against OpenXML schema。真正干活的是 src/officecli/Core/RawXmlHelper.cs 里的 ValidateDocument。它的步骤顺序值得单独看,因为每一步都是为堵一个具体的坑加进去的:
先把已加载部件的 DOM 刷回流,再 Clone 出一份快照来校验。 注释里解释得很清楚:直接在活的包上读每个部件的流,会让 SDK 对”改过但还没落盘”的部件失去追踪,那个部件下次 Save 时会被序列化成空——一个 set 命令新建了样式、又在同一会话里 validate 一下,结果就是 styles.xml 变成 0 字节。所以校验永远只在克隆体上做,不碰调用方接下来要保存的那份。
Preflight:逐个部件先用 XmlReader 硬读一遍。 PreflightXmlParts 只挑真正的 XML 部件(内容类型以 +xml 结尾,或者是 application/xml / text/xml),读不通的记一条 MalformedXml 错误。这一步存在的理由是:SDK 校验器碰到读不动的部件会直接跳过,于是一个明显损坏的文件反而”校验通过”。顺便这里踩过一次误判——早期用 ContentType.Contains("xml") 筛,结果 OOXML 的内容类型里都带 openxmlformats 这个子串,一个合法的二进制内嵌 .docx 每次都被当成坏 XML。
两个专项检测。 一个是 Word 的孤儿页眉页脚引用:w:headerReference / w:footerReference 的 r:id(关系 ID,指向包里另一个部件的编号)在关系表里找不到对应项时,SDK 校验器会在产出任何结果之前抛空引用异常,用户只能看到一句 NullReferenceException。项目自己先检出来,报成 OrphanedReference,并且在后面 catch 到空引用时,如果已经检出过孤儿引用就把那句裸异常吞掉,避免噪声盖住真正的诊断。另一个是跨格式的 OPC 包结构检查——OPC 是这类 zip 包的打包约定,规定包里必须有一份 [Content_Types].xml 清单,告诉解析器每个文件该按什么类型去读,办法有两种:按扩展名批量声明(Default)或给单个部件点名声明(Override)。项目检的是这样一种包:清单里只用 Override 声明部件、漏了 Default Extension="rels",这在规范上合法、schema 也过,但真的 Word / Excel / PowerPoint 会拒绝打开。
再跑官方校验器,逐条容错取结果。 用的是 OpenXmlValidator(FileFormatVersions.Microsoft365)。它的返回值是惰性枚举——不是一次性算完的列表,而是每取一条才现算一条,所以异常不是在调用时抛出,而是在遍历到某一条时才炸。因此取结果的循环整个包在 try/catch 里,单条出问题就记一条 ValidatorNullReference 或 ValidatorException 继续走,不让一条坏记录掀翻整个命令。
最后剔一类已知误报。 IsBenignChartExValAttributeError 专门去掉 chartEx(Excel 的新版图表,帕累托图、直方图这类)里 cx:axisId / cx:binCount / cx:binSize 把 id 写在 val 属性上时的两条报错。真 Excel 只认这种属性写法,校验器的 schema 模型跟不上,剔除条件卡得很窄——必须同时命中 chartEx 部件、这三个元素之一、那两句特定消息。
输出这块有两个细节容易被忽略。一是 validate 是”判决型”命令:JSON 信封里的 success 直接等于 errors.Count == 0,退出码同步(有错返回 1)。二是非 JSON 模式下,错误清单打到 stderr 而不是 stdout,每条形如 [{ErrorType}] {Description},带 Path: 和 Part: 两行缩进——CI 里拿 validate 当闸门的人,别只盯 stdout。JSON 里的结构是 {"count":N,"errors":[{type,description,path,part}]},由 CommandBuilder.cs 里的 FormatValidationErrors 手工拼出来。
三、view issues:问题分类学
view <file> issues(模式名可缩写成 i)走的是另一套。它产出的是 DocumentIssue(src/officecli/Core/DocumentIssue.cs),字段有 id / type / subtype / severity / path / message / context / suggestion。分两层:
粗分是 IssueType 枚举三个桶——Format(格式)、Content(内容)、Structure(结构);严重度是 IssueSeverity 三档——Error / Warning / Info。
细分是 Subtype,一个 snake_case 的稳定字符串,专门给 Agent 做精确匹配用,同时也是 view issues --type 接受的过滤值。目录在 src/officecli/Core/IssueSubtypes.cs,注释里明说它是”单一事实来源”,为的是让 CLI 前端和常驻服务端对非法值的拒绝行为完全一致。ValidSubtypes 数组里现在是 14 个:formula_not_evaluated、formula_cache_stale、formula_ref_missing_sheet、formula_eval_error、field_not_evaluated、field_cache_stale、slide_field_not_evaluated、notes_unresolved_rid、low_contrast、chart_series_ref_missing_sheet、chart_cache_stale、definedname_broken、definedname_target_missing、broken_part_ref。
用这个过滤器有几条规则写在 TypeHelpDescription() 里,都是能直接影响你怎么写脚本的:
- 三个桶名
format/content/structure各有单字母别名f/c/s,别名可用但刻意不出现在给用户看的列表里。 - 子类型是格式相关的:
formula_*/chart_*/definedname_*只对 xlsx 有意义,field_*对 docx,slide_field_*/notes_unresolved_rid/broken_part_ref/low_contrast对 pptx。对不适用的文件请求某个子类型,返回 count=0,不报错——这条很重要,你不能用”没报错”推断”这个文件没这个问题”。 - 所有值大小写不敏感、自动去首尾空格。写错了会抛
CliException,Code是invalid_issue_type,并把完整合法值列表塞进ValidValues返回给你。 chart_cache_stale是唯一的 opt-in 子类型:默认不扫,--type content也不含它,必须点名请求。
看几条具体的 issue 就明白这套分类想区分什么。xlsx 里同一个公式单元格可能落到三个不同子类型:公式引用了已被删掉的工作表是 formula_ref_missing_sheet(Error,消息里直说”officecli 的求值器会静默返回 0,而 Excel 会显示 #REF!”);既没有缓存值、求值器也算不出来是 formula_not_evaluated(Warning,“Formula written but not evaluated”);缓存值和重新求值的结果对不上是 formula_cache_stale(Warning,消息里把 cachedValue 和 computedValue 都打出来)。docx 的 field_not_evaluated 给的建议是”用 Word 打开一次,或者跑一次 TOC 更新,让结果 run 被填上”。pptx 的 low_contrast 判的是深色底上的深色字(填充亮度低于 30%、文字亮度低于 80%),而且只看显式声明的颜色——主题色、继承色、带 lumMod/shade 变换的颜色一律跳过,为的是把误报压到接近零。
还有一个刻意的语义差别:view issues 是”探测型”命令,即使列出一堆问题,信封里的 success 仍然是 true——列问题是它的正常输出,不是失败判决。这跟 validate 正好相反。你的脚本别拿 success 去判 issues 的有无,要读数组长度。
四、组成部分速查
| 组成部分 | 它负责什么 | 仓库位置 | 你什么时候会碰到它 |
|---|---|---|---|
BuildValidateCommand | 装配 validate 命令、决定退出码与信封判决 | src/officecli/CommandBuilder.Check.cs | 想知道 validate 为什么返回 1、为什么报告在 stderr |
ValidateDocument | 克隆快照、preflight、跑官方校验器、剔误报 | src/officecli/Core/RawXmlHelper.cs | 想知道某条 schema 报错是真错还是已知误报 |
DocumentIssue | 单条文档问题的数据结构(含 subtype/severity/suggestion) | src/officecli/Core/DocumentIssue.cs | 解析 view issues 的 JSON 时 |
IssueSubtypes | 子类型目录 + --type 值校验 + 帮助文案生成 | src/officecli/Core/IssueSubtypes.cs | 写过滤脚本、拿到 invalid_issue_type 时 |
CliException | 带 code / suggestion / help / validValues 的结构化异常 | src/officecli/Core/CliException.cs | 让 Agent 按错误码分支而不是按文本 |
SafeRun / WriteError | 全命令异常兜底、XmlException 友好化、退出码 1 | src/officecli/CommandBuilder.cs | 命令整体崩掉、只拿到一句 Error: 时 |
ReportNewErrorsAsWarnings | 改动前后各跑一次校验,只报”新增”的错误 | src/officecli/CommandBuilder.cs | 用 raw-set / add-part 动了原始 XML 之后 |
这些文件名里的 CommandBuilder.*.cs 是 C# 的分部类写法——同一个类拆成多个文件,编译时合成一个。这个项目把每组命令拆一个文件,仓库里 CommandBuilder*.cs 一共 18 个(在 src/officecli/ 下 ls 一下就能数出来),所以找某条命令的实现,按文件名猜比全局搜快。整个 src/officecli/ 目录是 469 个文件、其中 353 个 .cs;三个格式的处理器分别在 Handlers/Excel(47 个文件)、Handlers/Word(54 个)、Handlers/Pptx(65 个)。
五、改完之后:只报新增错误的增量校验
排障里最难受的一类场景是:文件本来就带着几条陈年 schema 错误,你改完一看还是几条,分不清是不是自己新加的。
raw-set(直接改部件原始 XML 的万能兜底命令,支持 append / prepend / insertbefore / insertafter / replace / remove / setattr)和 add-part 走的是另一套。看 src/officecli/CommandBuilder.Raw.cs:动手前先 handler.Validate(),把所有错误的 Description 收进一个集合;改完再校验一次,用 ReportNewErrorsAsWarnings 做差集,只把新出现的那些包成 CliWarning(code 是 validation_error)挂到信封的 warnings 里。raw-set 更进一步:只要有新增错误,退出码就是 1。
这个设计的含义是:你自己改坏的东西会当场被拦住,而文件原有的问题不会每次都糊你一脸。 反过来说,如果你在意存量问题,得单独跑一次完整 validate,别指望改动命令的 warnings 告诉你。
六、边界与代价
这套诊断放弃了什么,说清楚比夸它有用。
validate 只管 schema,不管设计。 仓库 skills/officecli-docx/SKILL.md 里有一节标题就叫”Honest limit”,直说:文档可以在标题层级错乱、假的 Heading 1 字号、正文里留着占位符、封面页脚为空的情况下照样通过校验。它给出的补法是把视觉检查(view … screenshot --grid 生成整档缩略图接触表)和字段结构检查当作必经步骤。
求值器不是 Excel。 formula_ref_missing_sheet 那条消息本身就承认,同一个公式项目内置求值器返回 0、Excel 显示 #REF!。所以 view issues 报出来的 xlsx 公式问题,本质是”这个工具的求值器视角下的问题”,最终以 Excel 为准。
误报是被主动接受的成本。 chartEx 那三个元素的报错被整类剔掉,代价就是这块真出问题也不会报;low_contrast 只认显式声明的颜色,代价是主题色导致的深色叠深色它看不见。docx 技能包里还专门列了一条”别追”的显示项:view issues 会给封面段落、居中标题、列表项、参考文献条目报”正文段落缺首行缩进”,而首行缩进只有学术正文体例才要求。
常驻模式下问题查的可能不是磁盘上那份文件。 非常驻模式每条修改命令都是打开—改—关闭,关闭即写盘,改的就是你磁盘上那份原文件,没有自动副本。常驻模式(resident)下改动先留在内存,写盘推迟到 save / close / 空闲自动落盘(自适应 2 到 10 秒,可用环境变量 OFFICECLI_RESIDENT_FLUSH 调成 each / auto / 秒数 / off)。这直接影响排查顺序:常驻里跑 validate 校验的是内存中的文档,而 docx 技能包的交付闸门第一步是 officecli close "$FILE" 之后再 officecli validate "$FILE",验的是关掉之后落在磁盘上的那一份。两者验的不是同一个对象,报错对不上时先想想这一层。
批量操作中途失败留下什么,取决于你选的模式。 batch 默认是原子的:任何一条失败,整批回滚,磁盘上什么都不会变(通过同卷临时文件原子替换实现)。加 --best-effort 就退回旧语义——成功的那些留在文件里,失败的不管;这时候再叠 --stop-on-error,前面已经应用的仍然保留。这三种组合出的中途状态完全不同,动生产文件之前必须想清楚。
拉外部资源的暴露面。 插图片、导入表格数据、3D 模型、媒体,这些参数可以给 URL,也就是说 URL 可能来自不受信的输入(批处理脚本、文档里嵌的指令、工具调用参数)。项目为此写了 src/officecli/Core/SsrfGuard.cs:在真正建连的回调里校验对端 IP,非公网地址(回环、内网、链路本地、云元数据端点)一律拒绝,重定向每一跳都查,最多跟 10 跳,单次远程读取上限写死 100 MB。另外 watch 命令会起一个本地 HTTP 预览服务(默认端口 26315),只接受 Host 为 localhost / 127.0.0.1 的请求以防 DNS 重绑定,而且它只感知 officecli 自己的修改,外部编辑不触发刷新。这些是既有防护,不等于没有暴露面——URL 来源仍然该在你的 Agent 那一层先把关,这和Agent 参数校验里的思路一致。
七、上手与避坑清单
先 close 再 validate,或者明确知道自己在验内存。 会踩是因为常驻进程默认存在且改动可能还没落盘,你用别的程序(或 Python 脚本)去读文件会读到旧版本,然后开始怀疑修改没生效。避法:任何要交给非 officecli 程序读的时刻,前面加一条 save 或 close。
别用消息文本做分支,用 code 和 subtype。 会踩是因为消息里带路径、单元格引用、缓存值,随文档变;而 code(invalid_issue_type、unsupported_type 这些)和 subtype(snake_case)在代码里就是按”稳定标识符”定位的。避法:解析 JSON 信封,匹配 error.code 和 issues[].subtype。
--type 拼错不会静默。 会踩是因为很多 CLI 对未知过滤值就返回空。这个项目故意反着来——IssueSubtypes.Validate() 直接抛异常并回列全部合法值。避法:拿到 invalid_issue_type 别去猜,读返回里的 validValues。
“count=0”不等于”没问题”。 会踩是因为子类型是格式相关的,对 pptx 查 formula_not_evaluated 稳定返回 0 且不报错。避法:先确认这个子类型适用于当前格式,再解读 0。
view issues 的 success 永远是 true。 会踩是因为 validate 和它相反,很容易写出一套判据用两边。避法:判决型命令看 success 与退出码,探测型命令看数据长度。
别把 stderr 丢掉。 会踩是因为非 JSON 模式下 validate 的错误清单走 stderr,脚本里只重定向 stdout 就会看到”什么都没输出”却退出码 1。避法:CI 里要么加 --json 统一从 stdout 拿结构化结果,要么把两个流都收下。
报错说 “Malformed XML in document part” 时,别去查工具。 会踩是因为这句是被重新包装过的 XmlException,看起来像内部错误。它的实际含义是文件里某个部件的 XML 已经坏了——先去 validate 看 preflight 报的是哪个 Part,那才是现场。
改原始 XML 之后看 warnings,不看 errors 总数。 会踩是因为存量错误的存在让总数没有信息量。避法:认 raw-set / add-part 返回里的 warnings(code validation_error),那是差集出来的新增项;退出码 1 也是这个意思。
收束
按这个顺序走通常最省事:命令整体崩了先看 error.code;命令跑通但结果不对,先 close,再 validate 判文档合不合规范;schema 干净但文档还是不对,用 view issues 按桶粗筛、按 subtype 精筛;改完立刻看返回里的 warnings 确认自己没引入新错误;最后才是打开渲染或截图看一眼——schema 通过不等于交付合格,这一点仓库里的技能包比谁都说得直白。
想再往里读一层,建议按这个次序:Core/IssueSubtypes.cs 看全部子类型和格式适用范围,Core/RawXmlHelper.cs 里的 ValidateDocument 看校验流程和每一步的历史原因,CommandBuilder.Check.cs 看退出码与信封契约,CommandBuilder.Raw.cs 看增量校验怎么接进改动命令。这四个文件读完,这个工具报出来的绝大多数错误你都能直接定位到产生它的那几行。
本篇属于一个把开源Office 文档读写套件 OfficeCLI逐层拆开讲的系列,整体地图见 OfficeCLI 是什么:给 AI Agent 用的开源文档读写套件;沿着这条线往下,还可以看 开源项目 OfficeCLI 的两套 SDK:什么时候别直接调命令 和 OfficeCLI 开源项目上手:三种装法怎么选,第一次跑前先懂这件事。