批量docx关键词标注:深入XML拆分run与高亮实现
简介一份面向docx文档关键词标注的Python小工具包适合需要批量处理Word文档、提取或标记实体信息的开发与办公人员使用。核心脚本docxTagging.py借助python-docx实现关键词查找与替换主函数内置两种可直接运行的示例一是检测document.docx中的Hello和World并替换为默认标注格式二是对照同目录下中国地名CSV词表将命中地名写入新文档使用者只需修改词表或替换规则即可适配自己的标注需求。资源共4个文件涵盖Python脚本、示例Word文档、全国地名数据CSV和Markdown说明文档压缩包整体仅23KB轻量且目录清晰。虽然作者自述对docx底层运行机制理解有限但脚本结构简单、注释与说明齐全适合作为文档自动化处理的入门参考。目前已有359人学习下载对于需要快速完成关键词定位、标注或词表匹配的读者这份小工具能节省不少手工操作时间。1. 批量给 docx 文档标注关键词为什么必须碰 XML批量给 docx 文档标注关键词第一反应是遍历段落、替换文本。真做起来才会发现Word 在保存和复制时会把一个词拆进两三个 run按 run 匹配要么漏标要么直接把格式改坏。docx 本质是个 zip 包正文存在 word/document.xml 的 w:t 节点里所以关键词标注最终要回到 XML 层走“合并文本→定位命中区间→拆分 run 注入样式”这条路。这篇文章会拆开 docx 的内部结构说清 python-docx 的边界和 lxml 直操作的方式并给出一个可直接跑的高亮脚本。适合批量处理 .docx 的开发者、知识库工具作者以及不想再人工核对几十份文档标注结果的人。2. 拆开 docx从 zip 包到 document.xml 的存储真相在动代码之前先把 docx 当黑匣子拆开看一眼。很多人在 run 层级折腾半天没结果就是没搞明白 docx 里面到底存了什么。2.1 解压看结构正文藏在哪些节点里docx 不是纯文本文件也不是单个文档对象它就是一个 zip 压缩包。用命令行解压就能看到全部东西我一般先建一个临时目录再解开mkdir docx_unzip cd docx_unzip unzip ../target.docx ls -R解压后核心文件是word/document.xml这里面才是正文word/media/下是图片word/header*.xml和word/footer*.xml是页眉页脚。打开 document.xml 会看到类似这样的结构w:p w:r w:rPr w:b/ /w:rPr w:t深度学习的模型/w:t /w:r w:r w:t训练流程/w:t /w:r /w:p结构分三层w:p是一个段落Paragraph段落里可以有多个w:rRun相当于带同一组样式的一段文本每个w:r里面是w:t实际文本内容。样式信息比如加粗、字体大小都存在w:rPr里。所以当 python-docx 告诉你某个 Paragraph 有 3 个 Run其实就是这个段落里有 3 个w:r节点。关键词标注要改的就是某个w:r下的w:t以及w:rPr。2.2 run 的粒度陷阱Word 为什么把你的关键词拆成两半这里有一个最容易被忽略的坑你在 Word 里看到的“深度学习”四个字在 document.xml 里可能不是一个w:t而是被拆成了w:t“深度”w:t“学习的模型训练流程”甚至一个字符一个 run。这个现象不是偶然。Word 在编辑、复制粘贴、修订、拼写检查时都会重新切分 run切分规则完全由 Word 内部决定。有次我在某个项目的文档里统计关键词看到 paragraph.text 是“数据增强”但 paragraph.runs 输出的是[数据, 增强]单看 run 列表根本匹配不到完整词。这种“文本完整但 run 被切碎”的情况就是 run 级操作最大的坑直接在 run 文本里做 replace 或正则匹配永远命中不了跨 run 的关键词。所以做关键词标注第一步要跳出 run 的粒度假象把段落内所有w:t合并成完整文本去匹配再回到 XML 节点上做拆分。2.3 遍历正文python-docx 读段落的入口代码用 python-docx 读正文很简单先跑通这段把文档模型摸清楚from docx import Document doc Document(target.docx) for i, para in enumerate(doc.paragraphs): print(i, repr(para.text)) for j, run in enumerate(para.runs): print( run, j, repr(run.text))Document(target.docx)是打开入口文件路径换成你的实际文件。doc.paragraphs返回文档 body 里最顶层的段落注意它不管表格、页眉、页脚里的内容。para.text是合并后的段落文本para.runs是 python-docx 解析出来的 run 列表run.text是单个 run 的文本。跑一下就会发现问题para.text能拼出完整句子但para.runs里的文本经常是碎块。这正是第 2.2 节说的 run 粒度陷阱。到这一步你已经可以看到为什么后面所有标注逻辑都要在w:t层面做而不是在 run 层面做。3. 两条修改路线的取舍python-docx API 与 lxml 直操作python-docx 提供了比较高层的 API可以直接改 run 样式但它管得太宽遇到跨 run 匹配就无能为力。lxml 直操作 document.xml 很底层却能精确控制在哪个字符位置注入什么样式。实战里通常是两条路线结合着用。3.1 用 python-docx 给 run 上高亮能改但改不到点子上如果你想给“某个 run 的全部文本”上高亮python-docx 够用。下面这段给第 0 段第 0 个 run 加黄色高亮from docx import Document from docx.oxml.ns import qn from docx.oxml import OxmlElement doc Document(target.docx) run doc.paragraphs[0].runs[0] rPr run._r.get_or_add_rPr() hl rPr.find(qn(w:highlight)) if hl is None: hl OxmlElement(w:highlight) rPr.append(hl) hl.set(qn(w:val), yellow) doc.save(out.docx)关键在w:highlight这个 XML 元素它是 Word 用来表示高亮底色的东西w:val的值就是颜色名。这段代码能跑但局限性很明显它只能给完整 run 上色。如果关键词正好被拆在两个 run 里这里匹配不到如果一个 run 里有多个关键词你只能把整个 run 涂黄无法精确定位到其中某几个词。所以结论是python-docx 适合“整 run 改样式”的粗粒度操作关键词标注这种“文本片段级”的操作必须拿到 XML 节点层做。3.2 lxml 直操作 w:t能精确改动但要注意边界lxml 直操作指的是绕过 python-docx 的 Run 对象直接拿到w:t节点改文本。每个 Paragraph 对象内部都有一个_p对应 document.xml 里的w:p元素可以用 xpath 抽所有w:tfrom docx import Document from docx.oxml.ns import qn doc Document(target.docx) p doc.paragraphs[0]._p for t in p.xpath(.//w:t): if t.text and 深度学习 in t.text: t.text t.text.replace(深度学习, [深度学习]) doc.save(out.docx)这段能改文本但有两个问题一是把正文内容污染了插入了中括号二是没有给命中词加高亮样式。更重要的是w:t节点被直接 replace 后如果关键词横跨两个节点这段代码依然抓不到。直接改文本只适合做替换不适合做样式标注。3.3 最小高亮函数把 rPr 的修改封装成工具不管用哪种路线最后都会落在一个动作上给某个w:r增加高亮rPr。把它封装成一个函数后面所有逻辑都会用到from docx.oxml.ns import qn from docx.oxml import OxmlElement def set_highlight_on_run(r, coloryellow): rPr r.find(qn(w:rPr)) if rPr is None: rPr OxmlElement(w:rPr) r.insert(0, rPr) hl rPr.find(qn(w:highlight)) if hl is None: hl OxmlElement(w:highlight) rPr.append(hl) hl.set(qn(w:val), color)这个函数有两个要点。第一个要点是rPr必须放在w:r的第一个位置所以不存在时用insert(0, rPr)第二个要点是w:highlight的w:val只认 Word 预设的颜色名常见值有yellow、green、cyan、red、blue、magenta、darkRed、darkBlue传别的值 Word 打开时可能直接忽略。后面实战脚本就是基于这个函数做的。4. 关键词标注实战跨 run 文本合并与样式注入4.1 核心算法先合并文本再按字符位置回溯节点正确做法是把段落的文本合并成一个字符串在上面跑正则找到关键词位置然后根据每个字符来自哪个w:t定位到具体节点做拆分。整体思路分三步把段落内所有w:t的文本按顺序拼接成full_text同时记住每个字符属于哪个节点、在节点内的偏移。在full_text上跑关键词匹配得到(start, end)区间。把区间内的每个字符映射回节点确定命中部分覆盖了哪些w:t再逐个节点处理。这里“字符位置回溯”是核心等价于做一个字符到节点的索引表nodes p.xpath(.//w:t) pos_to_node [] # 每个位置对应 (node, 节点内偏移) for node in nodes: text node.text or for i in range(len(text)): pos_to_node.append((node, i))如果full_text[10:14]是关键词“深度学习”那这 4 个字符可能来自同一个w:t也可能来自两个相邻的w:t。只要有了pos_to_node这个映射命中区间覆盖哪些节点、每个节点内要保留哪些字符就都变成可计算的了。4.2 完整标注脚本复制样式、拆分 run、注入高亮下面这个脚本可以直接抄它解决跨 run 匹配和拆分 run 后样式丢失两个问题import re from copy import deepcopy from docx import Document from docx.oxml.ns import qn from docx.oxml import OxmlElement def set_highlight_on_run(r, coloryellow): rPr r.find(qn(w:rPr)) if rPr is None: rPr OxmlElement(w:rPr) r.insert(0, rPr) hl rPr.find(qn(w:highlight)) if hl is None: hl OxmlElement(w:highlight) rPr.append(hl) hl.set(qn(w:val), color) def make_run_like(r, text, colorNone): 复制 r 的样式换成新文本 text。color 不为空时加高亮。 new_r deepcopy(r) for child in list(new_r): if child.tag ! qn(w:rPr): new_r.remove(child) t OxmlElement(w:t) t.text text if text ! text.strip(): t.set({http://www.w3.org/XML/1998/namespace}space, preserve) new_r.append(t) if color: set_highlight_on_run(new_r, color) return new_r def apply_one_match(p, start, end, color): nodes p.xpath(.//w:t) pos_to_node [] for node in nodes: text node.text or for i in range(len(text)): pos_to_node.append((node, i)) if end len(pos_to_node): return # 按节点分组统计每个节点中命中的字符 groups [] cur_node None cur_group [] for node, idx in pos_to_node[start:end]: if node is not cur_node: if cur_node is not None: groups.append((cur_node, cur_group)) cur_node node cur_group [] cur_group.append((node, idx)) if cur_node is not None: groups.append((cur_node, cur_group)) for node, char_indices in groups: text node.text or left char_indices[0][1] right char_indices[-1][1] r node.getparent() # 整个 w:t 正好就是关键词不用拆直接加高亮 if left 0 and right len(text) - 1: set_highlight_on_run(r, color) else: before text[:left] hit text[left:right 1] after text[right 1:] node.text before new_r make_run_like(r, hit, color) r.addnext(new_r) if after: after_r make_run_like(r, after) new_r.addnext(after_r) def highlight_in_paragraph(paragraph, keyword, coloryellow): p paragraph._p pattern re.compile(re.escape(keyword)) full_text .join((n.text or ) for n in p.xpath(.//w:t)) for m in pattern.finditer(full_text): start, end m.span() if start end: apply_one_match(p, start, end, color)用的时候很简单doc Document(target.docx) for para in doc.paragraphs: highlight_in_paragraph(para, 深度学习) doc.save(out.docx)几个关键参数说明color只接受 Word 高亮颜色名比如yellow、cyan、green、redkeyword是要标注的词默认走re.escape所以带、*、括号的关键词不会被当成正则语法。make_run_like里的xml:spacepreserve是给英文关键词留的如果w:t文本带首尾空格不加这个属性Word 打开时会把空格吞掉。这段脚本还处理了多关键词重叠的情况每处理完一个匹配重新通过p.xpath(.//w:t)拉取节点列表因为拆分 run 会改变节点数量但合并后的文本内容不变所以下一个匹配的坐标依然成立。4.3 中英文关键词的匹配差异与颜色参数上面的脚本对中英文关键词都能匹配但实际使用时有差别。中文没有词边界直接按字面匹配即可所以re.escape(keyword)就是最优解。英文需要词边界否则“data”会匹配进“database”“metadata”里一般会加\bimport re def build_pattern(keyword, langzh): if lang zh: return re.compile(re.escape(keyword)) return re.compile(r\b re.escape(keyword) r\b)langen时会在关键词前后加\b词边界。注意\b对含连字符的英文词无效比如state-of-the-art这种连字符本身就不是词字符\b的边界逻辑会变得很怪这种情况直接用字面匹配更省心。颜色参数方面如果需要按关键词分类标注可以传不同颜色合同条款标黄色公司名标青色金额数字标绿色。Word 的w:highlight值就是颜色名在highlight_in_paragraph(para, 某关键词, colorcyan)里改即可。颜色名传错文档能打开但高亮不会生效这是 Word 的 schema 限制没得绕。5. 标注 docx 必踩的五个坑从跨 run 丢高亮到样式丢失这章是把我在不同文档上跑关键词标注脚本时翻过的车集中列出来每条都是“现象→原因→解决”的结构遇到类似问题可以直接对照。5.1 关键词串到两个 run 里高亮“丢了一半”现象跑完脚本打开文档发现“深度学习”标出来了但“训练流程”没标而这四个字明明在正文里。原因Word 保存文档时把“训练流程”拆成了w:t“训练”和w:t“流程”在 run 层级做匹配时任何单个 run 都不包含完整关键词自然匹配不上。解决不要遍历 run 做匹配按第 4 章的方案先把段落所有w:t合并成完整文本在合并文本上跑正则再按字符位置回溯节点。核心就是apply_one_match里的pos_to_node映射它能处理任意跨 run 的情况。5.2 括号、星号把正则带偏现象关键词是C或(一)脚本在re.compile这行直接报错或者标注出来的位置不对。原因、(、)在正则里都是元字符直接拼进 pattern 会被当成正则语法解析。解决所有关键词都跑一遍re.escape(keyword)脚本里已经处理了。如果是在自己的代码里现写正则别忘了这一步如果关键词本身是用户输入的更要在入口处强制转义不然 SaaS 化部署直接变成正则注入漏洞。5.3 表格、页眉页脚里的关键词漏标现象正文关键词都标好了但表格里的数据和页眉页脚的标题没标。原因doc.paragraphs只返回 body 中最顶层的段落表格单元格里的段落不在列表中页眉页脚是独立的 XML 文件python-docx 也不把它们算进doc.paragraphs。解决手动扩展遍历范围把所有段落收集到一起all_paras list(doc.paragraphs) for table in doc.tables: for row in table.rows: for cell in row.cells: all_paras.extend(cell.paragraphs) for section in doc.sections: all_paras.extend(section.header.paragraphs) all_paras.extend(section.footer.paragraphs) for para in all_paras: highlight_in_paragraph(para, 深度学习)注意表格嵌套时cell.tables里还会有更深层的表格需要递归或循环展开这里贴的是一层表格的写法。5.4 拆分 run 后字号加粗全没了现象标注后的文档关键词确实高亮了但整段文字字体变成了默认字体加粗变细字号也变了。原因拆分 run 时粗暴地OxmlElement(w:r)新建了一个 run里面没有rPr或者没有复制原 run 的rPrWord 只能按默认样式渲染。解决用第 4.2 节的make_run_like它先deepcopy原 run再删掉原来所有内容节点只保留rPr和新的w:t。这样字体、字号、加粗、下划线、颜色这些样式全部继承只是在命中片段上额外叠加高亮。5.5 没做文本完整性校验标注完正文少了字现象脚本跑完后 docx 能打开但某一段落变短了或者关键词附近出现文字重复。原因拆分 run 时 before、hit、after 三段文本拼接顺序出错或者某个节点文本被直接覆盖导致原文丢失。解决保存前强制跑一遍文本完整性校验把原文档和标注后文档的所有文本提取出来对比只要有一个字符对不上就立刻停止。提取脚本放在第 6.2 节这个习惯我一直保留到现在。6. 进阶把高亮做成审阅效果并在保存前做回归校验6.1 高亮之外的其他标注方式高亮只是关键词标注的最小实现实际项目里往往还要区分标注类型。不同标注方式的实现差异如下标注方式实现途径适合场景高亮底色w:highlight设置 val阅读时快速定位最常见加粗w:rPr下加w:b/打印文档中的重点字体颜色w:rPr下加w:color的 val多关键词分类下划线w:rPr下加w:u的 val审阅修改场景批注commentRangeStart/End联动需要解释为什么标这个词w:b、w:color、w:u都可以在make_run_like的样式继承基础上追加改起来不复杂核心还是那套拆分 run 的逻辑。批注是最折腾的它需要维护w:comment列表、commentRangeStart、commentRangeEnd、commentReference四处联动而且 python-docx 没有公开 API纯拼 XML 很容易翻车。我一般不做批注而是生成一份“关键词上下文对照表”把关键词在原文中的前后各 20 个字一起导出审校体验反而更直观。6.2 保存前的文本完整性回归脚本标注脚本改完文档最怕的就是正文被悄悄改坏。我每做一个批量文档脚本最后一步都会跑这个校验from docx import Document def extract_all_text(path): doc Document(path) chunks [] for para in doc.paragraphs: chunks.append(para.text) for table in doc.tables: for row in table.rows: for cell in row.cells: for para in cell.paragraphs: chunks.append(para.text) for section in doc.sections: chunks.extend(p.text for p in section.header.paragraphs) chunks.extend(p.text for p in section.footer.paragraphs) return .join(chunks) before extract_all_text(target.docx) after extract_all_text(out.docx) assert before after, 文本不一致标注脚本可能破坏了正文 print(文本完整性校验通过)extract_all_text把正文、表格、页眉页脚全部拼成一个长字符串只比文本不比格式。只要标注逻辑只加高亮不碰文本这个断言一定能过。如果断言失败优先检查make_run_like里 before、hit、after 三段有没有拼接顺序错误。另外建议把这个校验做成独立脚本不要和标注脚本耦合在一起。标注跑完立刻校验校验通过再用 Word 或 WPS 打开抽查一页。从那以后我每做一个批量文档脚本都会强制走一遍“标注→校验→抽查”的流程省掉了大量返工。希望帮到你。本文还有配套的精品资源点击获取