在整理Word文档的过程中,我遇到过一件非常磨人的事:几十页的合同里,对方用黄色高亮标出了十几处需要修改的条款,我得一个个滚屏把高亮文字全部找出来,逐条复制到邮件里回复。第一次干这活花了快一个小时,第二次实在忍不了,花了十分钟写了个python-docx脚本,几秒钟就把所有高亮内容全部提取出来了。这篇内容就是把当时的完整思路、代码实现和踩过的坑重新梳理一遍。适合谁看?经常处理Word文档、需要批量提取标注内容、或者想给文档处理流程提效的人,无论你基础深浅,照着操作基本都能跑通。
1. 提取高亮这件事,为什么值得用脚本解决
很多人听到“提取Word高亮文字”第一反应是:直接打开文档手动复制不就行了吗?这个思路在小文档上没毛病,但在真实工作场景里,高亮通常意味着“重点”“待办”“需修改”,往往散落在长文档的各个角落。人工提取最大的问题不是不会做,而是不稳定——漏行、看花眼、重复复制,甚至高亮颜色不同代表不同语义时,还得一边滚屏一边做记号。一次两次可以忍,如果每周都要处理这类文档,就是在用时间成本为重复劳动买单。
1.1 手动方案的上限在哪里
我统计过自己手动提取的失误率:一个30页的文档,约40处高亮,漏掉3到5处是非常正常的事。尤其是高亮恰好出现在表格里、页眉页脚、或者某个文本框中的时候,肉眼扫过很容易当成普通修饰忽略掉。而高亮文字一旦漏掉,后续传达信息就会出错,轻则返工,重则造成误解。
手动方案的上限还体现在“提取后的二次整理”上。如果只是看一遍还好,但如果要把高亮内容整理成表格、按颜色分类、或者录入到别的系统里,手动复制回来还需要再做一轮格式清洗和归类,这部分时间几乎是提取本身的2倍以上。脚本方案一次做好,后续每次都是一个命令的事情。
1.2 四个备选方案的优劣势对比
我试过几种不同的技术路线,各有各的适用场景,简单对比如下。
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| win32com / pywin32 | 直接调用Word程序,保真度高,能处理所有Word能识别的对象 | 只能跑在Windows+Office环境;启动Word比较慢;多次操作后Word进程可能卡顿残留;对服务端环境不友好 | 本地单机、需要连带处理修订或批注等复杂对象的场景 |
| 直接解压docx读XML | 不依赖第三方库,原理透明 | 自己解析XML容易踩嵌套结构的坑,正文和表格、文本框的标签结构不一致时写起来很痛苦,本质上是重复造轮子 | 只是临时看一下XML结构的好奇场景 |
| pandoc等转换工具 | 转格式很快 | 无法精确提取高亮标记,转换后高亮样式基本会丢失 | 不适合本需求 |
| python-docx | 跨平台,纯Python实现,不依赖Word程序;API清晰;在服务器或离线环境也能跑 | 只能处理.docx格式,旧版.doc需要先转换 | 批量处理、自动化管道、团队共享脚本 |
对比完就明白,python-docx在“批量、自动化、跨平台”这几个维度上优势非常突出。它是解析文档结构而不是模拟人操作Word,所以不会出现“Word打开弹出对话框卡住”这类意外问题,也很适合放在定时任务或后台服务里跑。
1.3 为什么最终落在python-docx上
选择python-docx的核心原因,是它把Word的底层XML结构封装成了比较直观的对象模型:段落是Paragraph,段内的子片段是Run,高亮就是Run的一个字体属性。对大多数提取需求来说,这意味着代码量可以压缩到很短,而且语义清晰。另一个重要原因是纯Python实现意味着不需要在目标机器上安装Office,部署成本低很多。后面要处理的文件多了,这个优势会越来越明显。
2. 高亮在docx文件里到底是怎么存的
直接用python-docx之前,建议先搞清楚一件事:Word里用鼠标划一下给文字加上“黄色荧光笔”效果,保存到文件里时到底发生了什么。只有理解了底层存储方式,写代码时才能绕开那些莫名其妙的坑。
2.1 docx的zip结构和XML基本盘
一个看似普通的.docx文件,实际上是一个zip压缩包。把后缀改成.zip再解压,能看到word/document.xml、word/styles.xml、word/media/等一批文件。正文内容基本都在word/document.xml里,而文档中每个段落的排版信息、字符级格式信息,都嵌套在XML节点中。
一个段落的XML结构大致长这样:
<w:p> <w:r> <w:rPr> <w:highlight w:val="yellow"/> <w:color w:val="FF0000"/> </w:rPr> <w:t>这段文字被标黄了</w:t> </w:r> </w:p>可以理解为:w:p是段落,w:r是段落里的一个片段(run),w:rPr是片段格式,w:t是实际的文字内容。高亮样式就挂在w:rPr下面的w:highlight标签上,w:val属性就是颜色名,比如yellow、green、pink。
2.2 w:highlight标签和python-docx的映射关系
python-docx把XML结构做了面向对象映射。在代码里,一个run就是Run对象,它有一个font属性,font对象上又有highlight_color这个属性,类型是WD_COLOR_INDEX枚举或者None。判断某个run有没有高亮,最简单的写法就是看run.font.highlight_color是不是None。
比较有意思的一点是,如果只是想“提取所有有高亮的文字”,根本不用关心具体是什么颜色,只要判断“有没有”就行。但如果不同颜色代表不同含义,那就需要进一步判断颜色值。枚举类型和XML里的颜色名是对应的,比如WD_COLOR_INDEX.YELLOW对应XML里的yellow。
2.3 高亮和底纹的区别
用Word时还有一个特别容易混淆的概念:文本高亮和底纹(shading)。文本高亮就是“荧光笔”效果,用w:highlight表示;底纹是用段落或单元格的w:shd标签表示的,视觉上很接近,但完全是两种机制。python-docx对w:shd没有现成的高层API,只能通过底层XML去读。
如果你要提取的文档里,别人用的是底纹而不是荧光笔高亮,简单用highlight_color是提取不出来的。这也是很多人在试用脚本后发现“为什么我这里跑出来是空的”一个重要原因。做需求的时候先确认一下对方的操作习惯,或者两者都做兼容,会更稳妥。
3. 最核心场景:遍历段落runs提取高亮文字
理解了底层的结构,核心代码其实不复杂。它的逻辑就三步:打开文档、遍历所有段落、遍历每个段落里的所有run,只要run有高亮颜色就把文字收起来。
3.1 基础提取代码与逐行解释
直接给一个能跑的版本。
from docx import Document from docx.enum.text import WD_COLOR_INDEX def extract_highlight_from_paragraphs(docx_path): doc = Document(docx_path) results = [] for para in doc.paragraphs: highlight_runs = [] for run in para.runs: highlight = run.font.highlight_color if highlight is not None and run.text.strip(): highlight_runs.append({ 'text': run.text, 'color': str(highlight) }) if highlight_runs: # 把同一段落内多个连续的highlight run合并成一段文本 merged_text = ''.join(item['text'] for item in highlight_runs) results.append({ 'paragraph': para.text, 'highlight_text': merged_text, 'runs': highlight_runs }) return results注释里提到的“合并”很关键。Word在保存高亮时,不一定把一整句高亮放在同一个run里,中间可能因为格式切换、拼写检查、中英文切换而被拆分成多个run。如果逐run打印,你会看到高亮内容支离破碎,明明一句话被拆成三四个片段输出。所以我在收集完段内所有高亮run之后,把它们按位置顺序拼接回完整文字,这样提取结果才像人话。
运行后的效果大概是:
第2段:需要修改的地方:请将合同期限修订为三年。 第5段:新增条款:乙方应于每月5日前提交上月对账单。每个段落一条记录,同时保留了高亮后的内容和所在段落原文,后面要定位就方便多了。
3.2 按颜色分类收集,保留颜色信息
如果文档里的高亮颜色是有语义的,比如黄色表示待确认、绿色表示已通过、粉色表示重点提示,那提取时就不能只判断“有没有”,要把颜色也提取出来。
from docx.enum.text import WD_COLOR_INDEX def extract_highlight_with_color(docx_path): doc = Document(docx_path) items = [] for para in doc.paragraphs: for run in para.runs: if run.font.highlight_color is not None and run.text.strip(): items.append({ 'text': run.text, 'color': run.font.highlight_color, 'color_name': run.font.highlight_color.name }) return itemshighlight_color.name返回的是枚举名称,比如YELLOW、GREEN、PINK。如果是按颜色做统计,直接对这个字段做分组就行。这样整理出来的材料,不管是用表格还是透视图,都方便得很。
3.3 大文档和.doc老格式的处理
有人问:文档很大,几十MB,这样遍历会不会很慢?实测下来,python-docx是整体加载然后解析为对象模型,对几十MB的docx是能扛住的,正常几秒钟内能遍历完。如果文档大得离谱,可以考虑只读取不渲染,反正我们只要text字段,性能上限比首屏渲染高很多。
还有一个高频率问题:Document()打开.doc文件直接报错。python-docx不支持旧的.doc二进制格式,只支持.docx。这种场景下,要么让文档源另存为docx,要么用LibreOffice的命令行批量转换一下,转换后再交给脚本处理。我自己一般在自动化流水线里加一步预转换,把.doc先转成.docx再进提取逻辑。
4. 表格、文本框、页眉页脚:最容易漏的高亮区域
第一个版本的代码跑完,你以为万事大吉了,结果一核对发现文档里有些高亮没被提取出来。这种情况十有八九是那些高亮不在正文段落里,而是藏在表格单元格、文本框、页眉页脚这些“段落之外”的区域。
4.1 表格的遍历方式与合并单元格去重
Word表格里的文字,在python-docx里需要按“表格→行→单元格→段落→run”的路径去找。
def extract_highlight_from_table(docx_path): doc = Document(docx_path) items = [] seen_cells = set() for table in doc.tables: for row in table.rows: for cell in row.cells: # 合并单元格会被重复引用,跳过已处理的cell对象 if id(cell) in seen_cells: continue seen_cells.add(id(cell)) for para in cell.paragraphs: for run in para.runs: if run.font.highlight_color is not None and run.text.strip(): items.append(run.text) return items这里有个小坑:Word的合并单元格机制会导致同一个_Cell对象在row.cells里出现多次。如果不去重,提取结果里同一个单元格的高亮会重复出现好几遍,看起来就像内容无故翻倍了。我的处理方式是维护一个seen_cells集合,用id(cell)判断是否处理过,虽然粗暴但很有效。
4.2 文本框没有现成API,怎么用XML兜底
文本框比表格更隐蔽。Word里的文本框本质上是一个浮动在正文上的容器,它的内容存在XML的w:txbxContent节点里。python-docx的对象模型没有把文本框里的段落暴露给doc.paragraphs,直接用上面的遍历代码是碰不到这些文字的。
这种时候只能下沉到XML层。思路是找到文档体中所有w:txbxContent节点,然后遍历其中每个w:r,检查有没有w:highlight子节点。
from docx.oxml.ns import qn def extract_highlight_from_textboxes(doc): body = doc.element.body items = [] for txbx in body.iter(qn('w:txbxContent')): for r in txbx.iter(qn('w:r')): rPr = r.find(qn('w:rPr')) if rPr is None: continue highlight = rPr.find(qn('w:highlight')) texts = r.findall(qn('w:t')) if highlight is not None and texts: text = ''.join(t.text or '' for t in texts) if text.strip(): items.append({ 'text': text, 'val': highlight.get(qn('w:val')) }) return items这段代码的意思是:把文档XML里所有文本框容器找出来,再在容器内部遍历所有run片段,判断这些run是否有高亮标签。它绕过了python-docx的API限制,直接和底层XML对话,虽然代码看起来绕,但正是这种兜底方案能救你于水火。
4.3 页眉页脚和嵌套表格的遍历思路
页眉页脚提取高亮的方式和正文类似,只是入口不同。每个section对象都带有header和footer属性,它们的paragraphs列表可以直接遍历。
def extract_highlight_from_headers_footers(doc): items = [] for section in doc.sections: for paragraph in section.header.paragraphs: for run in paragraph.runs: if run.font.highlight_color is not None and run.text.strip(): items.append(('header', run.text)) for paragraph in section.footer.paragraphs: for run in paragraph.runs: if run.font.highlight_color is not None and run.text.strip(): items.append(('footer', run.text)) return items嵌套表格稍微麻烦一些。doc.tables只能拿到文档体最外层的表格,如果表格里又套了表格,内层的表格在doc.tables里是看不到的。解决方法还是用XML遍历,找到所有w:tbl节点再逐个包装成Table对象处理。做法不复杂,核心就是doc.element.body.iter(qn('w:tbl'))。
到这里,一个相对完整的提取程序应该覆盖了:正文、表格、文本框、页眉页脚、嵌套表格这几大区域。把这些函数组合起来,最终返回的是一个完整的高亮清单。
5. 踩坑实录:颜色枚举、空白run和版本差异
python-docx跑通基本功能不难,难的是面对“奇怪文档”时不翻车。我把实际使用中遇到的几个高频问题集中在这里,这些都是平时文档里不会写的东西。
5.1 自定义高亮颜色会导致解析异常
前面说过标准高亮颜色的枚举名是yellow、green、pink这些。但Word允许用户选“其他颜色”来自定义荧光笔颜色,一旦自定义颜色被保存到XML里,w:highlight的w:val可能变成一个非标准的值,比如十六进制颜色码形式00B0F0。这种时候run.font.highlight_color的枚举解析可能直接异常,也可能返回None,行为取决于库的具体实现版本。
最稳妥的兜底做法是不要完全依赖枚举,而是在确认异常时回退到XML取值:
def get_highlight_val(run): rPr = run._element.rPr if rPr is None: return None highlight = rPr.find(qn('w:highlight')) if highlight is None: return None return highlight.get(qn('w:val'))这个方法返回的是原始字符串,无论是不是标准枚举名都拿得到,适合做兼容处理和人工核对。
5.2 高亮run里全是空白或隐形内容
还有一种情况是:高亮标记确实存在,highlight_color也不是None,但run.text得到的是空字符串或者全是空格。这通常发生在用户高亮了段落末尾的换行标记,或者高亮区域里包含了一些特殊的控制字符。提取时如果不加过滤,输出结果里就会混入一批看不见内容的条目,看着像bug,其实是文档本身的问题。
代码里两个地方做了防护:一个是run.text.strip()判断非空,另一个是收集后参与合并的run本身必须有实际字符。这样处理下来,输出结果基本不会出现“假高亮”条目。
5.3 python-docx版本差异与快速排查顺序
python-docx版本之间有一些API差异。早期版本里,高亮相关的属性名或枚举组织方式和最新版有微调,如果代码在某个环境报AttributeError: 'Font' object has no attribute 'highlight_color',排查方向很可能是库版本太老。解决办法就是升级到新版:
pip install --upgrade python-docx还有一类问题是文档本身是加密的或者启用了宏。加密文档直接读取会报错,需要先通过msoffcrypto-tool解密;.docm宏文档如果只是提取文本,通常能读,但宏代码不会被处理。碰到这类文档,先单独确认格式,再跑脚本,省得排查半天最后发现是文件本身不配合。
整体排查顺序我的经验是:先确认后缀是.docx;再确认文档没有加密;接着确认库版本够新;最后再用上面提到的XML兜底函数去查原始标签。按这个顺序走,90%的问题都能定位到。
6. 把脚本扩展成批量小工具
单个文档能提取了,接下来比较自然的动作就是批量处理。把几十个文件拖进一个目录,跑一遍脚本,全部高亮内容汇总在一个文件里,这才是自动化该有的样子。
6.1 批量扫描目录下的docx文件
用glob或os.walk就能实现目录扫描。
import glob from docx import Document def batch_extract(directory): all_items = [] for file_path in glob.glob(f'{directory}/**/*.docx', recursive=True): try: doc = Document(file_path) items = extract_all(doc) all_items.append({ 'file': file_path, 'items': items }) except Exception as e: print(f'处理失败: {file_path}, 错误: {e}') return all_items注意这里我给每个文件都加了异常捕获。批量处理场景下,一个文件报错不应该让整个任务中断,先把能处理的处理完,最后再集中看失败清单,才是实用的做法。
6.2 输出结构化结果:TXT、CSV、Markdown
提取出来的内容最终要落到文件里。按用途不同,可以输出成不同格式。
- TXT适合快速浏览;
- CSV适合导入Excel做透视和分析;
- Markdown适合直接贴进文档、知识库或协作平台。
我一般输出CSV时,会保留这几个字段:文件名、所在区域(正文/表格/页眉等)、高亮颜色、高亮文本、所在段落上下文。这样既能看到内容,也能知道它在哪个位置、什么来头,后续回查特别快。
6.3 保留上下文:提取高亮的同时带上所在段落文本
这点是我实际用了一阵子后才补上的。最初我只提取高亮文字,结果汇总出来之后发现有些句子脱离了上下文根本看不懂在说什么。后来我把高亮run所在段落的完整文本也提取出来,作为“上下文”字段保存。别看只是多存了一列文本,用起来方便太多了——单独看高亮可能不知道是哪份文件里的哪一处,但一看所在段落马上就能定位。
def extract_all(doc): items = [] for para in doc.paragraphs: for run in para.runs: if run.font.highlight_color is not None and run.text.strip(): items.append({ 'area': 'body', 'color': str(run.font.highlight_color), 'text': run.text, 'context': para.text }) return items如果是比较重要的文档,我还会额外记录一下页码。python-docx不直接提供页码信息,需要先通过遍历找到run所在的段落,再配合其他手段推算页码,这个略复杂,但思路是清晰的可做的。
批量工具跑一段时间后,你会发现自己的生活被简化了一大半。原来可能要一个下午才能整理完的资料,现在双击一下脚本,几秒钟出结果。更妙的是这个脚本是通用的,以后任何人的Word文档需要提取高亮,都可以直接拿过来用,只不过改一下输入目录而已。我个人在实际使用中还有一个习惯:脚本里提取出来的结果文件,我会统一命名为高亮提取_日期.csv,按日期留档,这样哪天要复盘,翻历史文件就能看到当时的处理结果,不用重新跑一遍。这也是用了几次之后才总结出来的经验,希望对你有帮助。