使用lxml操作 Word 的 OpenXML 底层

.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>
相关推荐
梦想出海-Phoebe1 天前
Gemini新增文件编辑功能:PDF、Word、Excel处理体验分享
pdf·word·excel
开开心心_Every3 天前
快速隐藏程序工具自定义快捷键一键切换
java·开发语言·前端·spring·智能手机·word·intellij-idea
ComPDFKit4 天前
PDF转Word后,每行都成一段?先分清断段、文本框和扫描页
pdf·word
thethefighter4 天前
快速预览Word、WPS秒开,支持空格弹窗
linux·word·wps·快速预览·空格弹窗·nhdeepview
SunnyDays10114 天前
Java 操作 Word 脚注和尾注:添加、读取与删除
java·word
SunnyDays10114 天前
Java 为 Word 添加页码:隐藏封面页码、从指定页开始编号、奇偶页页码设置等
java·word·页码
Eiceblue4 天前
用 Python 给 Word 文档加水印:文字、图片与批量处理
开发语言·vscode·python·word
Metaphor6925 天前
使用 Python 在 Word 文档中添加、修改和删除变量
python·word
Monte05395 天前
word 如何让mathtype公式居中,公式编号右对齐
word