yivifu头像
使用lxml操作 Word 的 OpenXML 底层封面图

使用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 结构是扁平的、层级分明的。一个最简文档的路径是:

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 操作的第一个门槛。主命名空间固定为:

W = "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}"

用 lxml 的 XPath 时,需要以字典形式传入命名空间前缀映射:

ns = {"w": "http://schemas.openxmlformats.org/wordprocessingml/2006/main"}

3. 最小可运行框架:读 → 改 → 写

下面这段代码展示了从打开 .docx、解析 document.xml,到修改后重新打包的完整闭环。注意:修改后必须把整个 ZIP 包重新组装,不能只替换单个文件。

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 操作场景示例

场景一:提取所有段落文本(跳过格式)
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 可以精确过滤:

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:

# 把所有包含"旧关键词"的文本节点替换为"新关键词"
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 末尾追加一个普通段落:

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表格代码

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)

例如有如下的测试文档:
在这里插入图片描述
几次测试运行输入输出如下:

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>

下面的示例代码将转换的HTML写入目标文档,涉及的操作更有代表性:

import zipfile
import shutil
import re
from lxml import etree

w = '{http://schemas.openxmlformats.org/wordprocessingml/2006/main}'
NS = {'w': 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'}
XML_SPACE = '{http://www.w3.org/XML/1998/namespace}space'

INDENT = '\t'  # 每层缩进使用的字符

def load_document_xml(docx_path):
    """
    读取 docx 包中的 word/document.xml 并解析为 lxml 根元素。

    参数:
        docx_path (str): docx 文件路径,可含空格和中文。
    返回:
        lxml.etree._Element: document.xml 的根元素(w:document)。
    """
    with zipfile.ZipFile(docx_path, 'r') as z:
        return etree.fromstring(z.read('word/document.xml'))


def save_document_xml(original_path, output_path, root):
    """
    将修改后的 document.xml 写回,生成新的 docx 文件。

    zipfile 不支持原地替换单个成员,因此先复制原文件,
    再以“读全部成员 -> 重写全部成员、替换 document.xml”的方式重建。

    参数:
        original_path (str): 原始 docx 路径。
        output_path (str): 输出 docx 路径。
        root (lxml.etree._Element): 修改后的 document.xml 根元素。
    返回:
        None
    """
    shutil.copy(original_path, output_path)
    new_xml = etree.tostring(
        root, xml_declaration=True, encoding='UTF-8', standalone=True
    )
    with zipfile.ZipFile(output_path, 'r') as zin:
        items = [(i, zin.read(i.filename)) for i in zin.infolist()]
    with zipfile.ZipFile(output_path, 'w', zipfile.ZIP_DEFLATED) as zout:
        for info, data in items:
            if info.filename == 'word/document.xml':
                zout.writestr(info, new_xml)
            else:
                zout.writestr(info, data)


def paragraph_text(p):
    """
    提取一个段落 <w:p> 内所有 <w:t> 文本并拼接。

    参数:
        p (lxml.etree._Element): 段落节点。
    返回:
        str: 段落纯文本(不含格式)。
    """
    return "".join(p.xpath(".//w:t/text()", namespaces=NS))

def cell_content(tc, depth):
    """
    按文档顺序提取单元格内容:段落取纯文本,嵌套表格递归转 HTML。
    其它可能的内容,如图片等未处理,将被丢弃。

    参数:
        tc (lxml.etree._Element): 单元格节点 <w:tc>。
        depth (int): 当前单元格内容所处的缩进层级(只读,不修改)。
                     单元格内容位于“表格层 + 行层”之下,故传 depth+2。
    返回:
        str: 单元格内容。段落文本与嵌套表格 HTML 用 <br> + 换行 + 缩进连接。
    """
    parts = []
    for child in tc:
        if child.tag == f"{w}p":
            txt = paragraph_text(child)
            if txt.strip():          # 忽略纯空白段落
                parts.append(txt)
        elif child.tag == f"{w}tbl":
            nested_html = table_to_html(child, depth)
            if nested_html:
                parts.append(nested_html)

    # 多段/含嵌套表格时,用 <br> 分隔,并让后续内容对齐到当前层级
    sep = "<br>\n" + INDENT * depth
    return sep.join(parts)

def table_to_html(tbl, depth=0):
    """
    将一个 <w:tbl> 递归转换为带缩进的 HTML 表格字符串。对嵌套表格的缩进处理目前差强人意。	

    缩进模型(只读 depth,绝不修改传入值):
        <table>      位于 depth 层
        <tr>         位于 depth+1 层
        <td>         位于 depth+2 层
        嵌套表格     位于 depth+2 层(与所在 <td> 内容同层)

    参数:
        tbl (lxml.etree._Element): 表格节点 <w:tbl>。
        depth (int): 本表格 <table> 标签所处的缩进层级,默认 0。
    返回:
        str: HTML 表格字符串;无行时返回空串。
    """
    rows = tbl.xpath('./w:tr', namespaces=NS)
    if not rows:
        return ''

    grid = []          # 每行是单元格 dict 列表,None 表示被合并占位
    vmerge_open = {}   # 列索引 -> 纵向合并起点 cell dict

    for tr in rows:
        cells = tr.xpath('./w:tc', namespaces=NS)
        c_idx = 0
        row_cells = []
        for tc in cells:
            while c_idx < len(row_cells) and row_cells[c_idx] is not None:
                c_idx += 1

            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

            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

            # 单元格内容位于 depth+2 层
            text = cell_content(tc, depth + 2)

            if vmerge_val == 'restart':
                cell = {'text': text, 'colspan': colspan, 'rowspan': 1}
                for k in range(colspan):
                    vmerge_open[c_idx + k] = cell
            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
                else:
                    cell = {'text': text, 'colspan': colspan, 'rowspan': 1}
            else:
                cell = {'text': text, 'colspan': colspan, 'rowspan': 1}

            for k in range(colspan):
                if c_idx + k >= len(row_cells):
                    row_cells.extend([None] * (c_idx + k - len(row_cells) + 1))
                row_cells[c_idx + k] = cell if k == 0 else None

            c_idx += colspan

        grid.append(row_cells)

    # ---- 生成 HTML,缩进全部由 depth 推导 ----
    lines = [f'{INDENT * depth}<table border="1" style="border-collapse:collapse;">']
    for row_cells in grid:
        lines.append(f'{INDENT * (depth + 1)}<tr>')
        for cell in row_cells:
            if cell is None:
                continue
            attrs = ''
            if cell['colspan'] > 1:
                attrs += f' colspan="{cell["colspan"]}"'
            if cell['rowspan'] > 1:
                attrs += f' rowspan="{cell["rowspan"]}"'
            lines.append(f'{INDENT * (depth + 2)}<td{attrs}>{cell["text"]}</td>')
        lines.append(f'{INDENT * (depth + 1)}</tr>')
    lines.append(f'{INDENT * depth}</table>\n')
    return '\n'.join(lines)

def make_text_paragraph(text, bold=False, highlight_color='yellow'):
    """
    构造一个只含单段文本的 <w:p>,可选择加粗与高亮。

    参数:
        text (str): 段落文本。
        bold (bool): 是否加粗。
        highlight_color (str): 高亮颜色(w:highlight 的 val),可用值:
        		black,blue,cyan,darkBlue,darkCyan,darkGray,darkGreen,
        		darkMagenta,darkRed,darkYellow,green,lightGray,magenta,
        		none,red,white,yellow。
        		用其它值将导致文档打开时报文档损坏。
    返回:
        lxml.etree._Element: 新建的段落节点 <w:p>。
    """
    p = etree.Element(f'{w}p')
    r = etree.SubElement(p, f'{w}r')
    if bold or highlight_color:
        rpr = etree.SubElement(r, f'{w}rPr')
        if bold:
            etree.SubElement(rpr, f'{w}b')
        if highlight_color:
            hl = etree.SubElement(rpr, f'{w}highlight')
            hl.set(f'{w}val', highlight_color)
    t = etree.SubElement(r, f'{w}t')
    t.set(XML_SPACE, 'preserve')
    t.text = text
    return p

def replace_tables_with_html(input_path, output_path, target_text, highlight_color='yellow'):
    """
    查找正文顶层表格中包含目标文本(正则)的表格,
    将其转换为 HTML,按行拆成多个段落插入到该表格之后。

    参数:
        input_path (str): 输入 docx 路径。
        output_path (str): 输出 docx 路径。
        target_text (str): 用于查找要提取HTML表结构的表格的正则表达式。
        highlight_color (str): 高亮颜色。
    返回:
        None
    """
    root = load_document_xml(input_path)
    body = root.find(f'{w}body')
    if body is None:
        raise RuntimeError('找不到 w:body')

    tables = body.xpath("./w:tbl", namespaces=NS)
    pattern = re.compile(target_text)

    for tbl in tables:
        hit = any(pattern.search(txt) for txt in tbl.xpath(".//w:t/text()", namespaces=NS))
        if not hit:
            continue

        html_str = table_to_html(tbl, depth=0)

        note_p = make_text_paragraph("【HTML 转换结果】", bold=True, highlight_color=highlight_color)
        tbl.addnext(note_p)

        last_p = note_p
        for line in html_str.split('\n'):
            if not line.strip():
                continue
            line_p = make_text_paragraph(line, bold=False, highlight_color=highlight_color)
            last_p.addnext(line_p)
            last_p = line_p

    save_document_xml(input_path, output_path, root)
    print(f'完成:{output_path}')


if __name__ == "__main__":
    path = input("请输入 docx 路径(可直接输入,无需引号):").strip().strip('"').strip("'")
    text = input("请输入要匹配的正则表达式:")
    replace_tables_with_html(path, r'E:\temp\test path\output.docx', text, highlight_color="cyan")

转载自 CSDN-专业IT技术社区

原文链接:https://blog.csdn.net/yivifu/article/details/169941479

文章来源转载

评论

评论列表

微信小程序
QQ小程序

关于作者

点赞数:0
关注数:0
粉丝:0
文章:0
关注标签:0
加入于:--