资讯详情 python-docx实战:Word文档解析、样式提取与表格处理全攻略
📅 2026/10/27 20:09:12
简介面向办公自动化、数据处理与文本分析方向开发者的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-docx1.1.0 pip install openpyxl3.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[:level1] 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_dirimages): import os os.makedirs(output_dir, exist_okTrue) 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 访问页眉页脚需要经过 sectiondef 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, langeastAsia)后续不容易再踩第二次。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在一个 runme}}在下一个 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}}就发出去的翻车事故。试过的读者应该能感受到这个套路在你处理几十份标准报告、合同套打时有很直观的收益希望帮到你。本文还有配套的精品资源点击获取