Skip to main content

docx 自动编号还原

·1478 words·3 mins·
Table of Contents

如果文章中有不准确的地方,欢迎留言指正。

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 把数字转换成对应的显示格式。像常见的:

  • decimal
  • upperRoman / lowerRoman
  • upperLetter / lowerLetter
  • chineseCounting
  • chineseLegalSimplified

这些都可以在这一步统一处理。

然后再把 lvlText 里的 %1%2%3 这类占位符替换掉,最终得到类似 一、(2) 这样的前缀。

3.3 把可见编号重新拼回段落文本
#

拿到编号前缀后,最后一步就很简单了:把它补回到段落正文前面。

例如原始段落文本是:

一级自动编号  

还原之后变成:

一、一级自动编号  

为了不影响下游代码,我这里没有直接返回裸字符串列表,而是做了一个轻量的 FakeParagraph,保留了 textstyleruns 这些常用属性。这样后面如果还有依赖“段落对象”接口的逻辑,也不用全部重写。

4 运行示例
#

=== 直接使用 python-docx 读取(不含自动编号) ===  
一级自动编号  
二级自动编号  
二级自动编号  
自动编号  
三、手动编号  
  
=== 使用 DocxReader 读取(还原自动编号) ===  
一、一级自动编号  
(1)二级自动编号  
(2)二级自动编号  
二、自动编号  
三、手动编号  

5 源码
#

docx-reader

Yu Yantao
Author
Yu Yantao
Software Engineer