如果文章中有不准确的地方,欢迎留言指正。
1 背景#
之前在处理一批 docx 文档时,我用的是基于标题特征的正则分段。思路是先把正文读出来,再根据标题前缀把内容切成更稳定的块,后面再交给检索和召回链路。
我当时用到的规则大致像这样:
[
"^[一二三四五六七八九十百]+、",
"^[0-9]{1,2}、(?=[^0-9])",
"^([0-9]+)"
] 这套规则本身没什么问题,处理手动编号的文档时效果也正常。但文档源并不规范,有些标题里的序号是手动敲出来的,有些则是 Word 自动编号。两种写法在页面上看起来很像,但是使用 python-docx 读取到的结果却不一样。
结果就是:同样长得像标题的内容,有的能被分出来,有的分不出来。
2 问题说明#
Word 的自动编号和手动编号不一样。
手动编号很简单,用户输入的 一、、1.、(1) 本来就是正文的一部分,自然会跟着段落文本一起被读出来。
但自动编号不是这样。对 Word 来说,段落里通常只保存“我属于哪套编号规则、我是第几级”这类引用信息,真正显示成什么样,是由 numbering.xml 里的定义和当前位置计数一起决定的。
以测试文档为例,段落本身大致长这样:
<w:p>
<w:pPr>
<w:numPr>
<w:ilvl w:val="0"/>
<w:numId w:val="1"/>
</w:numPr>
</w:pPr>
<w:r>
<w:t>一级自动编号</w:t>
</w:r>
</w:p> 这里能看到正文 一级自动编号,也能看到它挂了编号引用 numId=1、层级 ilvl=0,但你看不到最终显示的 一、。
真正决定显示效果的是 numbering.xml 里的编号定义,例如:
<w:abstractNum w:abstractNumId="0">
<w:lvl w:ilvl="0">
<w:start w:val="1"/>
<w:numFmt w:val="chineseCounting"/>
<w:lvlText w:val="%1、"/>
</w:lvl>
<w:lvl w:ilvl="2">
<w:start w:val="1"/>
<w:numFmt w:val="decimal"/>
<w:lvlText w:val="(%3)"/>
</w:lvl>
</w:abstractNum>
<w:num w:numId="1">
<w:abstractNumId w:val="0"/>
</w:num> 也就是说,段落只是在说:“我用的是 numId=1 这套规则里的第 0 级。”
至于第 0 级到底显示成 1.、一、 还是 A.,要继续去 numbering 定义里找。
这也是为什么 python-docx 直接读 paragraph.text 时,只能拿到正文,拿不到自动编号的显示结果。
3 解决思路#
既然问题出在读取阶段,最直接的办法就不是继续补分段规则,而是在“读取 Word”和“正则分段”之间,插一层自动编号还原。
我的实现里,这层逻辑主要做了三件事:
3.1 先把编号定义读出来#
工具会先解析 numbering.xml,把 (numId, ilvl) 和对应的样式信息建立映射。
这里关注的核心字段主要有:
numFmt:编号类型,例如十进制、中文计数、罗马数字、字母编号lvlText:显示模板,例如%1、、(%3)start:起始值suff:编号后缀是空格、空字符串,还是其他分隔形式
这样后面看到一个段落带着 numId=1, ilvl=0 时,就知道它应该套哪种显示规则。
3.2 遍历段落时动态计算当前编号#
段落上的 numPr 并不是最终文本,而是“这个段落属于哪个编号序列、当前位于哪一层”的描述。
所以在真正拼接编号时,我会按 (numId, ilvl) 维护当前计数,再根据 numFmt 把数字转换成对应的显示格式。像常见的:
decimalupperRoman/lowerRomanupperLetter/lowerLetterchineseCountingchineseLegalSimplified
这些都可以在这一步统一处理。
然后再把 lvlText 里的 %1、%2、%3 这类占位符替换掉,最终得到类似 一、、(2) 这样的前缀。
3.3 把可见编号重新拼回段落文本#
拿到编号前缀后,最后一步就很简单了:把它补回到段落正文前面。
例如原始段落文本是:
一级自动编号 还原之后变成:
一、一级自动编号 为了不影响下游代码,我这里没有直接返回裸字符串列表,而是做了一个轻量的 FakeParagraph,保留了 text、style、runs 这些常用属性。这样后面如果还有依赖“段落对象”接口的逻辑,也不用全部重写。
4 运行示例#
=== 直接使用 python-docx 读取(不含自动编号) ===
一级自动编号
二级自动编号
二级自动编号
自动编号
三、手动编号
=== 使用 DocxReader 读取(还原自动编号) ===
一、一级自动编号
(1)二级自动编号
(2)二级自动编号
二、自动编号
三、手动编号 