简介:这是一份用于在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放待处理的 docx,output放处理后的文件,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.YELLOW(7)。如果返回None,说明这个 run 没有被设置高亮。验证这一步不能省,我见过不止一次脚本跑完,输出文件在 Word 里打开一切正常,但关键词一个都没标上,原因就是匹配逻辑把段落对象里不包含文本内容的 run 也当成了目标。回读能让问题当场暴露。
4. 标注范围补齐:表格单元格、页眉页脚与多套样式规则
4.1 表格里的关键词不能靠doc.paragraphs拿到
doc.paragraphs只返回正文里的段落,表格里的文本不会出现在这个列表里。而合同、报告这类文档里关键词恰恰高频出现在表格中。要覆盖表格,需要单独遍历doc.tables:
from 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, dry=False): cfg = json.loads(open(config_path, encoding="utf-8").read()) rule_map = cfg["keywords"] input_dir = Path(input_dir) output_dir = Path(output_dir) output_dir.mkdir(exist_ok=True) 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}", file=sys.stderr)dry_run的价值在于,批量跑之前先把每个文件会命中哪些词、命中次数是多少打印出来,一眼就能发现关键词规则是不是写错了,比如全角半角不匹配、标题里的词没被覆盖。我的习惯是先拿一个 10 页内的小文档做全量实跑并打开检查,再挂到整个目录上批处理。处理完再看校验报告,不把 Word 打开作为人工检查的唯一手段。
这段经验是从几个项目里攒出来的:还有人会让脚本在处理前自动复制一份原文件到backup目录,成本几乎为零,但能避免跑错规则后没有退路。希望帮到你。
本文还有配套的精品资源,点击获取