.docx 本质上是一个 ZIP 压缩包,用 lxml 直接读写其中的 word/document.xml,就能获得比 python-docx 更精细的控制力。本文从文件结构、核心元素模型、最小可运行代码到常见场景,尝试建立一套可复用的底层操作框架。
1. 先理解.docx的物理结构
.docx 不是二进制黑盒,而是遵循 ECMA-376 / ISO/IEC 29500 标准(标准详情可参阅ECMA-376-1:2016 - Part 1)的 ZIP 包 。用 zipfile 打开后,真正需要关心的核心部件通常只有这几个:
| 路径 | 作用 |
|---|---|
word/document.xml |
主文档内容(段落、文本、表格等) |
word/styles.xml |
样式定义 |
word/numbering.xml |
编号与项目符号规则 |
word/settings.xml |
文档级设置 |
word/_rels/document.xml.rels |
关系映射(图片、超链接等资源的 rId 绑定) |
如果只需要提取或修改正文文字与结构,九成以上的操作集中在 word/document.xml。
2. WordprocessingML 的核心元素模型
Word 的 XML 结构是扁平的、层级分明的。一个最简文档的路径是:
text
w:document → w:body → w:p(段落)→ w:r(运行块)→ w:t(文本范围)
关键理解点:
w:p是段落 ,对应你在Word里按一次回车产生的一个段落。w:r是"运行块" ,是一小段具有相同格式的连续内容。它的存在是因为OpenXML不允许像HTML那样任意嵌套格式标签。如果一句话里"这是"是常规,"三"加粗,"片"倾斜,那这句话在XML里至少被拆成三个w:r。w:t是纯文本 ,里面只放文字,不放格式。格式信息放在w:r的w:rPr(运行属性)或w:p的w:pPr(段落属性)里 。
命名空间是 lxml 操作的第一个门槛。主命名空间固定为:
python
W = "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}"
用 lxml 的 XPath 时,需要以字典形式传入命名空间前缀映射:
python
ns = {"w": "http://schemas.openxmlformats.org/wordprocessingml/2006/main"}
3. 最小可运行框架:读 → 改 → 写
下面这段代码展示了从打开 .docx、解析 document.xml,到修改后重新打包的完整闭环。注意:修改后必须把整个 ZIP 包重新组装,不能只替换单个文件。
python
import zipfile
import shutil
from lxml import etree
W = "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}"
NS = {"w": "http://schemas.openxmlformats.org/wordprocessingml/2006/main"}
def open_docx(path):
"""读取 docx 中的 document.xml,返回 lxml 根元素和原始 zip 文件对象"""
with zipfile.ZipFile(path, "r") as z:
xml_bytes = z.read("word/document.xml")
root = etree.fromstring(xml_bytes)
return root
def save_docx(original_path, output_path, new_root):
"""用修改后的 document.xml 替换原文件,生成新 docx"""
# 可选:先把原始 docx 复制一份
shutil.copy(original_path, output_path)
# 准备新的 document.xml 字节
new_xml = etree.tostring(new_root, xml_declaration=True, encoding="UTF-8", standalone=True)
# 打开副本(未复制副本则打开原始文件),把新 XML 写进去(zipfile 不支持原地替换,需要重建)
with zipfile.ZipFile(output_path, "r") as zin:
items = [(item, zin.read(item.filename)) for item in zin.infolist()]
# 重建目标文件,重建过程中替换掉修改过的xml文件
with zipfile.ZipFile(output_path, "w", zipfile.ZIP_DEFLATED) as zout:
for item, data in items:
if item.filename == "word/document.xml":
zout.writestr(item, new_xml)
else:
zout.writestr(item, data)
# 使用示例
root = open_docx("input.docx")
# ... 在这里修改 root ...
save_docx("input.docx", "output.docx", root)
4. 几个 lxml 操作场景示例
场景一:提取所有段落文本(跳过格式)
python
paragraphs = root.xpath("//w:p", namespaces=NS)
for p in paragraphs:
texts = p.xpath(".//w:t/text()", namespaces=NS)
line = "".join(texts)
if line.strip():
print(line)
场景二:只提取特定样式的段落(例如标题 1)
Word 的标题样式通过 w:pPr/w:pStyle 的 w:val 属性标识。用 XPath 可以精确过滤:
python
headings = root.xpath(
"//w:p[w:pPr/w:pStyle[@w:val='Heading1']]",
namespaces=NS
)
for h in headings:
text = "".join(h.xpath(".//w:t/text()", namespaces=NS))
print("标题:", text)
场景三:修改某段文字(保留格式)
不要直接改 w:t 的父级 w:r,因为一个 w:r 里可能有多个 w:t(虽然少见)。更稳妥的做法是定位到目标 w:t 后替换其 .text:
python
# 把所有包含"旧关键词"的文本节点替换为"新关键词"
for t in root.xpath("//w:t[contains(text(), '旧关键词')]", namespaces=NS):
t.text = t.text.replace("旧关键词", "新关键词")
注意:如果文本被拆散在多个 w:r 里(w:r定义了具有共同属性集的文本区域,因此,如果一段文本有多个具有不同属性的几个部分(例如文字格式),Word将它就会拆成多个w:r),这种简单替换会失效。此时需要先做"运行块合并"的预处理,或者用更复杂的跨节点替换逻辑。
场景四:插入一个新段落
用 etree.SubElement 按正确的层级构造。下面在 body 末尾追加一个普通段落:
python
body = root.find(f"{W}body")
new_p = etree.SubElement(body, f"{W}p")
new_r = etree.SubElement(new_p, f"{W}r")
new_t = etree.SubElement(new_r, f"{W}t")
new_t.text = "这是通过 lxml 插入的新段落"
如果需要在特定段落之后插入,先定位到那个 w:p,再用 addnext() 或 addprevious()。
5. 几个容易踩的坑
-
命名空间前缀只是"别名"。 你在 XPath 里写
w:p,w只是你起的名字,真正匹配的是那个长 URI。Word 文件里的前缀可能被其他工具改成别的,所以永远用 URI 匹配,不要硬编码前缀字符串。 -
w:t的属性xml:space="preserve"。 如果文本首尾有空格,必须给w:t加上xml:space="preserve",否则 Word 渲染时会吞掉空格 。 -
修改后必须重新计算关系引用。 如果你在
document.xml里插入了图片或超链接,还需要同步修改word/_rels/document.xml.rels,否则 Word 打开时会报"内容不可读"。 -
不要试图用
python-docx和lxml混改同一个文件。python-docx自己维护了一套元素包装层,你直接改_element后它可能感知不到。底层操作就纯粹用zipfile + lxml,保持路径清晰。
XPath查询语法简要介绍
XPath(XML Path Language)是一种在 XML 文档中查找信息的语言。你可以把它类比为文件系统中的"路径",只不过它是针对 XML 节点的"路径"。在处理 OpenXML(如 Word)时,XPath 是非常重要的核心工具。以下是其查询语法的简要介绍。
1. 基础路径语法 (Basic Paths)
| 表达式 | 说明 | 示例 |
|---|---|---|
/ |
从根节点开始,选择绝对路径。 | /w:document (选择根元素) |
// |
相对路径,查找文档中任何位置符合条件的节点(后代节点)。 | //w:p (查找文档中所有的段落) |
. |
当前节点。 | ./w:r (查找当前节点下的子节点) |
.. |
父节点。 | //w:t/.. (找到文本节点所属的 Run 节点) |
@ |
选择属性。 | //w:tc/@w:val (选择单元格属性中的 val) |
* |
通配符,匹配任何元素。 | //w:p/* (匹配段落下的所有子元素) |
2. 谓语/过滤器 (Predicates \[\])
谓语用于对节点进行筛选,语法是 条件。
| 语法类型 | 描述 | 示例 |
|---|---|---|
| 位置索引 | 选择第 N 个节点(注意:XPath 索引从 1 开始)。 |
//w:tr[1] (第一行) |
| 属性过滤 | 根据属性值进行筛选。 | //w:tc[@w:val='1'] |
| 文本过滤 | 根据节点包含的文本进行筛选。 | //w:t[text()='你好'] |
| 逻辑组合 | 使用 and, or, not 进行组合。 |
//w:p[w:r and @id='1'] |
| 存在性检查 | 检查是否存在某个子节点。 | //w:p[w:r] (包含 Run 的段落) |
3. 轴 (Axes) ------ 进阶导航
"轴"定义了当前节点与目标节点之间的层级关系方向。这是处理复杂 OpenXML 结构(如"根据文字找表格")的关键。
| 轴名称 | 说明 | 应用场景 |
|---|---|---|
child:: |
选择当前节点的直接子节点(默认)。 | //w:p/child::w:r |
parent:: |
选择当前节点的父节点。 | //w:t/parent::w:r |
ancestor:: |
选择当前节点的所有祖先节点(父、父的父...)。 | //w:t[contains(text(), '目标')]/ancestor::w:tbl (找文字所在的表格) |
descendant:: |
选择当前节点的所有后代节点(子、孙...)。 | //w:tbl/descendant::w:t (找表格内所有文字) |
following-sibling:: |
选择当前节点之后同级的节点。 | following-sibling::w:p[1]:紧邻的下一个段落; following-sibling::w:p:所有的后续段落 |
preceding-sibling:: |
选择当前节点之前同级的节点。 | preceding-sibling::w:p[1] :紧邻的上一个段落; preceding-sibling::w:p:所有的前置段落 |
4. 内置函数 (Functions)
在谓语中使用函数可以实现更复杂的逻辑。
| 函数 | 说明 | 示例 |
|---|---|---|
contains(haystack, needle) |
检查字符串是否包含子串(模糊匹配)。 | //w:t[contains(text(), '目标')] 匹配包含"目标"二字的文本。 |
starts-with(string, prefix) |
检查字符串是否以某值开头。 | //w:r[starts-with(@w:val, 'abc')] |
text() |
获取节点内的文本内容。 | //w:t/text() 返回该节点下的文本内容(//w:t 返回的是元素节点) |
count(path) |
返回节点数量。 | //w:p[count(w:r) > 5] (查找包含超过5个 Run 的段落) |
XPath 处理 Word 的 XML典型查询案例
场景 1:我想找到所有的段落,并提取它们的内容。
//w:p → \rightarrow → 选中所有 <w:p> 标签。
//w:p//w:t/text() → \rightarrow →选中所有段落里的纯文本。
场景 2:我想找到包含"合同编号"这四个字的那个单元格 (<w:tc>)。
//w:t[contains(text(), '合同编号')]/ancestor::w:tc
解析:先定位文本节点 → \rightarrow → 向上溯源直到找到最近的单元格节点。
场景 3:我想找到所有没有设置"跨列"属性的单元格。
//w:tc[not(w:tcPr/w:gridSpan)]
解析:定位单元格 → \rightarrow → 判断其属性层级下是否存在 gridSpan 节点 → \rightarrow → 取反。
场景 4:我想找到某个特定 ID 的 Run 节点。
//w:r[@w:id='12345']
综合实例:输出与Word文档表格结构完全相同的HTML表格代码
python
import zipfile
from lxml import etree
import re
# 定义命名空间常量
# W 用于直接通过标签名进行比较,例如 tag == f'{W}p'
W = '{http://schemas.openxmlformats.org/wordprocessingml/2006/main}'
# NS 用于 XPath 查询,例如 root.xpath('.//w:t', namespaces=NS)
NS = {'w': 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'}
def load_document_xml(docx_path):
"""
打开 .docx 压缩包,读取并解析核心内容文件 word/document.xml
"""
with zipfile.ZipFile(docx_path, 'r') as z:
# docx 本质是 zip,document.xml 存储了所有的正文 XML 数据
return etree.fromstring(z.read('word/document.xml'))
def paragraph_text(p):
"""
从一个段落节点 <w:p> 中提取所有文本内容
通过 XPath 查找该段落下所有的 <w:t> (Text) 节点并拼接
"""
return ''.join(p.xpath('.//w:t/text()', namespaces=NS))
def cell_content(tc):
"""
遍历单元格 <w:tc> 的直接子元素:
- 如果是 <w:p> (段落) -> 提取纯文本
- 如果是 <w:tbl> (表格) -> 递归调用 table_to_html 进行嵌套转换
按文档原始顺序拼接,并用 <br> 分隔,确保单元格内的布局逻辑
"""
parts = []
for child in tc:
tag = child.tag
if tag == f'{W}p': # 匹配命名空间下的段落标签
txt = paragraph_text(child)
if txt.strip(): # 忽略纯空白段落
parts.append(txt)
elif tag == f'{W}tbl': # 匹配命名空间下的嵌套表格标签
nested_html = table_to_html(child)
if nested_html:
parts.append(nested_html)
return '<br>'.join(parts)
def table_to_html(tbl):
"""
核心函数:将 Word 的 <w:tbl> 结构转换为 HTML <table> 结构。
难点在于处理:
1. Colspan (跨列): 由 <w:gridSpan> 控制
2. Rowspan (跨行): 由 <w:vMerge> (垂直合并) 控制
"""
# 获取当前表格所有的行 <w:tr>
rows = tbl.xpath('./w:tr', namespaces=NS)
if not rows:
return ''
grid = [] # 用于存储二维表格结构的列表
vmerge_open = {} # 记录正在进行的"跨行合并"单元格信息
for tr in rows:
cells = tr.xpath('./w:tc', namespaces=NS)
c_idx = 0 # 当前处理到的逻辑列索引
row_cells = [] # 存储当前行处理后的单元格对象
for tc in cells:
# 自动跳过由于上一行 rowspan 导致的逻辑占位符
while c_idx < len(row_cells) and row_cells[c_idx] is not None:
c_idx += 1
# --- 处理 Colspan (跨列) ---
grid_span_el = tc.find(f'{W}tcPr/{W}gridSpan')
colspan = int(grid_span_el.get(f'{W}val')) if grid_span_el is not None else 1
# --- 处理 Rowspan (跨行/垂直合并) ---
# Word 使用 vMerge 属性:"restart" 表示合并开始,"continue" 表示合并继续
vmerge_el = tc.find(f'{W}tcPr/{W}vMerge')
vmerge_val = vmerge_el.get(f'{W}val') if vmerge_el is not None else None
text = cell_content(tc)
# 情况 A: 跨行合并的起始单元格
if vmerge_val == 'restart':
cell = {'text': text, 'colspan': colspan, 'rowspan': 1}
# 记录这个单元格,以便后续行通过 "continue" 找到它并增加 rowspan
for k in range(colspan):
vmerge_open[c_idx + k] = cell
# 情况 B: 跨行合并的后续单元格 (continue)
elif vmerge_val == 'continue' or (vmerge_el is not None and vmerge_val is None):
start_cell = vmerge_open.get(c_idx)
if start_cell is not None:
start_cell['rowspan'] += 1 # 更新起始单元格的行数
cell = None # 当前单元格在 HTML 中不重复渲染
else:
cell = {'text': text, 'colspan': colspan, 'rowspan': 1}
# 情况 C: 普通单元格
else:
cell = {'text': text, 'colspan': colspan, 'rowspan': 1}
# 将处理好的单元格放入逻辑行 grid 中
for k in range(colspan):
# 动态扩展 row_cells 长度以匹配 colspan
if c_idx + k >= len(row_cells):
row_cells.extend([None] * (c_idx + k - len(row_cells) + 1))
# 只有第一个 index 存放 cell 对象,后续 colspan 占位设为 None
row_cells[c_idx + k] = cell if k == 0 else None
c_idx += colspan
grid.append(row_cells)
# --- 渲染 HTML 字符串 ---
html = ['<table border="1" style="border-collapse:collapse;">']
for row_cells in grid:
html.append('<tr>')
for cell in row_cells:
if cell is None: # 跳过被 rowspan 占用的位置
continue
attrs = ''
if cell['colspan'] > 1:
attrs += f' colspan="{cell["colspan"]}"'
if cell['rowspan'] > 1:
attrs += f' rowspan="{cell["rowspan"]}"'
html.append(f'<td{attrs}>{cell["text"]}</td>')
html.append('</tr>')
html.append('</table>')
return '\n'.join(html)
def find_table_by_text_and_print_html(docx_path, target_text):
"""
查找包含特定文本的【顶层】表格,并打印其 HTML 结构
"""
try:
root = load_document_xml(docx_path)
except Exception as e:
print(f"读取文档失败: {e}。")
return 0
# 使用 XPath 查找所有表格,但排除掉那些作为子表格嵌套在其他表格里的表格
# 逻辑:找到 <w:tbl>,且它的祖先节点中不包含 <w:tbl>
top_tables = root.xpath('//w:tbl[not(ancestor::w:tbl)]', namespaces=NS)
matched = 0
pattern = re.compile(target_text) # 允许给定正则表达式查找
for idx, tbl in enumerate(top_tables, start=1):
# 检查该表格内是否包含目标文本
all_text = tbl.xpath('.//w:t/text()', namespaces=NS)
for txt in all_text:
if pattern.search(txt):
matched += 1
print(f'===== 命中第 {matched} 个顶层表格(文档中第 {idx} 个) =====')
print(table_to_html(tbl))
print()
break
if matched == 0:
print(f'未找到单元格中包含文本"{target_text}"的表格。')
return matched
if __name__ == '__main__':
# 配置文件路径
path = input('请输入要提取HTML表结构的文件(可使用相对路径或绝对路径,路径中如有空格不必放在引号中):')
path = path.strip('"').strip("'").strip()
# 目标搜索文本
text = input('请输入要获取HTML表结构的表格某个单元格中的文本(允许输入合法的正则表达式):')
# 执行查找并打印
find_table_by_text_and_print_html(path, text)
例如有如下的测试文档:

几次测试运行输入输出如下:
text
PS E:\temp> python E:\temp\print_table_html_from_word.py
请输入要提取HTML表结构的文件(可使用相对路径或绝对路径,路径中如有空格不必放在引号中):测试lxml提取表格HTML.docx
请输入要获取HTML表结构的表格某个单元格中的文本(允许输入合法的正则表达式):.
===== 命中第 1 个顶层表格(文档中第 1 个) =====
<table border="1" style="border-collapse:collapse;">
<tr>
<td>表1单元格1</td>
<td>表1单元格2</td>
<td>表1单元格3</td>
</tr>
<tr>
<td>表1单元格4</td>
<td>表1单元格5</td>
<td>表1单元格6</td>
</tr>
<tr>
<td>表1单元格7</td>
<td>表1单元格8</td>
<td>表1单元格9</td>
</tr>
</table>
===== 命中第 2 个顶层表格(文档中第 2 个) =====
<table border="1" style="border-collapse:collapse;">
<tr>
<td>表2单元格1</td>
<td>表2单元格2</td>
<td>表2单元格3</td>
</tr>
<tr>
<td>表2单元格4</td>
<td rowspan="2">表2单元格5</td>
<td>表2单元格6</td>
</tr>
<tr>
<td>表2单元格7</td>
<td>表2单元格9</td>
</tr>
<tr>
<td>表2单元格8</td>
<td>表2单元格10</td>
<td>表2单元格11</td>
</tr>
</table>
===== 命中第 3 个顶层表格(文档中第 3 个) =====
<table border="1" style="border-collapse:collapse;">
<tr>
<td>表3单元格1</td>
<td>表3单元格2</td>
<td>表3单元格3</td>
</tr>
<tr>
<td>表3单元格4</td>
<td rowspan="2">表3单元格5<br><table border="1" style="border-collapse:collapse;">
<tr>
<td>嵌套1</td>
<td>嵌套2</td>
<td>嵌套3</td>
</tr>
<tr>
<td colspan="2">嵌套4</td>
<td>嵌套5</td>
</tr>
<tr>
<td>嵌套6</td>
<td>嵌套7</td>
<td>嵌套8</td>
</tr>
</table></td>
<td>表3单元格6</td>
</tr>
<tr>
<td>表3单元格7</td>
<td>表3单元格9</td>
</tr>
<tr>
<td>表3单元格8</td>
<td>表3单元格10</td>
<td>表3单元格11</td>
</tr>
</table>
PS E:\temp> python E:\temp\print_table_html_from_word.py
请输入要提取HTML表结构的文件(可使用相对路径或绝对路径,路径中如有空格不必放在引号中):测试lxml提取表格HTML.docx
请输入要获取HTML表结构的表格某个单元格中的文本(允许输入合法的正则表达式):表2
===== 命中第 1 个顶层表格(文档中第 2 个) =====
<table border="1" style="border-collapse:collapse;">
<tr>
<td>表2单元格1</td>
<td>表2单元格2</td>
<td>表2单元格3</td>
</tr>
<tr>
<td>表2单元格4</td>
<td rowspan="2">表2单元格5</td>
<td>表2单元格6</td>
</tr>
<tr>
<td>表2单元格7</td>
<td>表2单元格9</td>
</tr>
<tr>
<td>表2单元格8</td>
<td>表2单元格10</td>
<td>表2单元格11</td>
</tr>
</table>
PS E:\temp> python E:\temp\print_table_html_from_word.py
请输入要提取HTML表结构的文件(可使用相对路径或绝对路径,路径中如有空格不必放在引号中):测试lxml提取表格HTML.docx
请输入要获取HTML表结构的表格某个单元格中的文本(允许输入合法的正则表达式):表格1
未找到单元格中包含文本"表格1"的表格。
PS E:\temp>