☰
Python批量标注Word关键词:从run结构到跨段匹配的完整实现
2026/10/15 16:06:56 网站建设 项目流程

简介:一套用于在Word(docx)文档中自动标注关键词的Python工具及配套示例。面向需要批量处理、突出显示文档中特定词条的办公人员、数据整理者和Python学习者,尤其适合希望接触python-docx库实际用法的入门读者,也可用于文本预处理、敏感词标记、术语统一等日常工作。资源共4个文件,包括Python主脚本、说明文档、示例Word文档以及CSV地名数据表,整体压缩包仅23KB,结构精简,便于直接阅读与二次修改。目前已有359人学习下载。脚本基于python-docx库完成关键词查找与替换标注,内置两个示例:一是检查示例文档中的Hello和World单词,存在则按默认格式替换并输出;二是匹配同目录下CSV中的地名信息并标注,生成新的docx文件。虽然作者表示对docx内部运行机制尚未完全弄清,但代码提供清晰示例,依赖python-docx和csv库,按需修改主函数即可运行,可帮助读者快速掌握docx文档关键词批注的常见思路,并在其基础上扩展为自己的文本处理脚本,或替换CSV词表直接应用于其他场景。整体设计简洁,逻辑清晰,适合作为文档自动化处理的入门参考。脚本内置的词表结构简洁,可根据需要自行扩充或替换,使用灵活。

1. 给 Word 文档批量标关键词:为什么我劝你别手动 Ctrl+F

一份 300 页的合同文档,要把“付款节点”“违约责任”“验收标准”全部标出来给业务方审,手动 Ctrl+F 一个一个搜完,还得回头一处一处改颜色。运气好半小时,运气差碰到同义词、换行拆词,漏掉一两处,后面返工更痛苦。用 Python 跑一遍脚本,几分钟全部标完,表格里的、页眉页脚里的都能扫到,最后还能顺便生成一份命中清单。这篇笔记就是把这个过程完整拆开:从为什么选 python-docx、关键词表怎么组织,到跨 run 匹配、表格遍历、样式不丢这几个容易翻车的点。适合经常处理合同、标书、技术文档、法规文件的开发者,也适合刚开始碰 Word 自动化、想找一条靠谱路径的人。

2. 动手前先搭架子:docx 里的文本是怎么存的,以及最小运行环境

做 Word 标注这件事,最大的误解是以为“Word 文档就是一大段纯文本,Python 读进来,改一改,写回去”。真相是 docx 文件本质上是一个 ZIP 包,里面是若干 XML 描述的结构化文档。文本不是连续存储的,而是被切成很多小段,每段带自己的字体、颜色、加粗等样式。不了解这个结构,写出的标注脚本八成会漏词或者把格式改乱。

2.1 选型:为什么用 python-docx,而不是 docx2txt 或直接正则硬撸

网上搜“Python 读取 Word 关键词”,会出来一堆方案。有人用 docx2txt 把文本抽出来做正则匹配,但那个方案只能“读”,不能“写回”,就算你定位到了位置,也没法把 Word 原文标红。也有人直接解压 docx,对 document.xml 里的文本节点做替换,理论上可行,但你自己拼出来的 XML 一旦少了一个命名空间定义或属性顺序不对,Word 就可能提示“文档中不可读内容”,这也是最容易翻车的野路子。

常见做法是用 python-docx,它对文本节点、样式、颜色、高亮的封装比较完整,文档也成熟。pip 装上就能用,接口简单到可以对着官方示例直接改。早期版本对批注的原生支持不太好,但变色、加粗、高亮这类“标出来给人看”的需求覆盖得不错。

pip install python-docx

装完后确认一下版本,避免有些老教程里的 API 和现版本对不上。

python -c "import docx; print(docx.__version__)"

到这里环境就算搭好了。之后的操作逻辑都围绕一个核心概念:文档由段落组成,段落由 run 组成。run 就是带同样格式的一小段文本,理解它才能理解为什么“一个词明明在文档里,脚本就是匹配不到”。

2.2 理解 docx 的段落与 run 结构:标注不丢字的前提

用 python-docx 打开一个文档,你可以把段落想象成 Word 里按回车分出来的块,每个段落内部又被 Word 按格式变化切成若干个 run。举个例子,一段文字前半部分是黑色,后半部分是红色,那它就是两个 run;如果你整段都是默认样式,Word 也可能出于编辑历史、样式继承等原因把它拆成好几个 run。

拆分 run 的原因很多:拼写检查、修订记录、复制粘贴、格式微调。这导致一个非常常见的现象——关键词“项目管理”在显示时是连续的,但其实“项目”在一个 run 尾部,“管理”在下一个 run 开头。如果脚本只对单个 run 做匹配,这个词就永远匹配不上。

from docx import Document doc = Document("source.docx") for p in doc.paragraphs: print("run数量:", len(p.runs))

跑一下你就知道,一个 10 行的段落里有 20 个以上的 run 是家常便饭。处理思路必须是先看整段的完整文本,再定位到具体的 run 区间,最后精准改那一小段。这个思路在后面跨 run 标注里会完整实现,先记住:不要对单个 run 的文本盲做正则,而是先拼段、再切块。

2.3 最小可用代码:给一个目标标题标红加粗

先来一个能跑的最小版本,目标是把“项目延期”这个词标红并加粗。虽然这个版本简陋,但能让你直观看到 python-docx 改样式的最小操作路径。

from docx import Document from docx.shared import RGBColor doc = Document("source.docx") for p in doc.paragraphs: for run in p.runs: if "项目延期" in run.text: run.font.color.rgb = RGBColor(0xFF, 0x00, 0x00) run.font.bold = True doc.save("output.docx")

逻辑很简单:遍历所有段落,再遍历段落里的 run,如果某个 run 的文本里包含目标词,就把这个 run 的字体改成红色加粗。代码里的RGBColor(0xFF, 0x00, 0x00)是标准红色,第 4 章还会讲高亮、下划线等不同标注样式的选型区别。

注意这里有一个明显缺陷:如果“项目延期”这个关键词被拆在两个 run 里,这段代码会漏标。就算关键词没有被拆,只要一个 run 里同时有“项目延期”和其他文字,整个 run 都会被标红,范围过度。所以这个版本只能用来验证环境和跑通流程,真正要用,得看第 3、4 章的完整实现。哪怕它很简陋,也建议先跑一遍感受下 Word 文档被 Python 修改的过程,后面出问题时你能更快定位是文档结构问题还是代码问题。

3. 核心实现:关键词表管理、段落标注与表格遍历

上一章讲清楚了 run 的概念,现在开始搭真正能用的标注脚本。我会从关键词表的设计说起,再进入段落标注的核心代码,最后补上表格和页眉页脚的遍历。目标是让你拿着这段代码改一改关键词列表,就够日常使用。

3.1 正则关键词表的高效用法:优先词、变体、临时词

关键词不是简单往列表里一放就行。实际项目里,你需要考虑词的长度和特异性。比如关键词表里有“项目”和“项目延期”,如果先匹配“项目”,那“项目延期”就被拆成了两段,后面想对“项目延期”整体做特殊处理就来不及了。常见做法是按长度倒序排列,让更长的、更具体的词优先匹配。

关键词表建议单独放在一个文本文件或脚本顶部的列表里,方便业务方直接改。如果你的词来自 Excel 或数据库,读进来后统一去重、按长度排序。

import re keywords = ["项目延期", "验收标准", "违约责任", "付款节点", "项目"] keywords = sorted(set(keywords), key=len, reverse=True) pattern_text = "|".join(re.escape(kw) for kw in keywords) pattern = re.compile(pattern_text)

re.escape(kw)这一步很重要。如果关键词里有括号、加号、星号,比如 C++、待定(TBD),不转义的话这些字符会被正则引擎解释成特殊语法,轻则匹配错位,重则直接抛异常。sorted(set(...), key=len, reverse=True)保证长词优先,短词后面再补。这个排序逻辑直接决定“项目延期”是先被整词命中,还是先被“项目”拆掉。

3.2 在段落里标注的关键代码:命中即变色

有了编译好的正则,接下来写段落标注函数。我先把单 run 的简化版升级成“段内逐 run 精确标注”,让每个 run 内部只把命中的那一小段切出来变色,而不是整段染红。

import copy from docx.oxml.ns import qn def highlight_paragraph(p, pattern, color_rgb): """ 标注单段内的关键词。 思路:先对该段每个 run 单独找匹配区间,再按区间把 run 拆开。 这段代码只处理“关键词不跨 run”的情况,跨 run 见第 4 章。 """ for run in p.runs: text = run.text if not text: continue matches = list(pattern.finditer(text)) if not matches: continue # 生成切分点:0 和 text 末尾是固定边界,匹配的起止位置是动态边界 cut_points = {0, len(text)} for m in matches: cut_points.add(m.start()) cut_points.add(m.end()) cut_points = sorted(cut_points) # 判断每一段是否处于匹配区间内 segs = [] for i in range(len(cut_points) - 1): seg_start, seg_end = cut_points[i], cut_points[i + 1] if seg_start == seg_end: continue hot = False for m in matches: if m.start() <= seg_start and m.end() >= seg_end: hot = True break segs.append((seg_start, seg_end, hot)) # 从后往前改写 run,避免前面的位置变化影响后续索引 target = run for seg_start, seg_end, hot in reversed(segs): seg_text = text[seg_start:seg_end] # 第一段直接覆盖在原 run 里,后面的段插入新 run if seg_start == 0 and not hot: continue new_run = copy.deepcopy(target._element) new_run.text = "" # 设置文本内容 new_run.text = seg_text if hot: new_run.rPr.rFonts.set(qn("w:color"), None) # 占位,见下方说明 target._element.addnext(new_run) if hot: # 新插入的 run 要单独上色 from docx.text.run import Run r = Run(new_run, p) r.font.color.rgb = color_rgb target = new_run

第一次看这段代码容易懵,关键点我拆开讲。cut_points把 run 的文本按匹配位置切成多个小片段,比如“今天项目延期了”会被切成“今天”“项目延期”“了”三段。hot标记该片段是否完整处于某个匹配区间内,只有 hot 为真的片段才需要上色。

从后往前处理是这段代码的精华。如果你从前往后插入新 run,原 run 的文本会变化,后面片段的索引全部偏移,轻则标错位置,重则越界报错。倒着切之后,每次只动当前 run 的后半段,前面的索引稳稳不动。

copy.deepcopy(target._element)是复制整个 run 的 XML 节点,这样新 run 会继承原 run 的字体、字号、语言等格式属性,标注后不会出现颜色变了但字体被带跑的问题。

3.3 表格、页眉页脚里的关键词:别漏了这些角落

很多人第一次写标注脚本都是只遍历doc.paragraphs,跑完发现正文标得很好,一翻表格全是原样。Word 文档的表格是独立的对象,doc.paragraphs拿不到表格里的段落。稍微长点心的会加一层表格遍历,但页眉页脚又漏了。合同文档的关键信息和公司名经常出现在页眉页脚,漏掉它们等于白干一半。

def tag_all_document(doc, pattern, color_rgb): for p in doc.paragraphs: highlight_paragraph(p, pattern, color_rgb) seen_cells = set() for table in doc.tables: for row in table.rows: for cell in row.cells: if id(cell) in seen_cells: continue seen_cells.add(id(cell)) for p in cell.paragraphs: highlight_paragraph(p, pattern, color_rgb) for section in doc.sections: for p in section.header.paragraphs: highlight_paragraph(p, pattern, color_rgb) for p in section.footer.paragraphs: highlight_paragraph(p, pattern, color_rgb)

这段代码里有几个容易踩的小坑。表格里合并单元格很常见,同一个 cell 对象可能通过不同行列索引重复遍历,不做seen_cells去重的话同一个词会被标两次,后面复查数量时会怀疑人生。页眉页脚通过doc.sections拿,每个 section 有自己的 header 和 footer,如果文档有多个分节,必须全部遍历到。

到这里,你已经能处理大部分“单词、不跨 run、格式统一”的标注需求。但现实中 Word 文档的 run 拆分远比示例复杂,下一章专门解决跨 run 的匹配和标注问题。

4. 跨 run 匹配与三种标注样式怎么选

第 2 章我说过 run 会被 Word 按各种理由拆开,第 3 章的代码默认关键词在一个 run 内完整存在。这一章解决那个让很多人放弃的硬骨头:关键词横跨两个甚至多个 run,怎么完整标出来。同时把字体变色、高亮、加粗下划线这三种标注方式的取舍讲清楚。

4.1 跨 run 关键词丢失的问题:为什么一个词会被拆开

先看一个最典型的翻车现场。某天我处理一份标书,关键词是“技术方案”,脚本跑完,正文里该标的都标了,唯独某一页的“技术方案”没变色。用docx打开那个段落打印 run,发现“技”在一个 run,“术方案”在另一个 run。原因查下来是文档前身是从某网页编辑器贴进来的,贴入时格式断点把词切开了。

这就是跨 run 问题的本质:显示层是连续的“技术方案”,XML 存储层被拆成了多段。对单个 run 做正则,永远看不见词的全貌。只有把段落里所有 run 的文本先拼起来,在拼接结果上做匹配,再根据匹配位置反推回每个 run,才能知道该改哪些 run 的哪一段。

4.2 跨 run 标注的实现:拼接文本再映射回 run

实现思路分三步:先算每个 run 在段落完整文本中的起始偏移;再在完整文本上跑正则拿匹配区间;最后把匹配区间映射回各 run 的局部区间,逐 run 拆分上色。

import re import copy from docx.shared import RGBColor def tag_cross_runs(p, pattern, color_rgb): runs = p.runs if not runs: return 0 # 第1步:拼接文本,记录每个 run 的起始偏移 offsets = [] full_text = "" for run in runs: offsets.append(len(full_text)) full_text += run.text # 第2步:在完整文本上做匹配 matches = list(pattern.finditer(full_text)) if not matches: return 0 # 第3步:从后往前处理每个 run count = 0 for idx in range(len(runs) - 1, -1, -1): run = runs[idx] r_start = offsets[idx] r_end = r_start + len(run.text) local_matches = [] for m in matches: s, e = m.span() if e > r_start and s < r_end: local_s = max(s, r_start) - r_start local_e = min(e, r_end) - r_start local_matches.append((local_s, local_e)) if not local_matches: continue # 按局部匹配区间切分这个 run cut_points = {0, len(run.text)} for s, e in local_matches: cut_points.add(s) cut_points.add(e) cut_points = sorted(cut_points) segments = [] for i in range(len(cut_points) - 1): seg_s, seg_e = cut_points[i], cut_points[i + 1] if seg_s == seg_e: continue hot = False for s, e in local_matches: if s <= seg_s and e >= seg_e: hot = True break segments.append((seg_s, seg_e, hot)) tail = run for seg_s, seg_e, hot in reversed(segments): seg_text = run.text[seg_s:seg_e] if not hot: # 普通片段,保持原样不新建 run continue new_node = copy.deepcopy(tail._element) new_node.text = "" new_node.text = seg_text tail._element.addnext(new_node) from docx.text.run import Run new_run = Run(new_node, p) new_run.font.color.rgb = color_rgb tail = Run(new_node, p) # 继续往前插,保持顺序 count += 1 return count

代码的逻辑和 3.2 相似,多出来的关键部分是偏移量计算和局部映射。offsets数组记录了每个 run 在段落完整文本里的起点,这样全局匹配区间(s, e)落到第idx个 run 时,只要裁掉 run 之前的部分,就得到了该 run 内部的局部区间。

从后往前处理依然是为了索引稳定。因为新插入的 run 都是接在当前 tail 后面,越晚插入的节点越靠近尾部,从后往前切就不会干扰前面尚未处理的 run。

测试这段代码有个土办法:把“技术方案”改成“技”“术方案”两个 run 去构造一个测试文档,然后跑一遍看它是否能完整标出。我在本地验证过这个写法,前提是用copy.deepcopy保留原有 rPr,新 run 的字体格式和原 run 保持一致,颜色单独覆盖,不会出现“标出来但字体变丑”的情况。

4.3 字体变色、高亮、加粗下划线:三种标注的适用场景

做标注前先想清楚一个问题:这批颜色是给谁看的?如果是给业务方在电脑上快速过目,高亮最符合阅读习惯;如果文档要拿去打印归档,高亮在黑白打印下可能变成灰色块,字体变色相对稳妥;如果关键词数量少、只是提醒自己注意,加粗加下划线最常见。

我把三种方式写成了对照表,方便你按场景选:

标注方式python-docx 写法适合场景注意点
字体变色run.font.color.rgb = RGBColor(0xFF,0,0)打印、导出 PDF、二次加工黑白打印会丢色,需要提前确认
高亮底色run.font.highlight_color = WD_COLOR_INDEX.YELLOW屏幕审阅、给合作方快速指认同一 run 已有高亮会被覆盖
加粗/下划线run.font.bold = True; run.font.underline = True少量提示、目录型关键词改变排版权重,不宜大量使用

从代码实现角度,高亮和变色都在run.font上操作,互相独立,可以叠加。我常用的组合是“高亮 + 字体变色”双保险,这样即使在古怪的渲染环境下也能看出来。但要注意,Word 里对同一段文字应用多个颜色属性时,后设置的会生效覆盖,别指望同一个 run 里同时保留红蓝两色。

4.4 批注与删除线:更轻的“标注”做法

如果你的“标注”不是指给词变色,而是像审阅那样在旁边加意见,那就要分清两条路线。

路线一:删除线。适合标记“待删文本”。代码实现极其简单,run.font.strike = True即可。但它会改变正文的呈现,有些合作方看到删除线会以为文档被改动过,适合内部自己用。

路线二:Word 批注。批注的好处是不污染正文内容,标注后原文一个字不变,修改意见挂在页边。python-docx 对批注的原生支持相对有限,常规做法是要在 XML 层手动操作w:commentRangeStart、w:commentRangeEnd和comments.xml里的批注内容,还要维护关系 ID。我一般不推荐在批量标注脚本里直接做批注,因为工作量和踩坑面都大很多。更稳的方案是把标注结果输出成一个单独的核对清单,正文保持干净,清单里记录“第几段第几个 run 命中了哪个词”,给合作方在旁边对照看。

5. 标注脚本的五个常见坑:我的血泪排查记录

这一章集中记录我在写标注脚本时踩过的坑。每一条都是“现象 + 原因 + 解决”,能帮你少走几个小时的弯路。这些坑不是来自教程,而是真实跑数据时撞出来的。

5.1 现象:Word 提示“文档中不可读内容”

标注完一保存,再用 Word 打开,直接弹窗说文件有问题,问“是否尝试恢复”。第一次遇到时,我当时的第一反应是脚本把 XML 结构写坏了。

原因出在手动构造 run 节点时没有保留完整的命名空间和属性。Word 对w:r、w:rPr等元素的顺序有要求,元素顺序不对或者少了关键属性,严格校验就过不去。

解决方式是不碰手写 XML,用copy.deepcopy复制现有 run 节点,只改文本和颜色属性。这样所有命名空间、属性顺序都是 Word 自己生成的,改坏风险极低。如果你必须从零创建 run,也要用OxmlElement逐个元素按标准顺序拼,不要用字符串拼接 XML。

from docx.oxml import OxmlElement from docx.oxml.ns import qn def make_run_element(text): r = OxmlElement("w:r") r_pr = OxmlElement("w:rPr") r.append(r_pr) t = OxmlElement("w:t") t.text = text t.set(qn("xml:space"), "preserve") r.append(t) return r

5.2 现象:关键词表里有 C++,程序直接抛异常

正则报错,missing )之类。原因很直接:关键词被当成正则表达式解析了,C++里的+是“匹配一次或多次”的元字符,待定(TBD)里的括号是分组语法。

解决方式是统一对每个关键词调用re.escape。这个函数会把所有正则元字符转义成普通字符,让关键词原样匹配文本。不要只在遇到报错时补救,建议一开始就把所有词都转义,省得后面加了新词又踩雷。

escaped = [re.escape(kw) for kw in keywords] pattern = re.compile("|".join(escaped))

5.3 现象:表格里的关键词一个都没标上

正文标得漂漂亮亮,表格里全是漏网的。原因在第 3.3 章已经提过:doc.paragraphs不包含表格内容,表格段落是独立的树结构。

解决方式是显式遍历doc.tables的每个单元格。这里有个隐藏坑是合并单元格会被多次遍历到,所以用seen_cells按id去重。如果文档还有嵌套表格,还得递归遍历cell.tables,一层层往下钻。

def iter_cell_paragraphs(cell, seen): for p in cell.paragraphs: yield p for t in cell.tables: for row in t.rows: for c in row.cells: if id(c) in seen: continue seen.add(id(c)) yield from iter_cell_paragraphs(c, seen)

5.4 现象:标注后原字体被带跑

变色之后字体变了,有的 run 变得比原来大一圈,有的中文字体变成了默认等线。原因是在拆分 run 时新建的节点没有复制原节点的rPr,或者复制了但字体语言属性丢失。

解决方式是坚持用copy.deepcopy基于原 run 节点复制,而不是从零创建。这样字体、字号、语言、字符间距全部保留,只覆盖颜色值。如果你复制了节点还是出现字体问题,检查一下脚本里是不是有直接操作rPr删除属性的代码,比如rPr.fonts.clear()这种。

5.5 现象:几百页文档跑了几分钟还没结束

关键词几百个,文档几百页,脚本跑起来像死机。原因是每处理一个段落都要重新编译多个正则,或者用str.replace循环替换一大串词,复杂度直接翻了几番。

解决方式是把所有关键词合并成一个正则,一次编译,全程复用。另一个容易被忽视的性能点是:每插入一个 run 就触发一次 XML 节点的复制和写入,插入越多性能越低。如果你只需要统计出现次数而不需要逐段标注,先跑一遍统计脚本,确认哪些 run 真正需要拆分,避免对无关文本做大量无谓的 XML 操作。

6. 进阶:给标注结果做复查、分类配色与“后悔药”

脚本跑完不代表工作结束,我现在的习惯是永远跑两遍:第一遍标注,第二遍复查。复查不靠肉眼,用脚本统计每个关键词实际标了几处,跟预期对比,数量对不上就说明有词漏标,直接定位到段落去修。

from docx import Document from docx.shared import RGBColor doc = Document("output.docx") target_color = RGBColor(0xFF, 0x00, 0x00) count = 0 def count_paragraph(p): global count for r in p.runs: if r.font.color and r.font.color.rgb == target_color: count += 1 for p in doc.paragraphs: count_paragraph(p) for table in doc.tables: for row in table.rows: for cell in row.cells: for p in cell.paragraphs: count_paragraph(p) print("红色标注 run 数量:", count)

这个复查脚本的价值是把“标没标对”变成可量化的数字。你还可以按关键词维度统计,把每个词命中的段落号输出成一个清单,给合作方提供核对依据,顺便证明脚本质量。

分类配色是我在长文档项目里最常用的技巧。合同里“付款节点”用红色、“违约责任”用橙色、“验收标准”用高亮黄,不同类别不同颜色,业务方一眼就能分清文档里哪些板块对应哪些风险。实现上只要把关键词分组,每组一个正则,按组依次调用标注函数,颜色参数传不同值即可。

groups = { "付款节点": (["付款节点", "支付", "定金"], RGBColor(0xFF, 0x00, 0x00)), "违约": (["违约金", "违约责任", "逾期"], RGBColor(0xFF, 0xA5, 0x00)), "验收": (["验收标准", "验收报告"], RGBColor(0xFF, 0xFF, 0x00)), }

最后一件事,也是我这几年最深的教训:永远保留原件。脚本生成的“标注版”命名成新文件,不直接覆盖source.docx。一旦业务方说“颜色太深看不清”“我们不要加粗”,你还能直接从原件重新生成,不用费劲去反操作颜色属性。归档习惯是:source.docx是唯一原始输入,annotated_20240612.docx是当次标注输出,中间产物定期清理。重复跑脚本时,永远从原件出发,不要拿上一轮的标注版当输入,否则已经上色的 run 会被再次拆分,文件越跑越大,颜色越叠越乱。这个教训帮我避免了好几次返工。

希望这一整套流程能帮到正在跟 Word 文档较劲的你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询