开源项目 OfficeCLI 给 Word 开的 Markdown 通道

2026-08-05

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

**这条通道是一次性展开,不是一条可以往返的格式桥。**Agent 交出去一段 Markdown,OfficeCLI 把它翻译成一批普通的 Word 段落、列表和表格,翻译一结束,Markdown 这个中间形态就不存在了——文档里没有留下任何”这块曾经是 Markdown”的痕迹。你之后想改一个字,只能回到 Word 元素层去改,不能改 Markdown 再重放一遍。想明白这一点,后面所有的设计取舍和失真点都能顺着推出来。

先说清楚这里讲的 OfficeCLI 是什么:它是 GitHub 上 iOfficeAI/OfficeCLI 这个 Apache-2.0 许可的开源命令行项目(NOTICE 文件写明 Copyright 2026 OfficeCLI,由 goworm 创建维护),不是微软的产品,跟 Word/Excel/PowerPoint 这几个应用之间只有”读写它们的文件格式”这一层关系。仓库 README 把自己定位成”world’s first and the best Office suite designed for AI agents”——这是项目自己的说法,本文只做机制拆解,不替它背书。

站内已经聊过几个相邻话题:AI 翻译工具怎么选 谈的是自然语言之间的转换,结构化输出为什么不稳 谈的是让模型稳定吐出 JSON 这类结构,AI 写作工具横评 谈的是内容生成本身;这篇的位置在它们下游——内容已经写好了、结构也定了,剩下的问题是怎么把一段 Markdown 无损(或者说,可控地有损)地变成一份能交付的 .docx。

一、这条通道要替 Agent 省掉什么

不走 Markdown 通道时,Agent 生成一份带标题、正文、列表、表格的 Word,需要在文档模型上一条一条下指令:加一个段落、给它挂标题样式、再加一个段落、把其中三个字设成粗体、再建一张三行五列的表、逐格填字。OfficeCLI 的 L2 层(README 里把命令分成 L1 视图 / L2 DOM / L3 原始 XML 三层)确实支持这么干,addsetgetqueryremovemove 都在这一层。问题是一份两千字的文档要几百条指令,每条都要 Agent 自己算路径、自己记住上一段加到哪了。

Markdown 通道把这一整摞压成一条:add 命令带上 --type markdown,把整段 Markdown 作为属性传进去,剩下的展开交给它。属性的规范键叫 markdown,别名是 textmd;也可以不传正文、改传 src(别名 path)指向一个 .md 文件——这两条输入路径在 WordHandler.Add.Markdown.cs 的开头就能看到,先取内联文本,取不到再看 src/path,文件不存在就直接报错,两者都空则抛出提示。

有个容易被忽略的事实:这条通道只有 Word 有。仓库的 schemas/help/ 下按格式分出 docx/pptx/xlsx 三个目录(另有一个放公共定义的 _shared),markdown.json 只出现在 docx 目录里。所以”OfficeCLI 支持 Markdown”这句话要加限定词——它支持的是”把 Markdown 展开成 Word 内容”,不是全格式通用能力。

markdown.json 里的 operations 字段把这条通道的性格写得很直白:add 为 true,set/get/query/remove 全是 false。schema 的说明文字里管这叫 ADD-ONLY,并且点明”没有持久化的 markdown 节点”,add 返回的是第一个块的路径。这不是功能没做完,是它跟 diagramequation 属于同一类:一次性展开成原生元素,之后你编辑的对象是那些元素,不是源文本。

二、两层结构:中立的中间表示 + Word 侧的落地

这条通道在代码上被切成两半,切口很干净。

前半是 src/officecli/Core/Markdown/ 下的两个文件(这个目录当前就这两个 .cs 文件),跟 Word 完全无关。MarkdownParser.Parse(text) 吃一段文本,吐出一个 MarkdownDocument。这个 MarkdownDocument 是所谓的 IR(intermediate representation,中间表示——把源文本先翻成一套自己的数据结构,后续处理都对着这套结构做,不再碰原始字符串)。MarkdownModel.cs 的注释把它的定位说得很清楚:格式中立,不认识 OOXML,也不认识任何具体的文档处理器。

(OOXML 是 .docx/.xlsx/.pptx 这三种文件的底层标准:一个 .docx 其实是个 zip 包,里面装着若干 XML 部件,正文段落长成 <w:p> 这样的标签。手写 OOXML 就是直接拼这些 XML。)

后半是 src/officecli/Handlers/Word/WordHandler.Add.Markdown.cs。这个文件顶上写的是 public partial class WordHandler——C# 的分部类,意思是同一个类的代码可以拆到多个文件里,编译时再合成一个。Handlers/Word/ 目录下现在有 54 个文件,WordHandler.*.cs 那一大串(Add、Set、Query、Navigation、HtmlPreview……)都是同一个 WordHandler 类的不同侧面,Markdown 展开只是其中一片。

这个文件的类注释里有一句很关键的自我约束:块到元素、行内到 run 的映射,全部走处理器自己的 Add/Set 公共入口,“这里不手写 OOXML”,也不让 Core 反过来依赖处理器。翻译过来就是:Markdown 展开出来的东西,跟你手动一条条 add paragraph 出来的东西,走的是同一条代码路径,不存在”Markdown 生成的段落比较特殊”这种事。

组成部分它负责什么对应仓库位置你什么时候会碰到它
MarkdownParser.Parse文本 → 中立 IR,块级切分与行内扫描src/officecli/Core/Markdown/MarkdownParser.cs你的 Markdown 断块结果和预期不一样时
MarkdownDocument / MdBlock / MdSpanIR 本身:支持哪些块、行内带哪些标志位src/officecli/Core/Markdown/MarkdownModel.cs想确认某个语法到底有没有对应模型
AddMarkdown遍历 IR、调用 Add/Set、管事务与诊断src/officecli/Handlers/Word/WordHandler.Add.Markdown.cs追查展开出来的样式和失败后的残留
AddFlowList / AddMarkerParagraph列表编号:决定并复用同一个 numId同上列表编号莫名其妙从 1 重开时
AddFlowTable / ApplyInlineSpans表格填充、行内格式的第二遍重建同上表格单元格里的粗体不见了
markdown.json命令契约:属性名、别名、已知限制schemas/help/docx/markdown.json查参数怎么写、查官方承认的限制

MarkdownModel.cs 里的块类型是一份封闭清单,一眼数得完:MdHeading(带 1-6 的 Level)、MdParagraphMdList(带 OrderedStart)、MdCodeBlock(带 Language)、MdBlockQuoteMdTableHeaderRows)、MdHorizontalRule。行内是 MdSpan,字段只有 Text 加四个布尔标志 Bold/Italic/Code/Strike,外加一个可空的 Href。清单之外的一切,解析器的契约是降级成普通段落,并且注释里反复强调它”永不抛异常”。

解析器还有一条自我限制写在注释里:零第三方依赖,为的是 NativeAOT 和 WASM 干净(NativeAOT 是 .NET 把程序提前编译成原生可执行文件的模式,反射和动态加载越少越好,这也是这个项目能做成单个二进制的前提之一)。所以你不会在这里看到任何成熟 Markdown 库的影子,正则和手写扫描器全是自己的。

三、翻译时真正做的那些决定

AddMarkdown 最有意思的地方,是那些”本来可以偷懒但没偷”的地方。

标题不能只挂样式。AddHeading 里有一个写死的数组 HeadingSizes = { 18, 16, 14, 13, 12, 11 },对应 1 到 6 级标题的磅值。它同时做两件事:挂上 Heading1..Heading6 这个内建样式 id,再补一份直接格式(bold + size)。注释给的理由是空白 docx 里的 Heading1..9 只是空壳,光挂样式渲染出来跟正文没区别;挂样式是为了以后文档补上定义时目录和导航还能用,直接格式是为了现在就看得出层级。

引用块没有用 Quote 样式。MdBlockQuote 落地时用的是 indentLeft=720italic 的直接格式。720 是 twip(缇,OOXML 的长度单位,1440 twip = 1 英寸),也就是左缩进半英寸。理由同上:内建的 Quote 样式在空白 docx 里不存在,引用它等于什么都没发生。

代码块被拆成了 N 个段落。MdCodeBlock 的处理是把 Code\n 切开,每一行单独 add 一个段落,字体设成 Consolas。这意味着展开后的文档里没有”一个代码块”这种对象,只有一串恰好都是等宽字体的段落。分隔线同理,是一个带 borderBottom=single 的空段落。

列表编号是这里最费劲的一块。AddFlowList 用的是 liststyle(值取 orderedunordered)配合 level(0 基,钳制在 0-8),走真实的编号机制而不是样式 id。麻烦在于:一个列表的第一项创建时会铸造一个 numId(Word 里指向一份编号定义的编号),后续项如果也让系统自己去找,就会调用 FindContinuationNumId——那个方法的策略是”向前回扫找上一个列表段落”。而列表项里如果夹了代码行(那些是 Normal 样式的普通段落),回扫会被截断,下一项就会铸造一个新的 numId,真实的 Word 里表现为”1.” 之后又是 “1.”。所以代码里的做法是:第一项铸造完立刻把 numId 读出来记住,之后每一项显式传 numId=numlevel= 复用它。嵌套的子列表则递归下去、各自决定各自的编号。

有序列表的起始序号也照 CommonMark 处理(CommonMark 是目前最通行的那份 Markdown 标准化规范,把各家实现里含糊的边界情况写成了明确条款):MdList.Start 取首项的序号,3. 开头就从 3 排下去。但 start 属性只在非 1 时才传——注释写明这是为了保留跨块续编号的能力,传 start= 会强制铸造新编号。

**行内格式是分两遍做的。**第一遍只加块,第二遍才统一处理粗斜体。为什么不一边加一边设?注释里写了实测数字(这些是仓库注释中记录的观测值,不是本文测的):交错做的时候,每次 body 级的 Add 会清掉 body 的子节点索引缓存,于是每个 Set 的路径导航都要重建缓存,8000 行的格式化文本要跑约 147 秒。同一份注释里还记着另外两笔账:如果每个块都用 AfterElement(上一个) 去锚定插入位置,8000 行的列表要约 37 秒;如果不推迟保存、每个操作都完整重新序列化一遍主部件,又是约 27 秒。三处优化的方向是一致的——把 O(n²) 掰回 O(n)。

第二遍的 ApplyInlineSpans 也不是”把已有的 run 切开”,而是把段落里现有的 run 全删掉、按 span 逐个重建(run 是 OOXML 里”一段格式相同的连续文字”)。注释里给的理由同样是复杂度:切分式做法在一个有 2000 个 span 的段落上要跑约 2.5 分钟,而 LLM 输出里不带空行的大段文字恰好就长这样。重建时会把第一个 run 的属性克隆到每个 span 上(所以标题里的粗体和字号不会因为里面有个 *斜体* 就丢掉),再叠加 span 自己的标志位,Code 标志会换成 Consolas 字体,文本里的 \t 转成制表符元素。

**失败要能干净地退回去。**整个展开跑在一个推迟保存的作用域里(DeferSave),进来之前先把目标节点当前的直接子节点快照进一个集合。任何一个块抛异常,就把这次新增的子节点全部移除、让缓存失效、再把异常抛出去。注释里说明了为什么用这种”定向撤销”而不是整体丢弃:常驻会话里可能还有别人未保存的编辑,不能一起牺牲。

**诊断信息要合并。**每个块都走公共 Add(),而 Add() 在入口处会重置警告列表。不处理的话,只有最后一个块的警告能活到最后(注释举的例子:一个标题的”样式未找到”警告,会被后面跟着的列表块抹掉)。所以每加完一个块就把警告收一次,最后合并写回。

四、边界与代价:哪些地方注定失真

**链接和图片一定会被压平。**这是这条通道最大的一处放弃。MdSpan 明明带着 Href 字段,解析器也确实把 [text](url) 的目标存了进去,但 Word 侧的展开直接把它丢了——不生成超链接关系,也不嵌入图片。schema 的说明写得很具体:[t](u) 只留下 t![a](u) 变成 !a。解析器那边为空 alt 的图片(![](url))留了个后手,把 URL 本身当可见文字吐出来,免得整条信息凭空消失。

代码里没有静悄悄地把这件事咽下去。CountFlattenedReferences 会数出被压平的引用数量——按连续同 Href 的 span 归并,所以一个构造只算一次——然后追加一条警告,文案是 N link/image reference(s) flattened to plain text (hyperlink/image import lands in v2)。这句里的 v2 是代码文案自己的措辞,仓库里没有对应的时间表,本文不据此预期它什么时候到;能确定的只是当下这版会压平。注释里还补了一句:这个缺口被反复反馈过,所以宁可报警也不静默截断。对 Agent 来说,这条警告就是要读的返回信息;关于工具该怎么把这类”部分成功”的信号传回去,可以对照 Agent 工具返回值怎么设计

表格单元格里没有行内格式。AddFlowTable 拿到的 MdTable 其实每格都是一串 MdSpan,但填充时是 string.Concat 拼成纯文本、写成单个 run。注释解释了这是有意的:填格子时直接操作刚创建出来的表格元素,不走 /body/tbl[N] 的路径导航,因为每张表的第一格都会触发一次 body 子索引重建,多张表交错就是 O(M²)。代价就是格子里的粗体、代码、删除线全丢。

**表格列数有硬上限 63。**这不是这条通道加的限制,是 OOXML 的 w:tblGrid 本身的上限,WordHandler.Add.Table.cs 里对 cols > 63 直接抛错,错误文案原话是 “OOXML limits table columns to 63 (w:tblGrid max)“。一张超宽的 GFM 表(GFM 指 GitHub Flavored Markdown,用 | 划分单元格、再用 |---|---| 一行分隔表头的那种管道表就出自这套扩展)在展开中途炸掉,会触发上面说的定向回滚。

**回滚有一个已知漏网点。**这一点代码注释自己标了 KNOWN LIMITATION:列表块可能已经铸造了编号定义(abstractNum/num),而按 DOM 子节点移除的回滚回收不了它们,于是留下一批没人引用的孤儿编号项。注释同时说明它们在 Word 里不可见、不影响渲染内容——但如果你在做文件级别的字节比对或者洁净度校验,得知道有这么回事。

**它是 LLM 风格 Markdown 的子集,不是 CommonMark。**解析器注释里列的支持范围是:ATX 标题、空行分隔的段落、-/*/+1. 列表(2 空格缩进嵌套)、``` 围栏代码块、> 引用、GFM 管道表、---/***/___ 分隔线,行内支持 **粗体***斜体*`代码`~~删除线~~[文字](链接)![alt](图) 以及 CommonMark 的反斜杠转义。清单之外的东西降级成普通段落。几个具体的降级点值得记住:嵌套引用压平成一级(但引号字符不会漏出来);marker 与其内容之间隔了空行的 loose list 不被当作条目内容;四个及以上连续的 *_ 按字面输出,理由是扁平 IR 只能表达一层粗体加一层斜体,硬要配对反而会吃掉字符——注释里把这条原则写成了”文本丢失比漏掉一次强调更糟”。

**没有回头路。**ADD-ONLY 意味着你不能”改改 Markdown 再重发一次”。重发只会在文档里再追加一整份。想局部改,就得回到元素层,用 set 改那个具体的段落或 run。这在 Agent 的改动边界约定上是个硬约束,可以参考 约定 Agent 的改动边界 里的思路,把”哪些操作是幂等重放、哪些是纯追加”写进任务规约里。

五、上手与避坑

换行写不对,整篇会塌成一段。markdown.json 的属性说明专门为此写了一段警告:在普通双引号里,\n 是反斜杠加字母 n 两个字符,不会变成真换行,于是所有块黏成一行、解析器只看到一个段落。schema 给的两个真实示例是 --prop markdown=$'# Title\n\n- one\n- two'--prop text=$'1. First\n2. Second'——bash/zsh 的 $'...' 引用形式会把 \n 转成真换行。文档一长就别拼字符串了,写成 .md 文件用 --prop src=README.md(这也是 schema 里给的示例)更省事。

**别指望链接生成后还能点。**这条上面讲透了,这里只说怎么避:如果交付物要求超链接必须可点,这条通道就不该用来处理带链接的段落——把这些段落留给元素层单独处理,或者接受纯文本并在 Markdown 里把 URL 写成可见文字。展开后一定要读一眼返回的警告,N link/image reference(s) flattened 这句就是给你看的。

**先想清楚改的是不是原文件。**这个工具是直接对着你磁盘上的 .docx 动手的,不是复制一份再改。批量跑之前,副本自己先备好——尤其是 Agent 自动执行、你不在现场盯着的场景。

**常驻模式下”跑完了但文件没变”是正常的。**README 明确写了这件事:OfficeCLI 自己的读操作(get/query/view)总能看到最新编辑,但活着的常驻进程会推迟写盘。所以在任何非 OfficeCLI 的程序读这个文件之前——python-docx、openpyxl、Word 本身、渲染器、上传投递——先 save 刷盘(保持常驻)或者 close(刷盘并释放)。README 还写了常驻在空闲后会自适应地自动刷盘(2 到 10 秒,按文档实测的保存成本缩放),以及 OFFICECLI_RESIDENT_FLUSH=each 这个环境变量——设成 each 后每次修改在命令返回前就落盘,常驻仍然保持热态。流水线里下一步就要读文件的,用这个。

**批量里的两层回滚别搞混。**Markdown 展开内部有自己的定向回滚(失败就撤掉这次新增的子节点,孤儿编号除外)。而外层的 batch 命令是另一套:README 说它默认原子,任何一项失败整批回滚;--best-effort 保留已成功的项;--stop-on-error 是遇错就不再往下跑(除非配合 --best-effort,否则仍然整体回滚)。你判断”中途失败留下了什么”的时候,要看清是哪一层在管。

**src= 是一个真实的本地文件读取入口。**代码里就是 File.ExistsFile.ReadAllText,路径由调用方给。如果这个路径是 Agent 根据上游内容自由填出来的,暴露面就在这里:它能读到进程权限范围内的任意 .md。同理,Markdown 正文本身如果来自不受控的来源,展开出来的内容会原样进你的交付文档。给 Agent 固定工作目录、把 src 限制在白名单目录里,比事后检查便宜得多。

收束:几件展开之后该做的事

一份自检清单,跑完 --type markdown 之后按顺序过一遍:

  1. 返回的警告里有没有 flattened to plain text?有就说明链接或图片掉成了文字,确认这是不是可接受。
  2. 列表编号是不是连续的?重点看那些条目里夹了代码行的列表。
  3. 表格格子里该粗的地方粗了没有?按设计是不会粗的,需要的话回到元素层补。
  4. 交付前有没有刷盘?下游程序读的必须是磁盘上的最新版本。
  5. 这份文档后面还要不要改?要的话,记住改的对象是段落和 run,不是那段 Markdown。

想往下深挖,读的顺序建议是:先 schemas/help/docx/markdown.json,它是这条通道对外的契约,参数名、别名、官方承认的限制都在里面,最省时间;再 src/officecli/Core/Markdown/MarkdownModel.cs,116 行,一眼看完支持哪些块和哪些行内标志;然后 src/officecli/Core/Markdown/MarkdownParser.cs,887 行,正则和降级规则都在注释里解释了动机;最后才是 src/officecli/Handlers/Word/WordHandler.Add.Markdown.cs,502 行,事务、编号、两阶段行内格式这些真正决定输出质量的东西都在这里。这四个文件读完,这条通道你就没有盲区了。

本篇属于一个把开源Office 文档读写套件 OfficeCLI逐层拆开讲的系列,整体地图见 OfficeCLI 是什么:给 AI Agent 用的开源文档读写套件;沿着这条线往下,还可以看 开源项目 OfficeCLI 的 Word 结构操作:章节、目录、页眉页脚与导航各自成块开源项目 OfficeCLI 怎么读写 Word 的表单域、修订与批注

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