开源项目 OfficeCLI 的 Word 结构操作:章节、目录、页眉页脚与导航各自成块

2026-08-05

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

让 Agent 写一份结构正确的长文档,难的从来不是文字,而是文档的”结构”压根不住在正文里——它散落在四个互不相邻的存储位置,每一处有自己的身份系统、自己的生效条件、自己的失效方式。 你让模型写完二十页内容,它交回来的是一串段落;而”第三章换成横版""目录能点进去""首页不显示页眉”这些要求,改的是完全不同的四个地方。OfficeCLI 这个开源项目(Apache-2.0,NOTICE 写明 Copyright 2026 OfficeCLI,由 goworm 创建维护)把这四块拆成了四套独立的代码路径,本文就沿着它的源码把这四块讲清楚。

需要先消歧两件事:OfficeCLI 是上面那个开源仓库的专有名字,不是泛指”用命令行操作 Office”这类做法;它也不是微软出品,下文提到 Word / Excel / PowerPoint 时指的是文件格式和应用本身,与这个项目的归属无关。

站内已有几篇相邻的文章:AI 写作工具怎么选 讲的是”内容怎么产出”,结构化输出为什么不稳Agent 输出约束的忠实度 讲的是模型这一端如何把话说成规定的形状。本篇的分工在下游:内容和 JSON 都对了之后,它们落到一个真实 .docx 文件里时结构为什么还会错。

一、先看清 Word 的”结构”到底存在哪

.docx 本质上是个 zip 包,里面按”部件(part)“分成若干 XML 文件——正文一个、样式一个、每个页眉各自一个。这套约定叫 OOXML 包结构。理解这一点,四块结构为什么无法用一次操作搞定就很自然了:它们物理上不在同一个文件里。

组成部分它负责什么对应仓库位置你什么时候会碰到它
章节断点页面尺寸、页边距、分栏、页码格式、页眉引用src/officecli/Handlers/Word/WordHandler.Add.Structure.csAddSection要求”某几页横版""这一章页码重新计数”
目录域一段可重算的表达式,本身不存条目同上文件的 AddToc;条目生成在 src/officecli/Core/WordTocBuilder.cs要求”开头放个能点的目录”
页眉页脚独立部件 + 章节里的一条引用AddHeader / AddFooter,读回在 WordHandler.Helpers.HeaderFooter.cs要求”首页不要页眉""页码写成第 X 页共 Y 页”
路径导航把上面三者变成可寻址的坐标src/officecli/Handlers/Word/WordHandler.Navigation.csNavigateToElement每一次多步编排,上一步的返回值要当下一步的输入

这四个文件的规模差得很远:WordTocBuilder.cs 276 行、WordHandler.Helpers.HeaderFooter.cs 188 行,而 WordHandler.Add.Structure.cs 3105 行、WordHandler.Navigation.cs 7115 行。这个悬殊本身就是信息——生成结构是小事,判断”当前这个结构到底指向哪、算第几个”才是大头。

顺带解释一个 .NET 概念:WordHandler 用的是分部类(partial class),同一个类的代码拆在多个文件里,编译时再合成一个。Handlers/Word/ 目录下共 54 个 .cs 文件,其中 46 个声明了 partial class WordHandler。你在仓库里找某个行为时,别指望有一个”WordHandler.cs 主文件”能看全。

二、章节:一个看不见的段落,扛着整页的几何

Word 里的”章节”不是一个独立元素,而是一个空段落——它的段落属性里塞了一份 SectionPropertiesAddSection 干的就是造这个空段落:新建 Paragraph,挂上 ParagraphProperties,把填好的 sectPr 放进去,再由 AttachSectionBreakParagraph 插到指定位置。

断点类型走一份白名单:nextPagecontinuousevenPageoddPagenextColumn,其余值直接抛出参数异常并把合法取值列在错误信息里。这种”拒绝比容忍好”的取向在这份文件里反复出现,后面还会看到。

有两个细节值得单独拎出来,因为它们直接决定 Agent 生成的指令会不会翻车。

其一,方向与尺寸谁说了算。 代码里有个 dimsAuthoritative 判断:只有当调用方没有同时给出显式宽和显式高时,orientation=landscape 才会自动把宽高对调;两个尺寸都显式给了,就以尺寸为准不再旋转。源码注释说明了原因——Word 按 w:w/w:h 字面渲染,w:orient 更接近打印机与界面的提示位;一个合法的窄而高的横向页面若被自动旋转,可用高度会缩水并多溢出一页。对你的含义很直白:提示词里让模型改页面方向时,要么只给 orientation,要么把宽高一起给全,别三个都给一半。

其二,继承的是值不是引用。 新章节的页面尺寸从正文末尾那个章节复制而来,代码显式复制了数值而非共享 UInt32Value 对象。注释写明了不这么做的后果:两个章节共享同一份尺寸对象,后续对新章节做一次方向切换,会同时改掉原章节的尺寸。这类 bug 在批量生成里尤其难查——你只改了第三章,第七章跟着变了。

AddSection 的返回值是 /section[N],而这个 N 是”文档顺序里第几个带 sectPr 的段落”,不是章节总数。源码注释明确说了为什么:用总数会在 --before/--after 中插时算错,新章节未必是最后一个。

三、目录:写下去的是一句承诺,不是内容

这是最容易让人误判”已完成”的一块。

AddToc 做的事情非常有限:拼一条域指令,形如 TOC \o "1-3",默认追加 \h(超链接),当调用方把页码关掉时追加 \z,末尾补 \u;如果传了自定义样式映射就补 \t,传了书签范围就补 \b。然后在段落里依次放五个 run——域开始、域指令、域分隔、占位文本、域结束。那段占位文本原样是 Update field to see table of contents

先解释”域(field)“:它是 Word 里一段可重算的表达式,磁盘上同时存着”指令”和”上一次算出来的结果缓存”。你在 Word 里看到的目录条目,是缓存;F9 刷新,是让 Word 按指令重算并覆盖缓存。

AddToc 里有一段注释交代了一个刻意的取舍:它不写 updateFieldsOnOpen,因为那会让 Word 每次打开都弹”是否更新域”对话框;代价是在没有排版引擎的前提下,域结果默认就是那句占位文本,直到用户右键更新或按 F9。

真正把条目生成出来的是 WordTocBuilder.cs,四阶段流水线:枚举标题 → 保证书签 → 生成条目 → 回填页码。

枚举标题这一步的判定顺序值得记:先看段落自己的 outlineLevel(0-8 映射成 1-9 级),再查样式定义里带 outlineLevel 的样式表,最后才用 ^Heading([1-9])$ 正则兜底老式样式名。它还会跳过文本框内的段落——只有正文层级的标题才进目录,这条与 Word 的行为对齐。

书签这一步给每个标题挂一个稳定锚点:已有以 _Toc 开头的书签就复用,否则生成 _Toc 加 8 位十六进制的名字。条目段落按级别套用 TOC1/TOC2/TOC3 这类样式,页码位置写的是一个 PAGEREF 域,结果 run 先填字符串 0

页码从哪来?Core/WordHtmlRefresh.cs 先调 RegenerateAllTocs,再把文档渲染成 HTML、用浏览器的分页拿到锚点到页码的映射,回填进那些 PAGEREF 的结果 run,顺带把总页数写进扩展属性。这个文件的注释自己就写明了边界:页码来自浏览器分页而非 Word 的排版引擎,与 F9 的结果可能不同,但与 OfficeCLI 自己的 HTML 预览内部一致。

refresh 命令的实现印证了这个优先级:在 Windows 上先尝试 Word 后端,失败才回落到 HTML 分页,且回落时会往 stderr 打一条提示说明页码可能与 Word 不同。该命令的自述里写的是这件事对 .docx 需要 Word 与 Windows。

所以”Agent 写完目录了”这句话在工程上没有意义。它至少有三个不同状态:域已插入 / 条目已生成 / 页码已回填。三者由三条不同的代码路径产生,交付前必须说清停在哪一档。

四、页眉页脚:三种类型加一条引用

一个页眉在磁盘上是独立部件,章节属性里放的是一条 HeaderReference,靠关系 id 指向那个部件。类型只有三种:defaultfirsteven;同一章节里同类型重复,AddHeader 直接抛错,错误信息里还顺带提醒水印会自动创建这三类页眉,让你先处理水印。

有两处”自动补开关”是这一块最容易被忽略的机制:

  • type=first 时会在章节属性里补 titlePg。不补的话,Word 渲染时会无视这条首页页眉引用。
  • type=even 时会去设置部件里补 evenAndOddHeaders。同理,不补则偶数页页眉被静默忽略。

两处都留了退出通道(属性名分别对应不写 titlePg 与不写 evenAndOddHeaders 的开关),供转储回放场景使用——源码注释说明理由是:源文档本来就没有这个开关时,自动补会凭空多出一个原文件没有的标记,破坏往返一致性。

还有一处设计很能说明这个项目对”往返”的执念:孤儿引用的处理。如果章节里有一条 HeaderReference,它的关系 id 指向的部件已经不存在(ReferenceResolvesToPartGetPartById 探测,抛异常即视为解析不到),代码不把它当重复冲突,而是新建部件后把这条引用重新接线过去。判断依据写在注释里:真重复是用户错误,孤儿是往返残留,两者的正确处置方式相反——前者该拒绝,后者该修复。分不清就会丢内容。

页眉部件的规模上限也是硬的:一次 add 最多支持一段文本加一个域。仓库 schemas/help/docx/header.json 里明写了这条,并给出了”第 X 页共 Y 页”的四步做法——先建页眉带文本,再往 /header[1]/p[1] 里依次追加域、文本、域。

读回这一侧有个不对称,值得写进你的检查脚本:GetHeaderTexts 直接把所有文本节点拼起来;GetFooterTexts 则会解析域结构,把域指令渲染成形如 [PAGE] 的标记,并跳过域分隔之后的结果 run(那里是过期缓存)。也就是说,同样一个页码域,从页眉读回和从页脚读回,拿到的字符串形态不一样。

五、导航:路径是唯一的坐标系

多步编排能不能收敛,取决于一件事:上一步的返回值能不能直接当下一步的输入。NavigateToElement 就是这套坐标系的实现,顶层为若干种元素单独开了解析分支:/bookmark/sdt/section[N]/chart[N]/toc[N]/formfield[N],之后才落到按部件名分发的 /body/header/footer/styles/numbering/settings/comments/footnotes/endnotes

几个关键约定:

/section[N] 解析到承载段落,而正文末尾那个章节属性刻意不作为锚点目标——注释说明它必须保持为 body 的最后一个子元素。

/toc[N] 的计数逻辑与 AddToc 的返回值计算完全同源:都是”正文里含以 TOC 开头的域指令的段落,按文档顺序数第几个”。两处一致,返回值才敢直接拿去当 --after

/header[last()] 曾经解析错。代码里 PartIndex 这个局部函数专门处理 last(),注释记录了修复前的症状:last() 落进默认分支变成索引 0,于是写进了第一个页眉——在存在多类型页眉的文档上就是内容覆盖。

页眉页脚往哪个章节挂,由 ResolveTargetSectPrForHeaderFooter 决定:它用正则只认 /section[N] 这一种形态,其它任何路径(含 //body)一律落到正文末尾那个章节属性。这条规则不复杂,但你若让 Agent 自由拼路径,写偏一个字符就会挂错章节,且不会报错。

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

它不做排版。没有版面引擎,就没有真正的页码,目录页码要么借 Word(Windows)算,要么借浏览器分页近似。任何依赖”精确页数”的需求,这条链路给不了保证。

它拒绝生成结构上说不通的东西,代价是灵活性。目录不能加进页眉页脚部件——AddToc 直接拒绝,理由是目录域引用的是正文标题;脚注和尾注同样不能加进页眉页脚,源码写明 OOXML 的页眉页脚内容模型里没有这些引用,Word 会静默忽略,于是它选择在入口就报错。同一族的还有直接添加修订标记、裸的批注范围标记、altChunk 等,都被明确挡在门外并在错误信息里指出替代路径。

它默认不打扰用户。不写”打开时更新域”,换来的是不弹窗,代价是默认打开看到的是占位文本。

它按属性键做严格分派,不认识的点分隔键会走一层通用回落,仍不匹配才登记为未支持项。AddSection 里维护了一份”已被显式消费的键”集合,防止回落层对着错误的节点二次应用——这类账目在一个属性表长达五十余键的元素上(schemas/help/docx/section.json 里列出的章节属性键就是这个量级)不是洁癖,是必需品。

关于”它明确不管”的还有一条要说清:这个工具直接读写你磁盘上的真实文件。单条 add/set 走的是即时序列化,改的就是原文件;batch 默认是原子模式,在同目录建一份临时副本,全部成功才顶替原文件,--best-effort 才恢复就地语义——那种模式下中途失败会把已成功的那部分留在文件里。常驻模式(open 保持进程、save 刷盘、close 刷盘并退出)下改动先在内存里,命令自述写明外部程序直接读盘会看到改动前的文件,空闲后有自适应刷盘(2 到 10 秒,可用 OFFICECLI_RESIDENT_FLUSH 设为 eachauto、秒数或 off)。watch 只感知 OfficeCLI 自己的修改,外部编辑不会触发刷新。至于拉取外部资源:远程 URL 抓取统一走 Core/SsrfGuard.cs,在连接时校验实际 IP,非公网地址一律拒绝,自动重定向上限 10 跳——注释里把威胁模型说得很直白,URL 可能来自批处理脚本、文档里嵌的指令或工具调用参数,属于不可信输入。

七、上手与避坑清单

把”插入目录”当成”生成了目录”。 会踩是因为 AddToc 只写域和占位文本,get 读回也看不出差别。避法:插完显式跑 refresh,或在交付说明里写清需要在 Word 里按 F9,别让下游以为拿到的是成品。

首页页眉或偶数页页眉不生效。 会踩是因为 Word 需要 titlePg / evenAndOddHeaders 这两个开关配合,光有引用不渲染。工具正常路径会自动补,但那两个”不自动补”的开关是给往返回放用的。避法:手写指令时别去碰它们。

同类型页眉重复报错。 会踩通常是因为文档里已经有水印——水印会自动创建三类页眉。避法:按错误信息提示,先移除水印再加页眉。

自己数序号拼路径。 会踩是因为 /section[N]/toc[N] 都是文档顺序而非创建顺序,中间插入一个就把后面全部改了号。避法:只用 add 的返回路径当锚点,把”记住上一步返回值”写进 Agent 的工具契约里——这正是 工具返回值该怎么设计 讨论的那类问题。

往页脚里加脚注。 会踩是因为它读起来像”页面底部的注释”,语义上很像。实际是两套东西,工具会直接拒。避法:脚注只加到正文段落路径。

方向和尺寸一起给。 会踩是因为两个尺寸都显式时不再自动旋转,与”我给了 landscape 就该横过来”的直觉相反。避法:二选一。

批量写完立刻让别的程序读文件。 会踩是因为常驻模式下改动还在内存。避法:交接前显式 saveclose,别赌自动刷盘的时间窗。

让 Agent 自由拼 URL 拉素材。 会踩是因为一条来自文档内容的 URL 就能变成内网探测的入口。工具这一侧有防护,但你自己的边界也该收紧——参考 Agent 改动边界的约定。避法:素材先落本地,再喂路径。

收束:交付前的四问

回到开头那个判断。结构之所以比正文难,是因为正文只有一个状态(写了或没写),而结构每一块都有”已写入 / 已生效 / 已刷新”三个不同的档位,且三个档位由三条代码路径分别负责。交付一份长文档前,把这四件事问一遍:

  1. 章节断点用的是哪个返回路径,中途有没有插入操作让编号漂移?
  2. 目录停在哪一档——域已插入、条目已生成,还是页码已回填?
  3. 页眉是三类里的哪一类,对应的渲染开关在不在?
  4. 文件真的落盘了吗,还是还在常驻进程的内存里?

想继续往下读,建议的顺序是:先 schemas/help/docx/ 下的 section.jsontoc.jsonheader.json 三份属性表——它们是可读性最好的入口,把每个属性的读回口径都标了出来;再回到 WordHandler.Add.Structure.cs 看写入侧;最后是 WordHandler.Navigation.cs,那 7115 行里有大量以 bug 编号开头的注释,每一条都是一次往返失真的现场记录,比任何设计文档都好读。

本篇属于一个把开源Office 文档读写套件 OfficeCLI逐层拆开讲的系列,整体地图见 OfficeCLI 是什么:给 AI Agent 用的开源文档读写套件;沿着这条线往下,还可以看 OfficeCLI 开源项目:往 PPT 里塞三维模型做到了哪一层开源项目 OfficeCLI 给 Word 开的 Markdown 通道

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