简介:面向办公自动化、数据处理与文本分析方向开发者的Word文档解析资料,围绕实际业务中常见的Word文档批量解析需求展开,系统说明DOCX基于ZIP的存储结构、document.xml等内部文件的作用,并给出基于python-docx、lxml的解析思路与可直接套用的代码片段,同时梳理了不同Word版本兼容、图片与样式丢失等常见异常,帮助读者少走弯路。压缩包共29个文件,整体仅1.79MB,既包含Java/Android工程源码(.java、.xml、.prefs、.project等),也配有docx文档、PNG示意图与若干配置文件,目录结构直观,便于按需查阅和迁移复用。同时覆盖.doc与.docx差异、段落与表格提取等细节。总体来看,这套资料既有原理讲解又有可运行的示例工程,适合需要为系统添加Word导入、预览或文本提取功能的初中级开发者参考。目前已有301人学习,亦可作为快速上手的入门素材。
1. 解析 Word 文档:从拿到文件到能直接复用的代码包
做数据处理的人都有过这种经历:手里堆着一批 Word 文档,可能是合同、简历、验收报告或者会议纪要,需要把里面的文字、表格、图片抽出来进数据库,手动复制粘贴一上午能吐。这个资源包就是把这件事做成一套可直接复用的代码,围绕 python-docx 展开,把 Word 文档的解析流程拆成步骤化的代码,从读取正文、识别标题级联、抽取表格,到图片导出和模板批量生成,全部封装成函数,拿去改改路径就能跑。适合三类人:需要把 Word 内容导入系统的后端开发,做文档结构化清洗的数据分析师,以及写毕业论文或报告时被格式折腾到崩溃的学生。这套代码解决的痛点不是“能不能读”,而是“怎么读得干净”——我拆包的时候最深的感受是,作者把 docx 文档的 XML 结构当成解析主线来写,而不是靠正则硬抠,这条思路在遇到样式复杂的文档时差距会非常明显。
注意:本资源基于 python-docx 库,目标是 docx 格式,不覆盖老式 .doc 二进制格式。
2. python-docx 读写基础:先搞清文档对象模型再动手
2.1 为什么选 python-docx 而不是纯 XML 解析
解析 Word 文档有几条路:直接解压 docx 文件读 XML、用 pywin32 调 COM 接口、或者用 python-docx。我拆这个资源包时留意到作者的选型逻辑:docx 文件的本质是一个 ZIP 压缩包,里面的 word/document.xml 存放正文内容,直接解析 XML 虽然灵活,但你要是遇到分节符、题注、域代码这些复杂结构,自己写解析逻辑很容易翻车。python-docx 在库内部把这些 XML 结构封装成了 Document、Paragraph、Run、Table 这些对象模型,日常处理完全够用。
pywin32 方案只适合 Windows 且装了 Office 的环境,换成 Linux 服务器就只能干瞪眼。python-docx 是跨平台的,pip 装上就能跑,解析和生成都覆盖。另外一个选它的重要原因是对内容结构的表达方式:Word 文档不是一行行纯文本,而是有层级、有样式、有属性的复合结构,对象模型比字符串处理更贴近 Word 的设计逻辑。这个资源包把代码按“读取文档元素 - 遍历段落 - 判断样式 - 提取内容”的顺序组织,整体是照着文档对象模型来走的,不是照着屏幕显示来抠的。
2.2 安装与第一个可运行的读取脚本
先把依赖装上。资源包里的 requirements.txt 只锁定三个库,核心是 python-docx,我用的是 1.1.0 版本。
pip install python-docx==1.1.0 pip install openpyxl==3.1.2openpyxl 在包里用来做表格数据的二次导出,等会儿会用到。写第一个脚本,跑通读取流程:
from docx import Document doc = Document("demo.docx") # 段落遍历:每个段落包含样式名和文本内容 for i, para in enumerate(doc.paragraphs): style_name = para.style.name text = para.text.strip() if text: print(i, style_name, text) # 表格遍历:直接打印单元格内容 for t_idx, table in enumerate(doc.tables): for r_idx, row in enumerate(table.rows): cells = [cell.text.strip() for cell in row.cells] print(t_idx, r_idx, "|".join(cells))这段代码是解析的骨架。第 3 行加载文档对象;第 6 行取样式名,这个是后面做标题级联识别的关键;第 10 行 strip 掉空文本,避免空白段落干扰;第 13 行开始遍历表格,用"|".join(cells)把单元格拼成一行输出,方便肉眼核对。实测里你会发现,para.text拿到的是一段内所有 run 的合并文本,如果同一个段落里不同部分的字体或颜色不同,想拿到分段格式信息就必须往 run 层级下探,后面会讲。
注意:Unit 资源包内自带虚拟测试文档,如果读者用自己的文档测试,请先确认目标 Word 文件没有加密或损坏,否则加载阶段就会报错。
2.3 Document、Paragraph、Run、Table 的关系
四个核心对象的关系网决定了代码怎么写。Document 是入口,代表整个 Word 文件;它下面挂着两个平行序列:doc.paragraphs只管段落,doc.tables只管表格。这里要注意,Word 文档中表格内部的单元格里也能放段落,但那不进入doc.paragraphs,必须通过table.cell(r,c).paragraphs去拿。同理,一个段落里有多个 Run,每个 Run 才是一段拥有统一格式属性的文本。
资源包里的解析函数是这样封装的,抄作业可以直接用:
def extract_all_blocks(docx_path): doc = Document(docx_path) blocks = [] # 按文档顺序遍历 body 的子元素 from docx.document import Document as _Document from docx.table import Table from docx.text.paragraph import Paragraph from docx.oxml.ns import qn body = doc.element.body for child in body.iterchildren(): if child.tag == qn('w:p'): para = Paragraph(child, doc) if not para.text.strip(): continue blocks.append({ "type": "paragraph", "style": para.style.name, "text": para.text.strip() }) elif child.tag == qn('w:tbl'): tbl = Table(child, doc) rows = [] for row in tbl.rows: rows.append([cell.text.strip() for cell in row.cells]) blocks.append({ "type": "table", "rows": rows }) return blocks这里的关键不是调 API,而是用body.iterchildren()按 XML 中真实的出现顺序遍历,这样段落在表格前面、表格后面又跟段落的情况就能被正确还原顺序。qn('w:p')和qn('w:tbl')是拿命名空间限定名,判断子元素的类型。这段代码解決的正是doc.paragraphs与doc.tables分家、顺序错乱的坑。如果只是简单拼接两个列表,表格前后夹文字的文档就会被解析得乱七八糟。
3. 结构与样式解析:从正文里抽出标题层级、列表和样式属性
3.1 借助样式名识别标题层级
Word 的“标题 1”“标题 2”不只是显示加粗,它们是映射到 outline level 的段落样式,解析时可以利用这个特征来还原文档大纲。资源包里有一个递归函数,把标题层级转成字典嵌套结构,我拆完觉得这个函数的设计值得抄。
def build_outline(para_blocks): """ 输入:extract_all_blocks 返回的 blocks 输出:嵌套的标题结构 dict """ root = {"title": "root", "children": []} stack = [root] for block in para_blocks: if block["type"] != "paragraph": continue style = block["style"] text = block["text"] if not style.startswith("Heading"): continue level = int(style.split()[-1]) # "Heading 1" -> 1, "Heading 2" -> 2 node = {"title": text, "children": []} # 弹栈:保持当前层级正确 while len(stack) > level: stack.pop() # 这里 level 从1开始,stack[0] 是 root while len(stack) <= level: stack.append(None) # 占位,下面再修正 stack[level] = node stack[level-1]["children"].append(node) # 重置本级以后的占位 stack = stack[:level+1] return root["children"]文字步骤这样理解:stack 是一个按层级索引的列表,stack[0]永远是 root。每遇到一个标题,先按标题级别把过深的栈弹掉,然后把这个标题挂到上一级节点的 children 里。占位符那段代码逻辑上稍绕,但实测能兼容“从标题 3 直接跳到标题 5”的文档——正常文档标题应该是逐级递减的,但手工排版的文档各种跳级,这个写法能扛住。
参数说明:style.split()[-1]依赖样式名的英文命名,如果你的文档跑在中文版 Word 且样式名显示为“标题 1”,需要改成text = style.replace("标题 ", "")或直接用style.startswith("标题")。这个细节资源包里也做了双分支判断,不要只抄一段就跑。
3.2 列表结构的识别:Numbering 与 List 样式
列表是 Word 解析的另一大坑。你可能想当然地认为“List Paragraph”样式的段落就是列表项,但真正决定编号行为的 Numbering 属性藏在 XML 里。资源包里的列表抽取代码长这样:
from docx.oxml.ns import qn def get_numbering_info(paragraph): """ 返回当前段落是否带自动编号,以及编号层级 """ pPr = paragraph._p.pPr if pPr is None: return None numPr = pPr.find(qn('w:numPr')) if numPr is None: return None ilvl = numPr.find(qn('w:ilvl')) numId = numPr.find(qn('w:numId')) if numId is None: return None level = ilvl.get(qn('w:val')) if ilvl is not None else "0" num_id = numId.get(qn('w:val')) return {"level": int(level), "num_id": int(num_id)}这函数不关心编号长什么样,只关心有没有 numPr 节点以及它的级别值。因为 Word 的列表编号实际渲染受 numbering.xml 里的 abstractNum 定义影响,文本层和渲染层是分离的。对解析框架来说,拿到num_id就能判断是不是同一个列表,拿到ilvl就能判断缩进层级。作者在注释里写了这个规律:List Paragraph 样式和 Numbering 属性经常是配套的,但也有无编号的 List Paragraph,纯靠样式名会把普通缩进段落误判成列表项,这种情况就用 numbPr 来兜底。
3.3 样式属性与字体信息的细粒度提取
有的场景需要把字体、字号、颜色抽出来,比如做公文自动化处理时校验格式合规。资源包给的抽取方案是下钻到 run 级别:
def extract_run_info(paragraph): runs_info = [] for run in paragraph.runs: info = { "text": run.text, "bold": run.bold, "italic": run.italic, "size": run.font.size.pt if run.font.size else None, "name": run.font.name, "color_rgb": str(run.font.color.rgb) if run.font.color and run.font.color.rgb else None } runs_info.append(info) return runs_info注意run.bold可能是 None、True 或 False 三态,None 表示继承样式,不一定是没加粗。run.font.name只返回 ascii 字体名,中文字体藏在rPr/rFonts节点的w:eastAsia属性里,用run.font.element.rPr.rFonts.get(qn('w:eastAsia'))才能拿到。这是典型的“看着 API 说明书能用,一跑真文档就露馅”的点。颜色值方面,run.font.color.rgb只有显示为自动色时返回 None,需要区分“无颜色设置”和“自动色”,如果自动化校验要严格对比,得注意这个边界。
4. 表格与图片抽取:从跨行跨列单元到图片二进制存储
4.1 表格结构的完整映射
表格解析的难点不在读单元格文本,而在还原合并单元格和表头结构。资源包里表格处理的策略是先把w:tblGrid的列宽信息读出来,再按行遍历。
def parse_table(table): # 读取原始列定义 tbl = table._tbl grid = tbl.find(qn('w:tblGrid')) cols = grid.findall(qn('w:gridCol')) col_widths = [c.get(qn('w:w')) for c in cols] data = [] for row in table.rows: cells = row.cells row_data = [] # 计算当前行的单元格跨度 for cell in cells: tc = cell._tc tcPr = tc.tcPr grid_span = 1 h_merge = False if tcPr is not None: gs = tcPr.find(qn('w:gridSpan')) if gs is not None: grid_span = int(gs.get(qn('w:val'))) vmerge = tcPr.find(qn('w:vMerge')) if vmerge is not None: h_merge = True row_data.append({ "text": cell.text.strip(), "grid_span": grid_span, "v_merge": h_merge, "width": col_widths[0] if grid_span > 1 else None }) data.append(row_data) return datagridSpan 表示横向合并——一个单元格横跨几列;vMerge 表示纵向合并——同一个单元格的值会出现在后续多行的 row.cells 里。这里有个实测会踩的坑:纵向合并后 row.cells 会对同一单元格重复返回,如果不判断 vMerge 就会把同一份数据重复写进每一行,导出结果多出一堆重复行。这个判断就是资源包里"v_merge": h_merge这一笔的价值。你拿到单元格列表后要做一次去重:遇到 v_merge 标记为 True 且文本已在上一行出现的,跳过或标注“合并延续”,取决于你的业务。
4.2 图片提取:从内嵌关系到二进制流
Word 文档中的图片不是直接嵌在 document.xml 里的,而是以二进制文件存在 word/media 目录下,正文用w:drawing和a:blip引用它们。python-docx 没有直接的 get_images 方法,但我们可以经document.part.related_parts拿到所有关联文件。
def extract_images(doc, output_dir="images"): import os os.makedirs(output_dir, exist_ok=True) image_map = {} for rel_id, rel in doc.part.rels.items(): if "image" in rel.reltype: blob = rel.target_part.blob fname = os.path.basename(rel.target_part.partname) with open(os.path.join(output_dir, fname), "wb") as f: f.write(blob) # 记录 rel_id 对应的图片,方便正文里定位 image_map[rel_id] = fname return image_map这段逻辑的关键在doc.part.rels——它包含了文档部件指向的所有关系,image 类型的 reltype 形如<http://schemas.openxmlformats.org/officeDocument/2006/relationships/image>。用rel.target_part.blob拿二进制数据,文件名沿用包内部的名字(如 image1.png)。如果你的正文需要知道“第 3 段插了哪张图”,还得再回 document.xml 里找r:embed属性里的 rel_id,和 image_map 对接。实测经验:Word 里同一张图片多次插入,media 目录只存一份,正文多个 rel_id 指向同一个 target_part,所以用 image_map 存映射时要把多个 rel_id 指向同一文件名的场景处理好。
4.3 页眉页脚与文本框中的内容补充
正文之外还有一类内容容易被漏掉——页眉页脚里的版权信息或公司名。python-docx 访问页眉页脚需要经过 section:
def extract_headers_footers(doc): results = [] for section in doc.sections: header = section.header footer = section.footer h_text = "".join(p.text for p in header.paragraphs) f_text = "".join(p.text for p in footer.paragraphs) results.append({ "header": h_text.strip(), "footer": f_text.strip(), "header_tables": len(header.tables), "footer_tables": len(footer.tables) }) return results注意section.header有个陷阱:如果你的文档没有设置页眉,默认链接到上一节,读出来的可能是空或者继承自上一节。python-docx 的 header 对象有 is_linked_to_previous 属性,需要判断之后决定是否跳到上一节去读。文本框里的内容跑到了w:txbxContent节点,python-docx 默认不解析它,需要自行通过 XPath 抽,这个适用于少数在文本框中放关键信息的模板文档。
5. 解密与踩坑排查:解析 Word 常见问题与避坑清单
写这个章节是因为我在拆读资源包时,发现作者代码注释里记了一堆实际跑过的异常场景。这些坑如果不标记出来,新手抄代码跑一遍大概率会在某一类文档上当场翻车。
5.1 现象:文档加载直接抛异常
- 现象:
Document("xxx.docx")报PackageNotFoundError或KeyError。 - 原因:文件不是真正的 docx 格式。很多从网上下的所谓 Word 文档,实际是 RTF 文件改了后缀,或者 Office 另存时选择的格式不匹配。docx 的核心是一个 ZIP 包,python-docx 无法解包 RTF 文件。
- 解决:先确认文件头,用十六进制查看工具或一行命令检查:
file xxx.docx在 Linux 上会显示 "Microsoft Word 2007+",如果看到 "Rich Text Format" 那就是改了后缀的 RTF,需要先用 Word 另存为 docx,或者用 win32com 转换一次。资源包也附带了一个批量格式转换脚本,专门处理这种改后缀的脏文件。
5.2 现象:段落文本读丢内容
- 现象:
para.text输出比 Word 里显示的内容少一段,特别是段尾的文字或批注内容消失了。 - 原因:Word 文档中的文本不只存在于
w:p的子节点里,还有可能在w:ins(修订插入)、w:comment(批注)或者超链接的内部文本中。python-docx 的 Paragraph.text 只取了顶层w:r子节点的文字,如果文字包在超链接<w:hyperlink>里面,text 就丢这一段。 - 解决:遍历段落的
p.itertext()来兜底:full_text = "".join(p.itertext())。拿到手以后再做一次去空格处理,因为 itertext 还会把w:t以外的文本节点一并取出。这个修法会带上批注文字,所以不能盲目全局替换,超链接场景用可以,批注场景要根据业务决定要不要过滤。
5.3 现象:表格行列数和看到的对不上
- 现象:
len(table.rows)和肉眼看到的一致,但是某些行只有一个单元格,而且单元格内容等于多列内容的叠加。 - 原因:这个单元格设置了 gridSpan,横跨了两列。python-docx 里 row.cells 会按列数补齐:横向合并后,后面被吞掉的列位置会重复返回同一个 cell 对象,所以会出现“一行有两个单元格,内容完全相同”的现象。
- 解决:回到第 4.1 节,用
tcPr里的gridSpan值补列数,或者解析前把所有跨列单元格拆成虚拟单元。把cell._tc的 gridSpan 值读出来,按占比拆分,能让数据导入 pandas 时保持 DataFrame 形状正确。真处理过合并单元格的人应该有感觉:不拆的话数据行会出现对齐错乱,没法做聚合分析。
5.4 现象:中文字体名获取为空
- 现象:
run.font.name返回的字体名是空字符串,明明 Word 里给正文设的是宋体。 - 原因:Word 的字体分 ascii 字体和 eastAsia 字体,python-docx 的
font.name只映射到 w:rFonts 的 ascii/w:hAnsi 属性。如果文档把中文字体设置写在w:eastAsia属性里,API 读不到。 - 解决:手工去 XML 里取属性:
rPr = run._element.rPr if rPr is not None: rFonts = rPr.find(qn('w:rFonts')) if rFonts is not None: east_asia = rFonts.get(qn('w:eastAsia'))这个提取逻辑我在跑公文格式校验时重复写过很多次,建议直接封成一个函数get_font_name(run, lang="eastAsia"),后续不容易再踩第二次。
5.5 现象:解析大文档时内存暴涨
- 现象:一个上百页的 docx,解析到一半内存占用量上升到几个 GB,程序被系统杀掉。
- 原因:docx 中某些图片是超大的 EMF 或 TIFF 格式,
target_part.blob一次性把整个二进制文件读进内存。或者文档用嵌入式 Excel 对象,OLE 容器解出来有几十 MB。 - 解决:不要对所有关系图片做一次性 blob 读取,改成按需流式写盘:拿到
target_part.blob之前先看target_part.content_type和 partname 后缀,体积预估超过阈值就换blob的替代路径——直接结合 zipfile 模块从源文件中流式解压。另外提醒一句话:解析完一个文档后显式del doc并调gc.collect(),对批量跑几百个文档的场景很有帮助。
6. 模板化批量生成:把 Word 当数据渲染引擎用
最后一章给一个进阶用法:不只解析,还利用这套对象模型做批量文档生成。在拆包时我发现作者把解析能力反着用了一遍——既然 Word 的结构是段落 + run + 表格,那么生成报告时也按这个结构去拼装,比字符串拼接后写入文档要稳得多。
一个典型的自动化场景:从数据库读取一批员工信息,生成统一的入职通知书。常见做法是准备一个模板 docx,在模板里预留占位符,比如{{name}}、{{date}}、{{department}},然后用解析到的样式定位到这些段落,再把占位符替换成真实数据。
from docx import Document def render_template(template_path, data_list): results = [] for data in data_list: doc = Document(template_path) for para in doc.paragraphs: for run in para.runs: if "{{name}}" in run.text: run.text = run.text.replace("{{name}}", data["name"]) if "{{department}}" in run.text: run.text = run.text.replace("{{department}}", data["department"]) if "{{date}}" in run.text: run.text = run.text.replace("{{date}}", data["date"]) results.append(doc) return results这里有个细节:替换文字一定要在 run 级做,不能在 paragraph 级做。因为 Word 模板里占位符可能跨越多个 run 拆分——比如{{na在一个 run,me}}在下一个 run,段落级替换必然失效。这也解释了为什么我一直在强调 run 才是实际文本载体。批量生成后doc.save("output.docx")前,可以跑一遍上一章的抽取函数把生成结果反向解析出来,对比占位符是否清零,这一步我强烈建议不要省略。
实测验证方法在我的工作流里是固定动作:生成 10 份文档后,把每份的内容重新解析为纯文本,与期望结果做一遍比对。如果{{出现残留,说明有 run 级切分没合并。解决残留的方法是把段落内所有 run 拼接成整体再统一分配回去:
def merge_and_replace(paragraph, old, new): if old in paragraph.text: # 先把所有 run 文本拼到一起,取掉原有 run full = paragraph.text full = full.replace(old, new) for run in paragraph.runs: run.text = "" if paragraph.runs: paragraph.runs[0].text = full这个处理方式能避免每次都要遍历全部 run,更少出错。从那以后我每次做批量模板生成都强制走一遍“生成 - 反向解析 - 比对占位符残留”的流程,再也没出现过通知书写着{{name}}就发出去的翻车事故。试过的读者应该能感受到,这个套路在你处理几十份标准报告、合同套打时有很直观的收益,希望帮到你。
本文还有配套的精品资源,点击获取