Python批量实现Word关键词高亮:run与XML踩坑全指南
简介这是一份用于在Python中自动标注docx文档关键词的轻量工具包面向需要批量处理Word文档、进行词条定位或文本标记的Python使用者与办公自动化需求者。基于python-docx库以脚本方式实现关键词查找和替换标注。压缩包共4个文件包括Python主脚本、示例docx、全国地名CSV数据库和一份说明文档整体仅23KB体积小巧其中CSV提供精确到县级的全国地名可扩充自定义别名说明文档对运行环境、依赖安装和修改要点也进行了指引。脚本提供两个可直接运行的示例在示例Word文档中查找“Hello”“World”并替换为默认标注格式还可检测文档中是否出现CSV内的中国地名匹配后输出到指定路径读者只需修改主函数的输入输出即可迁移到自己的Word批注任务中。目前已获得359人学习适合作为python-docx库的入门实操参考。1. 给Word文档里的关键词批量做高亮第一行代码之前你要先知道的某公司法务每周要处理几十份 docx 格式的合同把“违约责任”“知识产权归属”“争议解决”这类词全部标黄。手动用 Word 的查找功能一个个刷遇到合并单元格、页眉、表格就很容易漏几百份文件下来眼睛先受不了。用 Python 给 word 文档做关键词标注本质上就是读入 docx找到关键词在文本里的位置再以不破坏原有格式的方式给对应 run 写入高亮标记。听起来是“查找 改样式”实际动手时至少有三个翻车点关键词被 Word 拆成 run 碎片导致匹配不到、标注完原格式被抹掉、表格和页眉漏标。这篇文章的路线是先把 docx 的内部结构讲透再给一个最小可用的高亮脚本最后把表格、页眉页脚、跨 run 匹配这些坑逐个补上。适合正在批量整理合同、报告、论文或者在做文本标记与人工复核数据准备的人。2. 先拆开docx再动手run与XML决定了你会踩什么坑2.1 docx不是一个文本文件而是装了XML的压缩包很多人第一次用 Python 处理 Word习惯性地以为 docx 像 txt 一样是一整段文本。实际上 docx 是一个 zip 压缩包打开后里面的word/document.xml才是正文文本被拆成大量w:r节点也就是我们常说的 run。每个 run 可以有自己的字体、字号、加粗、颜色、高亮等格式属性。python-docx 库把这一层封装成了Paragraph和Run对象paragraph.runs返回的列表就是这一段的 run 集合。这带来一个关键约束我们做关键词标注做的不是“替换字符串”而是“在指定的 run 上设置格式”。如果直接对整个段落文本做 replace或者用run.text run.text.replace(...)极有可能把 run 原有的格式属性冲掉或者让 Word 认为文件结构异常。正确思路是尽量不动文本内容只修改run.font.highlight_color、run.font.bold这类格式字段。即便要拆分 run也必须保留原 run 的格式定义否则标注完成的同时整篇文档的版式也毁了。2.2 关键词为什么会跨run被“腰斩”在实际文档里一句话往往不是一个 run而是按格式边界被拆成多个 run。比如“违约责任由乙方承担”这 9 个字“违约”是加粗的“责任”是默认体“由乙方承担”可能因为拼写检查或修订痕迹又被切开。如果这段文字被存成 5 个 run那关键词“违约责任”就不在任何一个 run 的完整 text 里而是横跨两个 run。这时候直接遍历run.text做in判断结果是什么都匹配不到。Word 本身在编辑、粘贴、输入法、撤销、审阅修订时会不断制造新 run所以文档越改越碎。paragraph.text能拿到的只是把所有 run 的文本按顺序拼起来的长字符串它保留了文字内容却丢失了 run 的边界信息。要做可靠的关键词标注必须先理解这种“文本完整、run 碎片化”的状态。常见做法有两个一是把命中段落整体合并成一个 run 再高亮简单但会丢失原有格式二是按偏移量把匹配区间映射到 run 上只对命中部分做处理保留前后 run 的格式。后一种才是能上生产环境的方案。2.3 动手前先定三件事标注样式、匹配规则、幂等性第一件事是标注样式。最常用的是高亮也就是highlight_color它不改变字体宽度也不会影响行高其次是加粗和字体颜色适合做分级标注。第二件事是匹配规则。关键词是否区分大小写、是否忽略全角半角差异、要不要整词匹配这些都会影响命中结果。第三件事是幂等性。同一个脚本跑第二遍不能把已经高亮过的区域再高亮一次否则颜色会叠加run 会被切得更碎后续维护很难看。这三件事哪怕在写最小脚本时也建议先想清楚否则后面扩展会处处返工。3. 最小可用的关键词标注脚本遍历run直接写高亮3.1 安装python-docx与工程目录先装依赖命令如下pip install python-docx装完之后在项目目录下建三个文件夹input放待处理的 docxoutput放处理后的文件src放脚本。一个最简工程结构如下keyword_marker/ ├── input/ │ └── demo.docx ├── output/ ├── src/ │ └── mark_keyword.py这样布局的原因很简单批处理场景下输入输出目录分开避免脚本把原文件覆盖掉保留一份“后悔药”。后面加批量处理时只需要在脚本里加一个目录遍历循环结构不用推翻。3.2 基础版实现run级匹配高亮先看第一版能跑的脚本它不做跨 run 处理只做“单个 run 内包含关键词就高亮整个 run”。from docx import Document from docx.enum.text import WD_COLOR_INDEX def highlight_run_direct(input_path, output_path, keywords): 把包含关键词的 run 直接标黄 doc Document(input_path) for para in doc.paragraphs: for run in para.runs: for kw in keywords: if kw in run.text: run.font.highlight_color WD_COLOR_INDEX.YELLOW doc.save(output_path) if __name__ __main__: highlight_run_direct( input/demo.docx, output/demo_highlighted.docx, [违约责任, 知识产权, 争议解决], )这段代码的逻辑很直白三层循环外层段落中层 run内层关键词。只要某个 run 的文本里包含任意一个关键词就把整个 run 的高亮颜色设为黄色。优点是不会破坏 XML 结构也不会改动文本内容所以几乎不存在文件损坏风险。缺点有两个一是跨 run 的关键词漏标二是只要 run 里有一个关键词整个 run 都会被标黄哪怕这个 run 里有 50 个字只命中了其中 2 个。参数上需要注意WD_COLOR_INDEX的取值除了YELLOW还有GREEN、RED、BLUE、PINK等。如果设置成AUTO或None就相当于取消高亮。关键词列表建议用集合或去重后的列表避免同一批词有重复项导致多做无用功。这个基础版适合处理几十页、文本没有被反复修订过的普通文档遇到复杂文档先拿它跑一遍看看漏标率再决定是否要上后面章节的跨 run 方案。3.3 验证高亮是否真的写进去了脚本跑完不能只看“没有报错”就认为成功必须回读文件确认。下面这段代码会把所有带高亮的 run 打印出来from docx import Document from docx.enum.text import WD_COLOR_INDEX def verify_highlight(file_path): doc Document(file_path) count 0 for para in doc.paragraphs: for run in para.runs: if run.font.highlight_color is not None: print(f高亮文本: {run.text!r}) print(f高亮颜色: {run.font.highlight_color}) count 1 print(f总共高亮 run 数: {count}) if __name__ __main__: verify_highlight(output/demo_highlighted.docx)逻辑说明run.font.highlight_color是WD_COLOR_INDEX枚举对象不是字符串打印出来会类似WD_COLOR_INDEX.YELLOW7。如果返回None说明这个 run 没有被设置高亮。验证这一步不能省我见过不止一次脚本跑完输出文件在 Word 里打开一切正常但关键词一个都没标上原因就是匹配逻辑把段落对象里不包含文本内容的 run 也当成了目标。回读能让问题当场暴露。4. 标注范围补齐表格单元格、页眉页脚与多套样式规则4.1 表格里的关键词不能靠doc.paragraphs拿到doc.paragraphs只返回正文里的段落表格里的文本不会出现在这个列表里。而合同、报告这类文档里关键词恰恰高频出现在表格中。要覆盖表格需要单独遍历doc.tablesfrom docx import Document from docx.enum.text import WD_COLOR_INDEX def highlight_tables(doc, keywords): seen_cells set() for table in doc.tables: for row in table.rows: for cell in row.cells: # 合并单元格会让同一个 cell 被多次访问做出去重 tc_id id(cell._tc) if tc_id in seen_cells: continue seen_cells.add(tc_id) for para in cell.paragraphs: for run in para.runs: for kw in keywords: if kw in run.text: run.font.highlight_color WD_COLOR_INDEX.YELLOW逻辑说明cell._tc是单元格底层的 XML 节点合并单元格时同一个 XML 节点会被多个row.cells引用。用id(cell._tc)做去重能避免同一格被高亮两次。这段代码没有修改run.text所以不会破坏表格的边框、列宽、合并状态。需要注意嵌套表格在 python-docx 里不会通过table.rows直接暴露如果文档里有表格嵌表格还需要递归遍历这个在小工具里再补。4.2 页眉页脚与“链接到上一节”的坑页眉页脚里的关键词也经常需要标注比如保密协议的页眉里带“合同编号”。遍历方式如下from docx import Document from docx.enum.text import WD_COLOR_INDEX def highlight_headers_footers(doc, keywords): for section in doc.sections: headers [ section.header, section.first_page_header, section.even_page_header, ] footers [ section.footer, section.first_page_footer, section.even_page_footer, ] for header in headers: if header.is_linked_to_previous: continue for para in header.paragraphs: for run in para.runs: for kw in keywords: if kw in run.text: run.font.highlight_color WD_COLOR_INDEX.YELLOW for footer in footers: if footer.is_linked_to_previous: continue for para in footer.paragraphs: for run in para.runs: for kw in keywords: if kw in run.text: run.font.highlight_color WD_COLOR_INDEX.YELLOW这里的坑在is_linked_to_previous。一个文档分成多节时后面节的页眉很可能设置成“链接到上一节”这时section.header返回的是空壳真正的内容在第一节里。如果忽略了链接关系会在没有内容的页眉上白跑一遍。处理策略是遇到链接到上一节就跳过由这一节的定义源头统一处理如果同一节内需要强制独立先调用header.is_linked_to_previous False再把内容写进去但这样会复制上一节内容实际标注前要确认不会搞乱文档结构。4.3 一份关键词表对应多种标注样式业务需求通常不只是“标黄”而是“重点词标黄、风险词标红、必读词加粗”。这时可以把样式规则做成字典让每个关键词对应一个动作再统一调度from docx import Document from docx.enum.text import WD_COLOR_INDEX from docx.shared import RGBColor STYLE_RULES [ { name: 黄色高亮, keywords: [知识产权, 违约责任], style: highlight, }, { name: 红色字体, keywords: [逾期, 解除合同], style: color, }, { name: 加粗, keywords: [不可抗力], style: bold, }, ] def apply_style_to_run(run, style): if style highlight: run.font.highlight_color WD_COLOR_INDEX.YELLOW elif style color: run.font.color.rgb RGBColor(0xFF, 0x00, 0x00) elif style bold: run.font.bold True def highlight_with_rules(input_path, output_path, rules): doc Document(input_path) for para in doc.paragraphs: for run in para.runs: for rule in rules: for kw in rule[keywords]: if kw in run.text: apply_style_to_run(run, rule[style]) doc.save(output_path)逻辑说明这里把关键词和样式解耦一个 run 可能同时命中多个规则前面的规则先执行后面的规则会覆盖相同字段。比如“违约责任”同时出现在“黄色高亮”和“加粗”两个规则里后执行的规则决定最终效果。为了避免这种隐式覆盖可以在外层加一个优先级排序或者规定每个关键词只能属于一个规则。参数说明RGBColor(0xFF, 0x00, 0x00)是红色如果想用更克制的暗红可以改成RGBColor(0xC0, 0x00, 0x00)。整个脚本仍然没有改文本所以格式风险很小。5. 关键词标注常见问题与排查从全部漏标到文件损坏5.1 关键词明明在文档里一个都没标上跨run检测失败现象肉眼在 Word 里能看到“知识产权归属”这个词但脚本跑完没有任何 run 被高亮。原因大概率是这个词被 Word 拆到了两个或多个 run 里所以任何一个run.text里都不存在完整关键词。解决思路是回到段落文本做匹配再把命中区间映射到 run 上做拆分。参考下面这段import copy from docx.text.run import Run from docx.enum.text import WD_COLOR_INDEX def get_run_with_range(para): 返回 [(run, 起始offset, 结束offset)]其中 offset 是段落内字符位置 result [] offset 0 for run in para.runs: start offset offset len(run.text) result.append((run, start, offset)) return result def highlight_paragraph_range(para, match_start, match_end): ranges get_run_with_range(para) for run, run_start, run_end in ranges: if run_end match_start or run_start match_end: continue # 与命中区间无交集 text run.text left_len max(0, match_start - run_start) right_len max(0, run_end - match_end) mid_start min(max(0, match_start - run_start), len(text)) mid_end max(0, len(text) - right_len) left_text text[:left_len] mid_text text[mid_start:mid_end] right_text text[right_len:] if right_len len(text) else run.text mid_text run.font.highlight_color WD_COLOR_INDEX.YELLOW if left_text: new_left Run(copy.deepcopy(run._element), para) new_left.text left_text run._element.addprevious(new_left._element) if right_text: new_right Run(copy.deepcopy(run._element), para) new_right.text right_text run._element.addnext(new_right._element)这段代码的核心是先算出每个 run 在段落文本里的字符起点和终点再找出与命中区间有交集的 run。每个 run 只保留中间命中的部分左侧和右侧用deepcopy复制原 run 的 XML 节点再分别插到原 run 前后。这样左右两侧的格式和命中的高亮是分开的。需要注意这段代码把原 run 的格式复制了但复制出来的 run 也会带上高亮属性因为deepcopy复制的是当前节点的完整状态。实际使用时要先把原 run 的高亮字段清掉再复制或者复制完再清理。这个细节就是“看起来能跑跑完不是那么回事”的高频来源。提示跨 run 处理属于高危操作正式批量前一定要先做一个小文档验证确认拆分结果在 Word 里打开后格式和预期一致。5.2 标注之后整段原有的加粗和颜色全丢了现象输出文件里被标注的段落字体都变成了默认黑色细体原来的加粗、标红、特殊字体全消失。原因很多人偷懒用run.text run.text.replace(...)把 run 的整体内容替换掉python-docx 在设置 text 时会把该 run 里原本的格式信息覆盖还有人直接把para.text重新拼接等于把整个段落的 run 全拆了重建格式必然丢失。解决不要用文本替换的方式做标注只设置格式字段。必须拆分 run 时用上一节copy.deepcopy的方式复制原 run 节点而不是新建一个空白 run。这条是最常见的血泪经验尤其处理已经排好版的报告时一份文件返工成本极高。5.3 同一批关键词跑两遍高亮越标越花现象第一次跑完某个 run 被标黄第二次再跑这个 run 里的子词又命中一次高亮区域扩大甚至出现多重高亮叠加。原因第一轮标注会把命中的文本和前后文本拆成三个 run第二轮再执行时原来的“中间命中段”是完整字符串关键词里的子串可能再次命中比如“急性心肌梗死”命中了“心肌梗死”。解决在拆分前做一个过滤如果整个命中区间已经被高亮过就跳过或者在匹配时对关键词按长度倒序排序优先匹配更长的词组避免短词先抢走区间。更稳妥的做法是每次只处理一个段落内未设置过highlight_color的 run并且把“已高亮区间”和“本次命中区间”做一个重叠检测。5.4 几百页文档跑十几分钟慢到没脾气现象脚本在几十页文档上跑得飞快换成几百页的标书就卡住。原因关键词循环嵌套太深for kw in keywords: if kw in run.text这种写法在关键词几十个、run 上万个时是立方级的性能消耗。解决把关键词列表用re.compile合并成一整条带“或”的正则一个 run 只需要一次finditer大段文本先做一次粗筛没有关键词的段落直接跳过超过 300 页的文档可以先把para.text缓存到内存数组所有关键词匹配完成后统一回写。注意正则里的关键词要先用re.escape转义否则词里的括号、点号会被当成通配符。5.5 输出文件打不开Word提示“文件损坏”现象保存后的 docx 双击报错Word 问是否尝试恢复。原因最常见的是直接解压 docx 改document.xml再压回去把命名空间或节点顺序搞乱其次是用了不兼容的库版本读取新版 docx还有可能是脚本把 run 拆成了空节点Word 不认空 run。解决始终用Document(path)读入、用doc.save(path)输出不要手动改 zip 里的 XML保存后立刻用下面这段做结构校验import zipfile from xml.etree import ElementTree def check_docx_ok(path): with zipfile.ZipFile(path) as z: xml_bytes z.read(word/document.xml) ElementTree.fromstring(xml_bytes) return Truecheck_docx_ok只做最基础的 XML 合法性检查确认document.xml能解析。它查不出所有兼容性问题但能拦住大部分低级损坏。批量处理前给输入目录留备份也是给自己留后悔药。某次处理一批模拟合同跑到第 120 份时输出的文件在 Word 里打开报错还好有备份和校验否则全部重来。6. 把标注脚本升级成可配置小工具批处理、dry-run与一份校验报告这一章的技巧是把前面所有逻辑打包成一个不至于把自己坑到的工程化脚本。核心是三个能力配置驱动、先模拟后实跑、处理完自动校验。配置用 JSON 最省事。关键词、样式、作用域全放进去{ keywords: [ {word: 知识产权, style: yellow}, {word: 逾期, style: red} ], scopes: [body, table, header, footer], case_sensitive: false }主循环这样设计import json import sys from pathlib import Path from docx import Document def dry_run(doc, rule_map): hit_info [] for para in doc.paragraphs: text para.text for rule in rule_map: if rule[word] in text: hit_info.append((rule[word], body)) return hit_info def run_batch(config_path, input_dir, output_dir, dryFalse): cfg json.loads(open(config_path, encodingutf-8).read()) rule_map cfg[keywords] input_dir Path(input_dir) output_dir Path(output_dir) output_dir.mkdir(exist_okTrue) for docx_file in input_dir.glob(*.docx): doc Document(str(docx_file)) if dry: hits dry_run(doc, rule_map) print(f{docx_file.name}: 命中 {len(hits)} 次) continue # 这里按第3/4章的遍历逻辑做标注 # 保存后自动校验 out_path output_dir / f{docx_file.stem}_marked.docx doc.save(str(out_path)) if check_docx_ok(str(out_path)): print(f已生成 {out_path.name}) else: print(f校验失败: {docx_file.name}, filesys.stderr)dry_run的价值在于批量跑之前先把每个文件会命中哪些词、命中次数是多少打印出来一眼就能发现关键词规则是不是写错了比如全角半角不匹配、标题里的词没被覆盖。我的习惯是先拿一个 10 页内的小文档做全量实跑并打开检查再挂到整个目录上批处理。处理完再看校验报告不把 Word 打开作为人工检查的唯一手段。这段经验是从几个项目里攒出来的还有人会让脚本在处理前自动复制一份原文件到backup目录成本几乎为零但能避免跑错规则后没有退路。希望帮到你。本文还有配套的精品资源点击获取