OfficeCLI 开源项目的选择器语法:三份解析实现与最常见的选错点
本文基于 OfficeCLI 仓库 commit 459b1a4(2026-08-04)梳理,该项目仍在高频迭代,具体行为以仓库 https://github.com/iOfficeAI/OfficeCLI 最新代码与文档为准。
在 OfficeCLI 里,/slide[3]/shape[2] 的意思不是「第 3 页上第 2 个看得见的东西」,而是「第 3 页形状树里第 2 个 <p:sp> 元素」——图片、表格、图表、组合都不参与这个编号。 这条规则写在 src/officecli/Handlers/Pptx/PowerPointHandler.Resolve.cs 的 ResolveShape 里,只有一行 shapeTree.Elements<Shape>().ToList(),但它是整套定位语法里最高发的踩坑点:你在 PowerPoint 界面上数到第 2 个对象,命令行数出来的可能完全是另一个。
先做一次澄清,免得读串:OfficeCLI 是 GitHub 上 iOfficeAI/OfficeCLI 这个 Apache-2.0 开源项目的专有名字(NOTICE 写明 Copyright 2026 OfficeCLI,由 goworm 创建维护),不是「用命令行操作 Office」这件事的泛称,也和微软没有任何从属或授权关系。它是一个单二进制的命令行工具,不装 Office 就能读写 .docx / .xlsx / .pptx 这三种文件格式。下文提到 Word、Excel、PowerPoint 时,指的是文件格式和对应的桌面应用,不是这个项目的出身。
站内已有几篇相邻的文章:让 Agent 的输出守住约束 和 结构化输出不稳怎么兜 谈的都是模型侧——怎么让模型吐出你要的格式;Pascal Editor 的选择机制 拆的是另一个项目在三维场景里怎么指到某个对象。本篇只谈工具侧:一条字符串交给 OfficeCLI 之后,它是怎么被拆成「哪个文件、哪一页、哪个元素」的,以及在哪些地方会拆歪。
一、它为什么要自己造一套
Office 文件的真身是 OOXML:一个 zip 包,里面按分工放着若干 XML 文件(幻灯片一片一个 XML,工作表一张一个 XML,Word 正文一大坨在 document.xml 里)。要让一个 Agent 修改文档,你得先解决一件很土的事——用一行文本说清楚「改哪儿」。
这个位置串有几个硬性要求:模型写得出来、能塞进 JSON 参数、能从工具的返回值里原样复制回去、并且人扫一眼能看懂改的是啥。XPath 太啰嗦,OOXML 的 r:id 太不直观,行列坐标只对表格有意义。OfficeCLI 选的是两种写法并存:
- 路径式:
/slide[1]/shape[2]、/body/p[3]、/Sheet1/B2,一层层往下钻,1 开始计数,形状上像 XPath。get命令返回的就是这种串,可以直接粘回去给set。 - 选择器式:
shape[fill=FF0000]、paragraph[style=Normal] > run[font!=Arial](这两条是仓库SKILL.md里给出的示例),形状上像 CSS,按属性筛,不关心它排第几。
两种写法在 query(只读查询)里都能用,set / remove 也接受选择器。这个双轨设计本身值得单独说一句:Agent 的第一步通常是「找」,第二步才是「改」,找的时候需要模糊,改的时候需要精确。关于工具接口该给多大的自由度,可以对照 工具设计的取舍 一起看。
二、三份解析实现,和它们共用的那一点点
这套语法没有一个统一的解析器。Word、Excel、PowerPoint 三个 handler 各写各的,每份都是一个独立的 C# 分部类文件(分部类就是把同一个类拆到多个源文件里写,编译时再拼起来——这个项目大量用它,Handlers/Word 下 54 个文件、Handlers/Excel 下 47 个、Handlers/Pptx 下 65 个,全都属于三个 handler 类)。三份选择器解析的体量差不多:用 wc -l 数,Excel 那份 405 行,PowerPoint 445 行,Word 417 行。
真正被抽出来共用的只有很小一块:
| 组成部分 | 它负责什么 | 仓库位置 | 你什么时候会碰到它 |
|---|---|---|---|
| Word 选择器解析 | ParseSelector / ParseSingleSelector:切一次 >、允许属性名带 @ 前缀、:contains() / :empty / :no-alt | src/officecli/Handlers/Word/WordHandler.Selector.cs | 查段落、run、特殊 run(tab / break / 域) |
| PowerPoint 选择器解析 | ParseShapeSelector:slide[N] 前缀、逗号并集、~= 包含匹配与 r"..." 正则形式 | src/officecli/Handlers/Pptx/PowerPointHandler.Selector.cs | 按填充色、字体、名字找形状 |
| Excel 选择器解析 | ParseCellSelector:Sheet1! 前缀、列名捷径(B、AB 这种纯字母段当列筛选,但排除 row 这类已知元素名)、:has(formula) | src/officecli/Handlers/Excel/ExcelHandler.Selector.cs | 按值、类型、是否公式筛单元格 |
| 顶层字符扫描 | TopLevelIndexOf / SplitTopLevelCommas:只在所有 []、() 和引号之外算数 | src/officecli/Core/SelectorCommaSplit.cs | 写逗号并集,或值里带 , ! / 的时候 |
| 尾部位置索引 | TakeNth:把结尾的 [N] 当序号而不是过滤条件 | src/officecli/Core/SelectorPositionalIndex.cs | 写 shape[2]、slide[1]>shape[2] |
| 裸选择器闸门 | EnsureScoped:写操作必须带作用域,否则抛 bare_selector_rejected | src/officecli/Core/MutationSelectorGuard.cs | set "cell" 被拒绝的时候 |
| 段名别名 | paragraph→p、run→r、table→tbl、row→tr、cell→tc | src/officecli/Core/PathAliases.cs | 想写人话而不是 OOXML 缩写 |
| 属性后置过滤 | AttributeFilter:= != ~= >= <=,命中不了会带诊断信息 | src/officecli/Core/AttributeFilter.cs | 过滤键写错时收到警告而不是空结果 |
SelectorCommaSplit 只有 83 行,做的事情是一次带状态的字符扫描:遇到引号进入「不透明区」,遇到 [ ( 记深度,只有深度归零且不在引号里的目标字符才算数。它的注释里写明是给 PowerPoint 和 Excel 两条查询路径共用的,好让逗号并集在两边行为一致。Word 没用它——Word 那边切 > 用的是自己的 SplitChildCombinator,只按方括号深度判断,不看引号。这类「同一件事三份实现」在选择器代码里到处都是,比如把 #FF0000 和 FF0000 视作同色的归一化函数,Excel 叫 ColorNormalizedEquals、PowerPoint 叫 NormalizedEquals、Word 叫 NormalizeColorForCompare,各写一份。
三、[N] 到底数的是什么
回到开头那个判断。PowerPoint 一页的内容都挂在「形状树」下,但 OOXML 里它们不是同一种元素:普通形状和文本框是 <p:sp>,图片是 <p:pic>,表格和图表被包在 <p:graphicFrame> 里,组合是 <p:grpSp>。Resolve.cs 里每种资源各有一个 resolver:
ResolveShape数的是形状树的Shape子元素;ResolveChart数的是「带图表引用的 graphicFrame」;ResolveTable数的是「里面能找到表格的 graphicFrame」。
也就是说 shape[2]、chart[2]、table[2] 是三套互不相干的编号,各自从 1 开始。ResolveShapeOrdinalById 的注释把这点写死了:它统计的是和 ResolveShape 一样的元素类型(纯 <p:sp>),这样按 id 反查出来的序号才和 /slide[N]/shape[K] 对得上。
序号本身是 1 开始的。项目专门为这件事建了个 Core/PathIndex.cs,里面只有两个方法 ToArrayIndex 和 FromArrayIndex,就是 -1 和 +1。这看着像过度设计,但注释讲清了动机:代码里同时存在「路径世界的 1 基」和「数组世界的 0 基」,让转换点有名字,读代码的人就不用每次去猜某个 - 1 是换基还是普通算术。
选择器式写法里的尾部 [N] 是后补上的一层。三份解析器的属性正则都只捕获 [键 运算符 值] 这种形式,纯数字的 [2] 匹配不上,会被静默丢掉——于是 shape[2] 一度等价于 shape,匹配所有形状。只读查询时无害,但当 set / remove 开始把非斜杠选择器也走 Query 通道之后,这就变成了 SelectorPositionalIndex 注释里描述的那种情况:set "shape[2]" 改了每一个形状,还报告成功。现在的做法是在 Query 拿到结果之后再用 TakeNth 取第 N 个,正则是 \[(\d+)\]\s*$,纯数字才认,[fill=red] 和 [hidden] 不会误伤。
这层补丁有它自己的边界,代码里显式排除了两种情况:以 / 开头的斜杠路径不走它(那是另一条解析链),顶层逗号并集也不走(每个逗号分片在递归 Query 里各自完成了取第 N 个)。PowerPoint 还额外排除了 slide[N] 作为主语的情况——那个 [N] 在派发阶段已经变成页码过滤条件了,再取一次就成了「第 N 页里的第 N 个」。
四、七种最容易选错的情况
1. 用界面上看到的顺序去数 shape[N]。
为什么会踩:人眼数的是「可见对象」,代码数的是「某一类 XML 元素」。一页上有 1 个标题、1 张图、1 个表、2 个文本框时,shape[2] 指的是第 2 个文本类形状,不是那张图。
怎么避:先 get <文件> '/slide[N]' --depth 1 把这一页的元素列出来(这条命令形式来自仓库 SKILL.md),拿它返回的 path 原样往下用,别自己拼序号。
2. 在多步流程里一直复用位置序号。
为什么会踩:插入或删除一个元素,后面所有序号都会平移,第二步的 shape[3] 已经不是第一步看到的那个了。
怎么避:改用稳定 ID 形式。SKILL.md 给的示例是 /slide[1]/shape[@id=550950021]、/body/p[@paraId=1A2B3C4D],并明确建议多步流程里优先用它——位置索引会随增删漂移,稳定 ID 不会。PowerPoint 还接受 @name=,且能识别形态切换用的 !! 名字前缀——形态切换就是 PowerPoint 的「平滑切换」(morph)动画,靠前后两页同名的形状来配对,!! 是这类命名约定的标记;MatchesShapeName 里 my-box 和 !!my-box 互相匹配,你不用记住自己当初有没有加那两个感叹号。没有稳定 ID 的元素(幻灯片本身、run、表格行列)只能退回位置索引,这些地方就要格外小心增删顺序。
3. 把 PowerPoint 的分隔符习惯带到 Excel。
为什么会踩:PowerPoint 用 slide[1]>shape[2],Excel 用 Sheet1!cell[...],两套语法长得像但分隔符不同。写成 Sheet1>ole 会掉进通用 XML 兜底,按代码注释的说法是「返回一个空结果集」——不报错、不抛异常,只是查不到东西,这是数据分析场景里最难发现的一类失败。
怎么避:Excel 一律用 !。项目在 QueryDispatch 里为这个错误专门加了识别:前缀看着像表名、后缀是已知 Excel 元素类型时,往 stderr 打一条提示正确写法的警告。它只是警告不是报错,所以别指望退出码,要看 stderr。
4. 值里带 ! 或 ,,被当成了语法字符。
为什么会踩:row[Msg~=hello!] 里那个感叹号是数据,不是表名分隔符。ExcelHandler.Selector.cs 的注释记着旧实现的问题:用 ^(.+?)!(?!=) 这种正则去找分隔符,会把感叹号之前的一整段吞成「表名」。
怎么避:现在的实现改用 SelectorCommaSplit.TopLevelIndexOf 找顶层 !,括号和引号里的都不算。所以只要给带特殊字符的值加引号,语义就是稳的;反过来,如果你的值不加引号又含 , ! /,那就是在赌解析顺序。顺带一提,Excel 那边还有个善后逻辑:斜杠路径掉了前导斜杠(Sheet1/row[2]),只要存在顶层 /,会自动把斜杠补回去再解析。
5. 在 PowerPoint 里以为祖先前缀真的起了过滤作用。
为什么会踩:slide > table > tr 看着像 CSS 的层级约束,但 ParseShapeSelector 的注释写得很直白——查询引擎始终是全局查的,祖先前缀是建议性的。代码里那条 remainingCombinator 正则会把 table > 这段直接剥掉,只留右边的 tr 作主语。所以你以为限定了「表格里的行」,实际拿到的是全篇所有行。真正生效的过滤只有 slide[N] 这个页码前缀。
怎么避:需要限定范围就用斜杠路径,别指望选择器式的层级链。
6. 用了不支持的组合器,或多写了一级 >。
为什么会踩:CSS 里 +(相邻兄弟)和 ~(后续兄弟)很常用,PowerPoint 这边有个 FindUnsupportedCombinator 专门把它们识别出来(还小心地避开了 ~= 这个属性运算符)——识别出来意味着不支持。Word 这边则是 SplitChildCombinator 遇到第一个顶层 > 就返回两段,右边整段交给单节点解析器;写 p > r > x 这种三级链,第三级不会成为独立的一层。
怎么避:把层级压到一层,剩下的条件用属性过滤表达。Word 的 > 有个真实有用的细节:它按方括号深度跳过括号内的 >,所以 paragraph[size>=14pt] > run[bold=true] 能正确切开。
7. 属性键写错,却收到了非空结果或空结果。
为什么会踩:这里三边行为不一致,而且都不是你的直觉。Excel 允许写 cell[bold=true],但 get 存的规范键是 font.bold,靠 _cellSelectorAliases 这张表做映射(bold→font.bold、size→font.size、color→font.color,而 underline、strike 本身就是规范键,映射到自己);这张别名表还专门暴露了一个 ResolveCellAttributeAlias 给 CLI 层的后置过滤器用,否则会出现「handler 层匹配上了、CLI 后置过滤器又全丢掉」的诡异空集。PowerPoint 那边遇到不存在的键,是放行而不是拒绝——注释解释得很清楚:放行是为了让后置过滤器看到非空结果集,从而发出「过滤键不存在」的警告,最终由后置过滤器把节点剔掉,结果集不变但多了诊断。这也意味着绕过 CLI 直接调 Query() 的程序化调用者,拿到的结果和命令行不一样。Word 那边 text 和 type 两个键在前置过滤里被显式跳过,同样交给后置过滤器,理由是它们不是 XML 属性而是节点级元数据。
怎么避:过滤键先照着 get --json 输出的键名写,别按印象拼;以及认真读 stderr 的警告,这套设计里很多信息是从警告通道出来的,不是从退出码。
再补两个更细的:伪类的否定形式在 Excel 里靠字符串包含判断,代码必须先查否定形式——:not(:empty) 这个串里也包含 :empty,:not(:has(formula)) 里也包含 :has(formula),顺序反了结果就整个反过来。还有 PowerPoint 的尺寸容差:单位换算允许 500 EMU(EMU 是 OOXML 的内部长度单位)的误差,好让 0.07cm 和 2pt 能对上;但这个容差有个 LooksLikeDimension 门禁,要求两边都带 cm/mm/in/pt/px/emu 后缀才启用——没有这道门禁的时候,裸整数会被当成 EMU 值,[zorder=2] 会匹配到 zorder=3。
五、边界与代价:这套设计放弃了什么
它不是 CSS,也不打算是。 支持的是一层子组合器、属性过滤和少数几个伪类。相邻兄弟、后续兄弟、任意深度后代、:nth-child 这类都没有。好处是三份解析器各自四百行就能写完、行为可预测;代价是复杂定位只能靠「先 query 拿路径、再逐条 set」的两步走。
三份实现意味着三份语义。 同一个 ~= 运算符,PowerPoint 的选择器解析里认(还支持 r"..." 正则形式,正则写坏了会退回字面包含匹配),Word 的选择器正则里故意不认,留给后置过滤器处理。同一件「颜色 # 前缀归一化」的事,三个文件各写一份。你没法把在 pptx 上验证过的选择器直觉平移到 docx 上,得分别验。
前置过滤和后置过滤是两层,行为不完全等价。 handler 层的匹配更多是性能优化,最终结果由 CLI 层的 AttributeFilter 收口。这个分层带来的直接后果是:把这套东西当库调用(仓库 sdk/ 下有 node 和 python 两套)时,别假设它和命令行输出一致。
位置索引天生易碎。 项目自己在文档里承认了这点并给出稳定 ID 作为解法,但幻灯片、run、表格行列这些没有稳定 ID 的元素只能用序号。批量增删这类元素时,序号漂移是设计内的代价,不是 bug。
它明确不管的事:不管你的选择器在语义上是否合理(set 到一个不该改的元素上,它照改);不管排版效果(改了字号之后文字会不会溢出,是 view <文件> issues 那条体检通道的事——SKILL.md 把它和 validate 一起列为改完之后的核对手段);不管模型为什么写出这条选择器。
风险这块要说清楚,因为选择器直接决定写操作的爆炸半径:set / remove 改的是你磁盘上那个真实文件,不是副本,要副本得自己先复制一份。项目为此在写操作上加了裸选择器闸门——set "cell" 这种没有作用域的写法会被 MutationSelectorGuard 直接拒掉并抛出 bare_selector_rejected,错误信息里带上建议的加作用域写法;只读的 query 不设这道闸,注释里说明是因为裸类型选择器是它的主要探索形式。落盘时机也要留意:按 SKILL.md 的说法,每条命令首次访问文件时会自动拉起一个常驻进程(60 秒空闲超时),officecli 自己的读操作总能看到最新改动,但别的程序(Word、python-docx、渲染器、上传流程)读这个文件之前,需要先 save 或 close,或者把 OFFICECLI_RESIDENT_FLUSH 设成 each 让每次修改都落盘。批量操作默认是原子的:任何一项失败整批回滚,磁盘上的文件与执行前逐字节一致,--best-effort 才恢复「成功多少保留多少」的旧行为。另外 watch 会起一个本地 HTTP 预览服务(默认端口 26315),是个额外的本地暴露面,用完记得 unwatch。这几条和 改动边界怎么约定 是同一类问题:先把爆炸半径框住,再谈自动化。
六、动手前的三条自检
第一,别自己拼序号。让 get 或 query 把路径吐出来,原样传给下一步;多步流程里能换稳定 ID 就换。
第二,把值加上引号。只要值里可能出现 , ! / >,加引号,让顶层扫描把它当不透明区。
第三,读 stderr。这套实现把大量「你可能写错了」的信息放在警告里而不是错误码里:分隔符用错、过滤键不存在、值匹配不上,都是警告。只看退出码等于主动放弃一半的反馈。
想继续往下挖,读文件的顺序建议是:先 src/officecli/Core/SelectorCommaSplit.cs(83 行,看清「顶层」两个字的定义),再 src/officecli/Core/SelectorPositionalIndex.cs(52 行,看清 [N] 什么时候是序号),然后 src/officecli/Core/MutationSelectorGuard.cs(58 行,看清写操作的闸门在哪),最后按你实际要处理的文件格式,挑对应那份 *.Selector.cs 通读一遍。这四个文件加起来不到六百行,但决定了你写的每一条选择器会打到哪儿。
本篇属于一个把开源Office 文档读写套件 OfficeCLI逐层拆开讲的系列,整体地图见 OfficeCLI 是什么:给 AI Agent 用的开源文档读写套件;沿着这条线往下,还可以看 开源项目 OfficeCLI 的命令面全景:一套动词打通三种文档 和 开源项目 OfficeCLI 的查询与转储:Agent 先读懂再动手。