python

关注公众号 jb51net

关闭
首页 > 脚本专栏 > python > Python解析Word

Python批量解析并提取Word文档的工程化实践

作者:菩提风

本文主要聚焦Python对Word文档(.docx)的工程化结构化解析,深入OOXML底层结构,详解document.xml、numbering.xml、styles.xml等核心文件的协同解析机制,文中的示例代码讲解详细,感兴趣的小伙伴可以了解下

简介:这份资源是一套面向IT运维、云计算与项目管理方向学习者的选择题练习文档,内容覆盖项目管理流程、OpenStack组件、AWS云服务、Docker容器操作、Linux命令及网络基础等高频考点,适合备考相关认证或巩固基础知识的读者自测使用。压缩包内共1个docx文件,约23KB,以题目与参考答案的形式呈现,便于快速浏览和对照复习。文档包含单选题与多选题两种题型,涉及Keystone认证服务、RDS数据库引擎、EBS卷跨可用区迁移、VLAN取值范围、NAT通信吞吐率、收尾过程顺序等具体知识点,每道题均标注了正确选项,可直接用于查漏补缺。目前已有70人学习下载,适合需要系统梳理云计算与项目管理考点、进行针对性刷题练习的初中级技术人员参考。

1. 从一份“题目参考.docx”说起:文档解析的坑与工程化落地

很多人看到“题目参考.docx”这个标题,第一反应是——这不就是个 Word 文档吗?用 python-docx 读一下不就行了?我一开始也这么想,直到在一个真实项目里,一份 200 多页、包含 47 个表格、嵌套了 12 层编号列表、还有大量合并单元格和浮动图片的“题目参考.docx”摆在面前,用常规方法解析出来的结果惨不忍睹:表格串行、编号丢失、段落顺序错乱。这才意识到,docx 解析远不是调个库那么简单。它本质上是一个 ZIP 包,里面藏着 OOXML 的 XML 树,你要处理的是样式继承、编号映射、表格合并、文本框定位这一整套“文档对象模型”的还原问题。这篇文章面向的是需要把 Word 文档内容结构化提取出来、喂给下游检索或入库的工程师,我会把从最小可跑通的解析脚本,到表格与编号的深水区处理,再到批量场景下的性能与容错,一层层拆开讲。如果你手头正好有一批“题目参考.docx”这类文档要处理,这篇笔记能让你少走至少两天的弯路。

2. 拆开 docx 的黑匣子:OOXML 结构与解析选型

2.1 为什么 python-docx 读合并单元格会翻车

要理解解析的坑,得先知道 docx 在磁盘上到底是什么。把一个 .docx 后缀改成 .zip 再解压,你会看到 word/document.xml 、 word/styles.xml 、 word/numbering.xml 、 word/_rels/document.xml.rels 这几个核心文件。正文内容全在 document.xml 里,但段落用的字体、缩进、编号格式,分别定义在 styles.xml 和 numbering.xml 中,通过 styleId 和 numId 引用。python-docx 帮你封装了大部分常用操作,但它的抽象层在遇到合并单元格时有个致命问题:它把合并区域中除左上角以外的单元格,返回成重复的同一个 _Cell 对象,而不是空值或 None。这意味着你遍历表格时,如果不做去重判断,同一段文字会被读出来 N 次。

更隐蔽的是编号列表。python-docx 的 paragraph.style.name 只能告诉你“这是 List Paragraph”,但具体是第几级、编号是多少,它不直接给。你需要自己去 numbering.xml 里 根据 numId 和 ilvl 查对应的 abstractNum,再结合 lvlText 和 start 属性推算。很多教程只教到“读段落文字”,一到编号就含糊带过,结果就是解析出来的题目序号全丢了。

2.2 最小可跑通脚本:用 zipfile + lxml 直读 document.xml

与其被高层库的抽象限制,不如先写一个最底层的解析器,把 document.xml 当成纯 XML 来处理。这样你对文档结构的掌控力最强,后面再按需封装。

import zipfile
from lxml import etree

NS = {
    'w': 'http://schemas.openxmlformats.org/wordprocessingml/2006/main',
    'r': 'http://schemas.openxmlformats.org/officeDocument/2006/relationships',
}

def read_docx_xml(path):
    """读取 docx 中的 document.xml 并返回根节点"""
    with zipfile.ZipFile(path, 'r') as z:
        with z.open('word/document.xml') as f:
            tree = etree.parse(f)
    return tree.getroot()

def iter_block_items(root):
    """按文档顺序产出段落和表格,保持原始顺序"""
    body = root.find('w:body', NS)
    for child in body:
        tag = etree.QName(child).localname
        if tag == 'p':
            yield ('paragraph', child)
        elif tag == 'tbl':
            yield ('table', child)

def get_paragraph_text(p_elem):
    """提取段落纯文本,拼接所有 w:t 节点"""
    texts = p_elem.findall('.//w:t', NS)
    return ''.join(t.text or '' for t in texts)

# 使用示例
root = read_docx_xml('题目参考.docx')
for kind, elem in iter_block_items(root):
    if kind == 'paragraph':
        txt = get_paragraph_text(elem)
        if txt.strip():
            print(f'[P] {txt}')
    else:
        print('[TABLE] 发现表格,行数待统计')

这段代码的核心逻辑是:docx 的 body 下,段落和表格是平级交替出现的, iter_block_items 按原始顺序遍历,保证你还原出来的内容顺序和 Word 里看到的一致。 get_paragraph_text 用 XPath 的 .//w:t 把所有文本节点捞出来拼接,注意这里会忽略 w:tab 和 w:br ,如果你需要保留制表符和换行,得单独处理这两个标签。参数上, NS 字典是 OOXML 的命名空间映射,所有查找都必须带前缀,否则 lxml 找不到节点。这个脚本跑通后,你已经能拿到文档的纯文本骨架了,接下来才是硬骨头。

2.3 表格解析:处理合并单元格与嵌套表格

表格是“题目参考.docx”里最容易出问题的部分。一个 w:tbl 下面有若干 w:tr (行),每行有若干 w:tc (单元格)。合并单元格的信息藏在 w:tcPr 里的 w:gridSpan (水平合并列数)和 w:vMerge (垂直合并)中。 w:vMerge 的 val 属性如果是 restart ,表示这是合并区域的起始行;如果是 continue 或空,表示它延续上一行的合并。

def parse_table(tbl_elem):
    """解析表格为二维列表,处理 gridSpan 和 vMerge"""
    rows = []
    for tr in tbl_elem.findall('w:tr', NS):
        row = []
        for tc in tr.findall('w:tc', NS):
            # 水平合并列数
            tcPr = tc.find('w:tcPr', NS)
            grid_span = 1
            v_merge = None
            if tcPr is not None:
                gs = tcPr.find('w:gridSpan', NS)
                if gs is not None:
                    grid_span = int(gs.get('{http://schemas.openxmlformats.org/wordprocessingml/2006/main}val'))
                vm = tcPr.find('w:vMerge', NS)
                if vm is not None:
                    v_merge = vm.get('{http://schemas.openxmlformats.org/wordprocessingml/2006/main}val') or 'continue'
            # 提取单元格内所有段落文本
            cell_text = ' '.join(
                get_paragraph_text(p) for p in tc.findall('w:p', NS)
            ).strip()
            row.append({
                'text': cell_text,
                'grid_span': grid_span,
                'v_merge': v_merge,
            })
        rows.append(row)
    return rows

拿到这个结构后,你需要做二次归一化:对于 v_merge == 'continue' 的单元格,它的内容应该继承上一行同列的值;对于 grid_span > 1 的单元格,它占据多个列位,后续单元格要相应偏移。常见做法是维护一个列索引指针,遇到 gridSpan 就跳着走。这里有个血泪经验:不要试图在解析阶段就把合并单元格“填平”成规整矩阵,因为不同下游对合并的语义需求不同——检索场景可能希望合并单元格的内容在每个被合并位置都出现,而入库场景可能只保留一份。我一般会先输出带元信息的原始结构,再写一个独立的 normalize_table 函数按需转换。

3. 编号、样式与图片:还原文档语义的深水区

3.1 从 numbering.xml 反查题目序号

“题目参考.docx”里大量使用自动编号,比如“一、”“1.1”“(1)”这类。这些编号不在 document.xml 的文本里,而是由 numbering.xml 定义、段落通过 w:pPr/w:numPr 引用。要还原出“第 3 题”这样的序号,你需要三步:第一,从段落拿到 numId 和 ilvl;第二,在 numbering.xml 里找到 numId 对应的 abstractNumId;第三,在 abstractNum 里找到 ilvl 对应的 lvl 定义,读取 w:lvlText 和 w:start ,再结合同级前序段落计数。

def build_numbering_map(zip_path):
    """构建 numId -> abstractNumId -> lvl 定义的映射"""
    with zipfile.ZipFile(zip_path, 'r') as z:
        with z.open('word/numbering.xml') as f:
            root = etree.parse(f).getroot()
    num_map = {}
    for num in root.findall('w:num', NS):
        num_id = num.get('{http://schemas.openxmlformats.org/wordprocessingml/2006/main}numId')
        abstract_ref = num.find('w:abstractNumId', NS)
        if abstract_ref is not None:
            num_map[num_id] = abstract_ref.get('{http://schemas.openxmlformats.org/wordprocessingml/2006/main}val')
    abstract_map = {}
    for an in root.findall('w:abstractNum', NS):
        an_id = an.get('{http://schemas.openxmlformats.org/wordprocessingml/2006/main}abstractNumId')
        lvls = {}
        for lvl in an.findall('w:lvl', NS):
            ilvl = lvl.get('{http://schemas.openxmlformats.org/wordprocessingml/2006/main}ilvl')
            start = lvl.find('w:start', NS)
            lvl_text = lvl.find('w:lvlText', NS)
            lvls[ilvl] = {
                'start': int(start.get('{http://schemas.openxmlformats.org/wordprocessingml/2006/main}val')) if start is not None else 1,
                'text': lvl_text.get('{http://schemas.openxmlformats.org/wordprocessingml/2006/main}val') if lvl_text is not None else '',
            }
        abstract_map[an_id] = lvls
    return num_map, abstract_map

拿到映射后,遍历段落时维护一个计数器字典 {numId: {ilvl: count}} ,每遇到一个带 numPr 的段落就递增对应层级的计数,再按 lvlText 里的 %1 、 %2 占位符替换成实际数字。注意 lvlText 里的 %1 对应 ilvl=0, %2 对应 ilvl=1,以此类推。这个逻辑写对了,你就能把“题目参考.docx”里的自动编号完整还原成文本序号,下游做题目切分时就不会丢题号。

3.2 样式继承:为什么加粗和字号会“丢失”

Word 的样式是层叠的:文档默认样式 → 段落样式 → 字符样式 → 直接格式化。python-docx 的 run.bold 只反映直接格式化,如果加粗是通过样式定义的,它返回 None。要准确判断一个 run 是否加粗,你得沿着 rStyle 引用链往上查 styles.xml,再合并 docDefaults。常见做法是写一个 resolve_run_props 函数,按优先级依次检查:run 的直接属性、run 的 rStyle、段落样式、文档默认。每一层只覆盖上一层没定义的属性。

def resolve_bold(run_elem, styles_root):
    """解析 run 是否加粗,考虑样式继承"""
    # 直接属性
    rPr = run_elem.find('w:rPr', NS)
    if rPr is not None:
        b = rPr.find('w:b', NS)
        if b is not None:
            val = b.get('{http://schemas.openxmlformats.org/wordprocessingml/2006/main}val')
            return val not in ('0', 'false')
        rStyle = rPr.find('w:rStyle', NS)
        if rStyle is not None:
            style_id = rStyle.get('{http://schemas.openxmlformats.org/wordprocessingml/2006/main}val')
            # 在 styles.xml 中查找该样式的 w:b
            for style in styles_root.findall('w:style', NS):
                if style.get('{http://schemas.openxmlformats.org/wordprocessingml/2006/main}styleId') == style_id:
                    sb = style.find('.//w:b', NS)
                    if sb is not None:
                        return sb.get('{http://schemas.openxmlformats.org/wordprocessingml/2006/main}val') not in ('0', 'false')
    return False  # 默认不加粗

这段代码只处理了加粗,实际项目中你需要对斜体、字号、颜色做同样的继承解析。参数上, styles_root 来自 word/styles.xml ,你需要提前解析好传进来。这个函数跑一遍,就能解释为什么有些文字在 Word 里看着是粗体,但 python-docx 读出来 bold 是 None——因为加粗定义在样式里,不在 run 上。

3.3 图片与浮动对象:提取关系映射与定位

docx 里的图片不是直接嵌在 document.xml 里的,而是通过 r:embed 属性引用 document.xml.rels 中的关系 ID,再映射到 word/media/ 下的实际文件。要提取图片并知道它属于哪个段落,你需要解析 w:drawing 或 w:pict 节点,拿到 r:embed 值,再去 rels 文件里查 Target。

def extract_images(docx_path, output_dir):
    """提取所有图片并返回 rId -> 文件路径 的映射"""
    import os
    os.makedirs(output_dir, exist_ok=True)
    rel_map = {}
    with zipfile.ZipFile(docx_path, 'r') as z:
        # 解析关系文件
        with z.open('word/_rels/document.xml.rels') as f:
            rels = etree.parse(f).getroot()
        for rel in rels:
            rid = rel.get('Id')
            target = rel.get('Target')
            if 'media' in target:
                rel_map[rid] = target
        # 提取媒体文件
        for name in z.namelist():
            if name.startswith('word/media/'):
                data = z.read(name)
                out_path = os.path.join(output_dir, os.path.basename(name))
                with open(out_path, 'wb') as img_f:
                    img_f.write(data)
    return rel_map

拿到 rel_map 后,在遍历段落时检查 w:drawing 下的 a:blip 节点,读 r:embed 属性,就能把图片和段落关联起来。注意浮动图片( wp:anchor )和嵌入式图片( wp:inline )的 XPath 路径不同,需要分别处理。如果图片在文本框里,还得先定位 w:txbxContent 。这一步做完,“题目参考.docx”里的图片就能按原始顺序和段落对应上了。

4. 避坑与排查:docx 解析中 5 个真实翻车现场

4.1 现象:表格内容重复输出 N 次 → 原因:合并单元格未去重 → 解决:检查 vMerge 和 gridSpan

这是最常见的坑。python-docx 遍历 table.rows[i].cells 时,合并区域内的每个逻辑单元格都会返回同一个 _Cell 对象。如果你直接 cell.text 输出,同一段文字会出现多次。解决方法是先读 tcPr 里的 vMerge 和 gridSpan ,自己维护一个已访问集合,或者改用底层 XML 解析。我一般会在解析阶段就标记每个单元格的合并状态,输出时只保留 vMerge != 'continue' 的单元格内容,并在元数据里记录它跨越的行列数。

4.2 现象:编号全部丢失或错乱 → 原因:numbering.xml 未加载或计数器未按 numId 隔离 → 解决:按 numId + ilvl 双键计数

很多人只读了 document.xml 就以为拿到了全部信息,结果所有自动编号都变成空字符串。另一个隐蔽问题是:不同列表可能共用同一个 abstractNum 但 numId 不同,如果你只按 ilvl 计数,两个独立列表的序号会串在一起。正确做法是维护 {(numId, ilvl): count} 的字典,每遇到一个 numPr 就递增对应键,遇到 numId 变化时重置该 numId 下所有层级的计数。另外注意 w:numPr 可能出现在段落样式里而不是段落直接属性中,需要做样式继承解析。

4.3 现象:段落顺序和 Word 里看到的不一致 → 原因:文本框和浮动对象被忽略 → 解决:递归处理 txbxContent

Word 里有些内容放在文本框( w:txbxContent )或图形画布中,这些节点不在 body 的直接子节点序列里,简单的 iter_block_items 会跳过它们。如果你发现解析出来的段落数比预期少,或者某些题目凭空消失,大概率是文本框在作怪。解决方法是写一个递归遍历函数,遇到 w:txbxContent 就深入进去,把它内部的段落按位置插入到当前序列中。浮动对象( wp:anchor )还需要读 wp:positionH 和 wp:positionV 来判断它相对页面的位置,决定插入顺序。

4.4 现象:中文乱码或特殊符号变成问号 → 原因:XML 编码声明与实际字节不符 → 解决:用 lxml 的编码自动检测

docx 的 XML 文件头部通常有 <?xml version="1.0" encoding="UTF-8"?> ,但有些工具生成的文档会声明 GBK 却实际写 UTF-8,或者反过来。用 etree.parse 时 lxml 会按声明解析,导致乱码。稳妥做法是用 etree.XMLParser(encoding='utf-8') 强制指定,或者先读成 bytes 再用 etree.fromstring 让 lxml 自动嗅探。如果遇到 &#xNNNN; 形式的数字实体,lxml 会自动解码,不用额外处理。特殊符号如 & 、 < 、 > 在 XML 里是转义的,解析后自然还原。

4.5 现象:大文档解析内存暴涨 → 原因:一次性加载全部 XML 并构建完整树 → 解决:用 iterparse 流式处理

一份 500 页的“题目参考.docx”,document.xml 解压后可能超过 50MB,用 etree.parse 构建完整 DOM 树会吃掉几百 MB 内存。如果只是做文本提取,可以用 etree.iterparse 流式解析,每遇到一个 w:p 就处理并释放。但要注意,流式解析下你无法回头查父节点,所以编号映射和样式表需要提前加载到内存中。我一般会先解析 styles.xml 和 numbering.xml(这两个文件通常很小),再用 iterparse 流式扫 document.xml,内存占用能降一个数量级。

5. 批量处理与下游对接:把解析结果变成可检索的结构化数据

5.1 用多进程加速批量 docx 解析

当你手头有几百份“题目参考.docx”要处理时,单进程跑太慢。docx 解析是 CPU 密集型任务,适合用多进程。但要注意,lxml 的解析器不是进程安全的,每个进程要独立创建解析器。下面是一个基于 concurrent.futures 的批量处理框架:

from concurrent.futures import ProcessPoolExecutor, as_completed
import glob, json, os

def parse_one_docx(path):
    """解析单个 docx,返回结构化字典"""
    root = read_docx_xml(path)
    blocks = []
    for kind, elem in iter_block_items(root):
        if kind == 'paragraph':
            txt = get_paragraph_text(elem)
            if txt.strip():
                blocks.append({'type': 'p', 'text': txt})
        else:
            table_data = parse_table(elem)
            blocks.append({'type': 'table', 'data': table_data})
    return {'file': os.path.basename(path), 'blocks': blocks}

def batch_parse(pattern, workers=4):
    files = glob.glob(pattern)
    results = []
    with ProcessPoolExecutor(max_workers=workers) as executor:
        futures = {executor.submit(parse_one_docx, f): f for f in files}
        for future in as_completed(futures):
            try:
                results.append(future.result())
            except Exception as e:
                print(f'解析失败: {futures[future]}, 错误: {e}')
    return results

# 使用
all_data = batch_parse('data/*.docx', workers=8)
with open('parsed_output.json', 'w', encoding='utf-8') as f:
    json.dump(all_data, f, ensure_ascii=False, indent=2)

参数上, workers 建议设为 CPU 核心数,但不要超过文件数。每个子进程独立解析,互不干扰。异常处理很重要——某一份文档损坏不应该导致整批任务崩溃,所以用 try/except 包住 future.result() ,记录失败文件继续跑。输出 JSON 时用 ensure_ascii=False 保留中文原样。

5.2 输出结构化 JSON:段落、表格、编号的统一 schema

下游检索或入库需要一个稳定的数据格式。我一般会定义这样的 schema:每个文档是一个对象,包含 file 和 blocks 数组;每个 block 有 type 字段( paragraph 或 table ),段落有 text 、 style 、 numbering 字段,表格有 rows 二维数组和 merges 合并信息。编号字段记录 numId 、 ilvl 和计算出的 display 文本。这样下游无论是要做全文检索、题目切分还是表格抽取,都能从统一结构里取数。

def normalize_block(kind, elem, numbering_state):
    """将原始 XML 节点转为统一 schema"""
    if kind == 'paragraph':
        text = get_paragraph_text(elem)
        num_info = extract_numbering(elem, numbering_state)
        return {
            'type': 'paragraph',
            'text': text,
            'numbering': num_info,  # {'numId': ..., 'ilvl': ..., 'display': '1.1'}
        }
    else:
        rows = parse_table(elem)
        return {
            'type': 'table',
            'rows': [[cell['text'] for cell in row] for row in rows],
            'merges': [
                {'row': ri, 'col': ci, 'grid_span': cell['grid_span'], 'v_merge': cell['v_merge']}
                for ri, row in enumerate(rows) for ci, cell in enumerate(row)
                if cell['grid_span'] > 1 or cell['v_merge']
            ],
        }

这个 schema 的好处是合并信息单独放在 merges 数组里,不污染 rows 的规整性。下游如果不需要合并语义,直接读 rows 就行;如果需要,再根据 merges 做还原。编号的 display 字段是计算好的最终序号文本,检索时可以直接用。

5.3 对接检索:把题目文本和表格内容分别建索引

解析出来的结构化数据,最终要服务于检索。我的做法是分两条线:题目文本走全文索引,表格内容走结构化字段索引。题目文本需要先做切分——根据编号的层级,把“一、”作为一级标题,“1.1”作为二级,“(1)”作为三级,构建树形结构。每个叶子节点是一道题,包含题干和选项。表格内容则按行列建倒排,支持“在表格中查找包含某关键词的行”这类查询。如果下游是 Elasticsearch,题目文本用 text 类型字段,表格用 nested 类型保留行列关系。如果下游是 SQLite,题目建一张表,表格内容建另一张表用 JSON 字段存。

提示:编号切分时要注意跨页和跨节的情况,有些文档会在节边界重置编号,你需要读 w:sectPr 里的 w:pgNumType 判断是否重新计数。

5.4 验证解析质量:三个可量化的检查指标

解析完不能凭感觉说“差不多对了”,得有量化验证。我一般看三个指标:第一,段落总数与 Word 状态栏的统计是否接近(允许因文本框和页眉页脚有少量差异);第二,编号序列是否连续无跳号,把所有 display 字段抽出来检查递增规律;第三,表格行列数与 Word 里目测的是否一致,特别是合并单元格的跨度。写一个 validate_output 函数自动跑这三项,任何一项偏差超过阈值就人工复查。这套流程跑顺了,几百份文档的解析质量就能稳定在 95% 以上。

6. 一个容易被忽略的技巧:用样式名反推文档结构

解析“题目参考.docx”时,最值钱的信息其实藏在样式名里。很多出题模板会给不同层级的内容定义专门的样式,比如“题干”“选项”“解析”“答案”。你不需要去猜编号规则,直接读段落的 pStyle 值,就能把内容按语义分类。我一般会先跑一遍全文档的样式统计,看看哪些样式出现频率高、对应什么内容,然后建一个样式名到语义角色的映射表。

def collect_style_stats(root):
    """统计文档中所有段落样式的使用频率"""
    from collections import Counter
    counter = Counter()
    for p in root.findall('.//w:p', NS):
        pPr = p.find('w:pPr', NS)
        if pPr is not None:
            pStyle = pPr.find('w:pStyle', NS)
            if pStyle is not None:
                style_id = pStyle.get('{http://schemas.openxmlformats.org/wordprocessingml/2006/main}val')
                counter[style_id] += 1
    return counter

# 输出示例:{'Heading1': 12, 'QuestionStem': 47, 'OptionA': 188, ...}

拿到统计后,你就能针对性地写提取规则: QuestionStem 样式的段落是题干, OptionA 到 OptionD 是选项, Answer 是答案。这比单纯靠编号切分准确得多,因为编号可能因为手工调整而错乱,但样式名通常是模板作者精心定义的。这个技巧在批量处理同一套模板生成的文档时特别管用,一次映射,几百份文档通用。

我自己的习惯是:拿到任何一批 docx,先花十分钟跑样式统计和编号统计,把文档的“骨架”摸清楚,再动手写解析逻辑。这个前置动作看起来慢,但能避免后面反复调试编号和合并单元格的玄学问题。希望帮到你。

到此这篇关于Python批量解析并提取Word文档的工程化实践的文章就介绍到这了,更多相关Python解析Word内容请搜索脚本之家以前的文章或继续浏览下面的相关文章希望大家以后多多支持脚本之家!

您可能感兴趣的文章:
阅读全文