开源项目 OfficeCLI 怎么读写 Word 的表单域、修订与批注
本文基于 OfficeCLI 仓库 commit 459b1a4(2026-08-04)梳理,该项目仍在高频迭代,具体行为以仓库 https://github.com/iOfficeAI/OfficeCLI 最新代码与文档为准。
表单域、修订、批注在 XML 里的”真身”和你在 Word 里看到的样子隔了好几层:Agent 改的是真身,人验收的是样子,中间任何一层错位都不报错,只会在别人打开文件时炸。 正文改错了肉眼看得见,这三样改错了,症状往往是文件打不开,或者打得开但审阅窗格里空空如也。
先消歧:OfficeCLI 是 GitHub 上 iOfficeAI/OfficeCLI 这个开源项目,Apache-2.0 许可,NOTICE 文件写明 Copyright 2026 OfficeCLI、由 goworm 创建维护。它不是微软出品,与微软没有从属或授权关系;下文的 Word / Excel / PowerPoint 指文件格式和对应应用,不是这个项目的归属。它提供一个单二进制命令行工具,让 Agent 不装 Office 也能读写 .docx / .xlsx / .pptx。
站内已有几篇讲通用原则的:Agent 权限台过大讲”该不该让它碰”,可观察日志讲”事后怎么查”,输出约束与忠实性讲”怎么让它别乱编”。这篇不重复那些,只把一个真实仓库里的三块代码拆开,看清”文档要在人之间流转”落到实现层是什么形状。
一、这三样为什么是高危区
只给机器看的文档不需要这三样。它们全部诞生于同一个场景:文档在人之间传递,每个人的动作要留痕——你只能填我留的空、我的改动要能被你逐条同意或驳回、我不改字但在旁边说句话且这条讨论要能标记为完结。
Agent 一进这类文档,面对的就不是一段文字,而是一份带流程语义的契约。契约的麻烦在于正确性不由渲染结果决定:复选框显示成勾了,不代表它的默认状态是勾;修订标记存在,不代表它记录了真正的改动前状态。
| 组成部分 | 它负责什么 | 对应仓库位置 | 你什么时候会碰到它 |
|---|---|---|---|
| 表单域读写 | 三种传统域的解析、回读、写回与新建 | src/officecli/Handlers/Word/WordHandler.FormFields.cs | 让 Agent 填一份别人做好的模板 |
| 修订创建装饰器 | 夹在每次属性设置前后,把改动记成变更标记或修订外壳 | WordHandler.Set.Revision.cs 上半段 | 想让改动在审阅窗格里可见 |
| 修订动作分发器 | 按选择器批量接受 / 拒绝已有修订 | WordHandler.Set.Revision.cs 下半段 | 定稿前清理修订 |
| 批注扩展元数据 | 回复线程与已解决状态,id 与 paraId 互译 | WordHandler.CommentsExt.cs | 让 Agent 参与讨论而不只是留言 |
| 文档保护判定 | 判断字段在当前保护模式下是否可编辑 | WordHandler.Helpers.cs 的 GetDocumentProtection | 模板锁了保护还要往里写 |
| 落盘时机 | 决定改动什么时候真的进磁盘 | Handlers/WordHandler.cs 的 SaveDoc、Core/AtomicPackageWriter.cs | 批量操作中途失败时 |
src/officecli/Handlers/Word/ 下有 54 个文件,全是 WordHandler.* 与 WordBatchEmitter.* 的分部类切片(C# 允许一个类的代码拆在多个文件里、编译时合成一个类,这叫分部类),所以上表里的方法其实同属一个 WordHandler 类。整个 src/officecli/ 469 个文件、其中 353 个 .cs。
二、表单域:一个字段在 XML 里是五个 run
先补一个概念。.docx 本质是个 zip 包,里面装着一堆 XML 文件(这套约定叫 OOXML 包结构,每个 XML 文件称为一个”部件”)。正文在 word/document.xml,一段文字被切成若干 run(<w:r>,同一段里格式一致的连续文本片段就是一个 run)。
WordHandler.FormFields.cs 里的 AddFormField 展示了传统表单域的真实形状:它不是一个元素,而是一串兄弟 run 依次追加进段落——begin run(挂着域开始标记和 FormFieldData 这个元数据本体)、指令 run(写死的常量,代码里只有 " FORMCHECKBOX "、" FORMDROPDOWN "、" FORMTEXT " 三种)、分隔 run、结果 run(可选,就是你看到的当前值)、end run,外面还可能包一对书签起止标记。人在 Word 里看到的,只是一个能点的方框或一条能填的横线。
分隔 run 是无条件写的,理由在注释里:创建时为空的字段要靠 SetFormFieldResultText 补值,而那个函数第一行就是 if (field.SeparateRun == null) return;——没有分隔边界就没地方插结果 run,字段直接变成不可填;分隔标记本身不渲染,留着不亏。
字段名的校验很严:不许出现 /、[、],不许有空白字符、开头的 @ 或 '、内嵌的 ",异常信息给的理由是这些字符会让字段之后没法用选择器寻址。更要命的是外面那对书签:多个表单域共用同一个 ffData 名字在 Word 里合法(一张表里五个 Check1 很常见),但书签名必须全文档唯一,重名会让 Word 拒绝打开文件,而这种损坏在 SDK 校验和”XML 格式是否良好”两道检查里都是干净的。代码的做法是保留原名给 ffData,书签名冲突时加 _1、_2 后缀。同类死规矩还有两处:帮助文本和状态文本必须带类型属性,漏了打不开;下拉选项绝不能 trim,全空格的选项被 trim 成空字符串后 Word 同样拒绝打开。
读回来那一侧(FormFieldToNode)把几组易混状态拆开了:复选框的 checked(当前显示状态)和 default(初始状态)是两个独立值,下拉框的 result(当前选中索引)和 default(默认索引)也是。注释记着不拆的后果——旧代码把选中项存进了 default,读出再写回一轮,真正的默认值被掩盖、当前选中项丢失。
三、修订:创建和处置是两套完全不同的键
WordHandler.Set.Revision.cs 全文 1650 行,文件头注释把它切成两段,这个切法本身就值得抄。
第一段是创建装饰器。 它不是一个”创建修订”的命令,而是夹在每次普通属性设置前后的一层:解析出目标元素 → 调 BeginTrackChangeIfRequested,拿到一个剥掉了 revision.* 的属性字典和一个待执行的包装动作 → 用剥干净的字典跑正常设置 → 成功后再执行包装动作,把记录”改动前状态”的 rPrChange / pPrChange / tblPrChange 追加上去。快照在改动之前取、包装在改动之后落,顺序反了记录的就是改完的状态,等于没记。第二段是动作分发器,处理已存在的修订:接受或拒绝。
两段用两套不重叠的键:revision.type(ins / del / format / moveFrom / moveTo)走创建,revision.action(accept / reject)走动作,revision.author / .date / .id 是创建侧的署名。裸的 revision= 被专门写了个 RejectBareRevisionKey 拦下来,并在报错里直接说该换成哪个——不拦的话它会掉进”不支持的属性”这个通用兜底桶,给出一条对迁移毫无帮助的信息。两套键混用同样被拒为意图不明。
几条约束明显是从”Agent 会怎么用坏它”倒推的:
- 在 run 上只给署名、不给任何实际改动会被拒绝。 注释说得很直白:这样写出来的标记,“改动前”快照等于当前状态,是个记录了零改动的标记;Word 界面永远不会产生这种东西,你的名字只有在真改了什么之后才会挂上。
- 移动的两半必须显式给同一个
revision.id,否则报错——它们靠共享 id 配对。 - 已被包在修订外壳里的 run 不许再包一层,已挂着待处理变更标记的元素不许再挂一个,理由都是叠起来之后接受/拒绝的语义说不清。
处置侧还有个关键细节:所有匹配到的标记按文档逆序处理。因为接受或拒绝某些修订会连带删掉兄弟节点(删除被接受、段落标记删除导致两段合并),正序走一遍,后面那些 /revision[N] 的位置索引会在迭代中集体漂移。
寻址上,BuildRevisionPath 优先生成 /revision[@id=N] 这种基于稳定标识的路径,只有确实没有 id 才退回位置索引。单结果的 get /revision 只接受 @id=,按作者、按日期这类可能匹配多条的过滤器被明确拒绝并提示改用查询命令,理由是单结果查询在多条命中时静默返回第一条,对做”查询再取值”的调用方是个陷阱;过滤器里出现无法识别的属性名也会直接抛错。
EnumerateRevisions 能识别的标记类型数下来有 17 种,除了插入和删除,还覆盖 run / 段落 / 节 / 表格 / 行 / 单元格的属性变更、表格网格变更、行与单元格级的插入删除、段落标记的插入删除,以及移动的两半。把”修订”想成插入和删除,是严重低估。
四、批注:已解决状态根本不在批注文件里
WordHandler.CommentsExt.cs 只有 90 行,解决的却是一个容易踩空的认知问题:批注正文在 word/comments.xml,但回复线程关系和”已解决”标记不在那儿,它们在另一个部件 word/commentsExtended.xml 里,而且不用批注 id 做键,用的是每条批注第一个段落的 paraId。
于是这个文件的全部职责就是翻译:对外说用户看得见的批注 id,对内读写 paraId。GetCommentFirstParaId 按 id 找到批注、取第一段的 paraId,段落没有就现分配一个;ReadCommentExInfo 反向把父段落 paraId 换回父批注的 id——注释写明了为什么多这一步:读回来的值要正好等于你写进去的那个值。这种往返对称性,是任何要被 Agent 反复读写的接口都该有的性质。
UpsertCommentEx 新建条目时默认把已解决标记写成 false,镜像 Word 自己的输出;读取侧规定批注在这个部件里没有条目时视为未解决。这个默认值直接决定”还有哪些讨论没结”这个查询准不准。创建批注那侧配套做了两件事:指定回复对象时找不到对应批注就直接报错而不是静默丢掉;并且先给父批注也建一条记录再挂回复,因为父批注缺记录线程就不成立。
五、边界与代价
它改的是原文件,不是副本。 Core/AtomicPackageWriter.cs 把完整包序列化到同目录的一个临时文件(名字形如 .<原文件名>.savetmp-<随机串>),需要时在临时文件上后处理,再用一次原子替换盖到目标上——进程中途死掉不会留下半截损坏文件,要么旧的要么新的。但注释也写明:为了让大文件自动保存足够便宜,刷盘到物理磁盘那一步是故意省掉的,断电场景不在保证范围内。想要原件不动,得你自己先复制一份。
落盘时机在批量模式下会变。 每条变更路径都走同一个 SaveDoc(),它只在不处于延迟保存状态时才真正序列化;批量回放会打开延迟开关,把 N 次变更合并成最后一次序列化。好处是快,代价是批量跑到一半失败时,磁盘上还是操作开始前的状态,你以为写进去的前若干步只活在内存里。原子批还有一条回滚路径,直接丢掉内存里已被污染的文档树、以磁盘为准。
修订的处置有损且不可逆。 接受一处插入是拆外壳留内容,接受一处删除是真删。拒绝一处格式变更靠的是从标记里那份”改动前”快照重建属性,快照是空的就恢复不回去——仓库注释记录过这个坑:早期创建标记时把内部快照写成了空的,署名、日期、id 都在,唯独恢复用的数据没了。所以”先全部接受再改”等于主动销毁审阅历史。
明确不管的事。 通过属性设置创建插入/删除类修订,只有两处宿主有专门分支:run(把它整个包进对应的修订外壳)和表格行(往行属性里写行级插入/删除标记)。在已存在的段落、单元格、表格、节上这么干是不支持的,代码直接抛错并让你改用新增内容的路径、或把改动落到内层 run 上——它宁可报错也不肯降级成格式变更,因为那会把改动的性质记错。行那条分支的注释也说明了它为什么存在:批量回放会先建表再回头补行属性,标记必须能补挂到已有的行上。表单域的属性设置只认三类键:文本值、勾选状态、名字。修订标记附带的那个原生位置路径也是只读的,注释明确说不许拿来当接受/拒绝的目标,因为多处修订压在同一锚点上时它没法保证指对。
跨渲染器不一致是常态。 仓库 skills/officecli-word-form/SKILL.md 记了一条:某些 LibreOffice 版本导出 PDF 时会把表单复选框画成两个方框,Word 和 WPS 是一个可点击的方框。这不是文档本身的问题,但如果验收环节是”截图看看”,就会误判。
六、上手与避坑清单
先看帮助,别猜参数。 仓库技能文档自己写了这条规矩:技能文档与 officecli help docx <元素> 冲突时以 help 为准。实际印证——skills/officecli-word-form/SKILL.md 的已知问题表把表单域的最大长度和下拉选项列为不支持,但当前 AddFormField 源码里这两个键都有分支处理。为什么会踩:文档更新跟不上代码。怎么避:以 help 输出加一次真实的写入-读回验证为准,别把任何一份 Markdown 当 API 契约。
署名要伴随真实改动。 为什么会踩:习惯性地想”先打上作者标记,再改内容”。怎么避:把署名和改动放进同一次调用,或直接声明改动类型。
批量处置时别自己维护位置索引。 为什么会踩:先查一遍拿到一串 /revision[1..N] 再循环处置,第一条处置完后面的编号就全变了。怎么避:用基于稳定 id 的选择器,或让一次调用去处置一批(工具内部逆序走,你拆成 N 次调用就享受不到这个保护)。skills/officecli-docx/SKILL.md 给的批量形式是 set "$FILE" /revision --prop revision.action=accept,可用 /revision[@author=Alice] 或 /revision[@type=ins] 收窄范围。
表单域取名当变量名取。 为什么会踩:中文场景下顺手就写出”姓名 1”这种带空格的名字。怎么避:只用字母、数字、.、_、-,并控制长度;仓库技能文档记了名字过长的情况——新增时能过,校验环节会拒。
别只搬 comments.xml。 为什么会踩:以为批注都在一个文件里,做迁移或比对时只处理它。怎么避:记住线程关系和已解决状态在 word/commentsExtended.xml、按段落 id 关联;skills/officecli-docx/SKILL.md 建议把讨论标记为已解决而不是删掉,审计链还在,没结的讨论可以用带 done=false 条件的查询列出来。
复选框验收看两个值。 为什么会踩:读回来 checked 是 true 就以为对了。怎么避:checked 是当前显示状态、default 是初始状态,一份要发出去让人反复填的模板,这两个值的组合才是你真正交付的东西。
别对没有备份的原件跑批量脚本。 为什么会踩:工具就地替换目标文件,加上批量模式的延迟落盘,中途失败的现场不好还原。怎么避:跑之前复制一份,把”哪个是原件”从工具行为里剥出来自己控制。关于事先把改动范围说清楚,可以再看改动边界怎么约定。
收束
三块代码放在一起,能提炼出一条对做任何 Agent 工具都成立的判断:当一个格式里同时存在”当前状态”和”历史状态”时,工具必须把两者显式拆成两个字段,并保证写进去的值和读回来的值是同一个东西。 表单域拆 checked 和 default,修订拆创建键和动作键,批注拆正文和扩展元数据——同一个原则的三种长相。凡是把两者揉成一个字段的实现,都会在往返一圈之后丢掉其中一个。
想接着读源码,顺序建议是:先 WordHandler.CommentsExt.cs(90 行,看清”用户看到的 id”和”存储用的键”怎么解耦),再 WordHandler.FormFields.cs(看清一个字段在 XML 里的真实样子),最后啃 WordHandler.Set.Revision.cs(1650 行,前半创建侧、后半处置侧,可分两次读)。重点看注释里以 BUG- 开头的段落——那是这个项目被真实文件教过的地方,比任何设计文档都实在。
本篇属于一个把开源Office 文档读写套件 OfficeCLI逐层拆开讲的系列,整体地图见 OfficeCLI 是什么:给 AI Agent 用的开源文档读写套件;沿着这条线往下,还可以看 开源项目 OfficeCLI 给 Word 开的 Markdown 通道 和 开源项目 OfficeCLI 的 Mermaid 图形编译链路拆解。