Install
openclaw skills install @huagc/fill-docx-template当用户需要基于模板填充 Word 文档(.docx)、从模板生成报告、创建包含动态数据的合同,或自动化文档生成时使用此技能。包括替换普通占位符 {name} 替换文本、使用 {name|r:x,c:y} 格式标记的智能表格填充(支持从标记行开始向下填充,保留上方内容)、插入图片、批量生成文档等。如果用户提及 .docx 模板、邮件合并功能或以编程方式填写 Word 表单,请使用此技能。
openclaw skills install @huagc/fill-docx-template\n 在 docx 中不会产生换行!这是最高优先级的注意事项,在此之前已多次出错。
python-docx 中 paragraph.text = "含有\n的文本" 不会在 Word 中产生渲染换行。\n 只是 XML 文本节点中的普通字符,Word 不识别为排版指令。
<w:br/> XML 元素<w:p> 段落元素正确做法:
from lxml import etree
from docx.oxml.ns import qn
def set_paragraph_with_breaks(paragraph, text):
"""将含 \n 的文本正确写入段落,\n 转换为 <w:br/>"""
lines = text.split('\n')
# 清空原有 runs
for run in list(paragraph.runs):
run._element.getparent().remove(run._element)
for i, line in enumerate(lines):
if i > 0:
# 在前一个 run 中插入换行符
if paragraph.runs:
etree.SubElement(paragraph.runs[-1]._element, qn('w:br'))
if line: # 非空行
run = paragraph.add_run(line)
def split_into_paragraphs(doc_or_body, text, base_paragraph, style=None):
"""将含 \n\n 的文本拆分为多个独立段落,插入 base_paragraph 之前"""
from copy import deepcopy
blocks = [b.strip() for b in text.split('\n\n') if b.strip()]
if len(blocks) <= 1:
set_paragraph_with_breaks(base_paragraph, text)
return
parent = base_paragraph._element.getparent()
pos = list(parent).index(base_paragraph._element)
for i, block in enumerate(blocks):
new_p = deepcopy(base_paragraph._element)
for child in list(new_p):
if child.tag == qn('w:r'):
new_p.remove(child)
# 处理单 \n 为 br
lines = block.split('\n')
run = etree.SubElement(new_p, qn('w:r'))
for j, line in enumerate(lines):
if j > 0:
etree.SubElement(run, qn('w:br'))
t_elem = etree.SubElement(run, qn('w:t'))
t_elem.text = line
t_elem.set(qn('xml:space'), 'preserve')
if style and hasattr(new_p, 'style'):
pass # XML 级别设置样式较复杂,可后续补充
parent.insert(pos + i, new_p)
parent.remove(base_paragraph._element)
判断用哪种方式:
\n\n(段落间距)→ 拆分为独立段落\n(连续行)→ 用 <w:br/> 软换行\n 的字符串赋给 paragraph.text本指南介绍如何使用 Python 向 Word(.docx)模板填充动态数据。支持:
{variable_name} 替换文本{name|r:x,c:y} 标记在表格左侧任意位置,自动从标记行向下填充,保留上方内容,并自动调整表格行列数表格并非总是"提前自动扩展" - 这取决于占位符是否被正确识别:
{name|r:x,c:y} 格式完全正确且位于**该行的最左侧单元格(第一列)**时,表格会在 DocxTemplateFiller 初始化时立即调整为声明的行列数{name | r:5, c:4})、或不在第一列,表格将保持原样,不会自动扩展或收缩from docx import Document
from docx.shared import Inches
from docx.oxml import OxmlElement
from docx.oxml.ns import qn
from lxml import etree
from copy import deepcopy
import re
import os
def set_paragraph_text_with_breaks(paragraph, text):
"""将含 \n 的文本正确写入段落(辅助函数,见第0条规则)"""
lines = text.split('\n')
for run in list(paragraph.runs):
run._element.getparent().remove(run._element)
for i, line in enumerate(lines):
if i > 0 and paragraph.runs:
etree.SubElement(paragraph.runs[-1]._element, qn('w:br'))
if line:
run = paragraph.add_run(line)
class DocxTemplateFiller:
def __init__(self, template_path):
if not os.path.exists(template_path):
raise FileNotFoundError(f"模板文件不存在: {template_path}")
self.doc = Document(template_path)
self.template_path = template_path
self.named_tables = {}
# 初始化时扫描表格占位符
self._process_table_placeholders()
def _process_table_placeholders(self):
"""
扫描所有表格的每一行第一个单元格,查找 {name|r:x,c:y} 格式
例如:{products|r:5,c:4} 表示从当前行开始,共5行,4列
"""
pattern = re.compile(r'\{(\w+)\|r:(\d+),c:(\d+)\}')
for table_idx, table in enumerate(self.doc.tables):
for row_idx, row in enumerate(table.rows):
if len(row.cells) == 0:
continue
first_cell = row.cells[0]
text = first_cell.text.strip()
match = pattern.search(text)
if match:
name = match.group(1)
target_rows = int(match.group(2))
target_cols = int(match.group(3))
self.named_tables[name] = {
'table': table,
'start_row': row_idx,
'target_rows': target_rows,
'target_cols': target_cols
}
self._resize_table(table, row_idx, target_rows, target_cols)
new_text = pattern.sub('', first_cell.text).strip()
if new_text:
set_paragraph_text_with_breaks(first_cell.paragraphs[0], new_text)
else:
first_cell.text = ""
def _resize_table(self, table, start_row, target_rows, target_cols):
"""调整表格大小:确保从 start_row 开始有 target_rows 行,总列数为 target_cols"""
total_needed_rows = start_row + target_rows
current_rows = len(table.rows)
current_cols = len(table.columns) if table.columns else 0
if total_needed_rows > current_rows:
for _ in range(total_needed_rows - current_rows):
table.add_row()
elif total_needed_rows < current_rows:
self._delete_rows_from_end(table, current_rows - total_needed_rows)
if target_cols != current_cols:
self._resize_columns(table, target_cols)
def _delete_rows_from_end(self, table, num_rows):
"""从表格末尾删除指定行数"""
tbl = table._tbl
for _ in range(num_rows):
if len(table.rows) > 0:
tr = table.rows[-1]._tr
tbl.remove(tr)
def _resize_columns(self, table, target_cols):
"""调整表格列数"""
current_cols = len(table.columns)
tbl = table._tbl
tblGrid = tbl.find(qn('w:tblGrid'))
if target_cols > current_cols:
for _ in range(target_cols - current_cols):
gridCol = OxmlElement('w:gridCol')
tblGrid.append(gridCol)
for row in table.rows:
for _ in range(target_cols - current_cols):
tc = OxmlElement('w:tc')
tcPr = OxmlElement('w:tcPr')
tc.append(tcPr)
p = OxmlElement('w:p')
tc.append(p)
row._tr.append(tc)
elif target_cols < current_cols:
for _ in range(current_cols - target_cols):
if len(tblGrid) > 0:
tblGrid.remove(tblGrid[-1])
for row in table.rows:
for _ in range(current_cols - target_cols):
tcs = row._tr.findall(qn('w:tc'))
if len(tcs) > target_cols:
row._tr.remove(tcs[-1])
def fill_placeholders(self, data_dict):
"""
替换普通占位符 {key}(不包括表格定义格式)。
⚠️ 如果替换值包含 \n,会自动按规则0转换为真换行。
"""
pattern = re.compile(r'\{(\w+)\}(?!\|r:\d+,c:\d+)')
for para in self.doc.paragraphs:
self._replace_in_paragraph(para, pattern, data_dict)
for table in self.doc.tables:
for row in table.rows:
for cell in row.cells:
for para in cell.paragraphs:
self._replace_in_paragraph(para, pattern, data_dict)
def _replace_in_paragraph(self, paragraph, pattern, data_dict):
"""在段落中执行替换,含 \n 的值用 set_paragraph_text_with_breaks 处理"""
text = paragraph.text
matches = pattern.findall(text)
if not matches:
return
new_text = text
for key in matches:
if key in data_dict:
placeholder = f'{{{key}}}'
value = str(data_dict[key])
new_text = new_text.replace(placeholder, value)
if new_text != text:
set_paragraph_text_with_breaks(paragraph, new_text)
def fill_named_table(self, table_name, data):
"""
填充指定名称的表格。
⚠️ 如果单元格数据包含 \n,会自动转换为 <w:br/> 软换行。
"""
if table_name not in self.named_tables:
raise KeyError(f"未找到名为 '{table_name}' 的表格")
table_info = self.named_tables[table_name]
table = table_info['table']
start_row = table_info['start_row']
target_rows = table_info['target_rows']
target_cols = table_info['target_cols']
if len(data) > target_rows:
data = data[:target_rows]
for row_offset, row_data in enumerate(data):
actual_row_idx = start_row + row_offset
if actual_row_idx >= len(table.rows):
break
if len(row_data) > target_cols:
row_data = row_data[:target_cols]
for col_idx, value in enumerate(row_data):
if col_idx >= len(table.columns):
break
cell = table.cell(actual_row_idx, col_idx)
text = str(value)
if '\n' in text:
set_paragraph_text_with_breaks(cell.paragraphs[0], text)
else:
cell.text = text
def fill_all(self, text_data=None, table_data=None):
"""一键填充所有内容"""
if text_data:
self.fill_placeholders(text_data)
if table_data:
for name, data in table_data.items():
self.fill_named_table(name, data)
def set_paragraph(self, paragraph, text):
"""
设置段落文本,正确处理 \n 换行。
短文本用 <w:br/>,含 \n\n 的长文本建议调用 split_and_insert_paragraphs。
"""
set_paragraph_text_with_breaks(paragraph, text)
def split_and_insert_paragraphs(self, base_paragraph, text, style=None):
"""
将含 \n\n 的长文本拆分为多个独立段落,替换当前段落。
每个 \n\n 分割处成为新的 docx 段落边界。
"""
blocks = [b.strip() for b in text.split('\n\n') if b.strip()]
if len(blocks) <= 1:
set_paragraph_text_with_breaks(base_paragraph, text)
return
parent = base_paragraph._element.getparent()
pos = list(parent).index(base_paragraph._element)
for i, block in enumerate(blocks):
new_p = deepcopy(base_paragraph._element)
for child in list(new_p):
if child.tag == qn('w:r'):
new_p.remove(child)
lines = block.split('\n')
run = etree.SubElement(new_p, qn('w:r'))
for j, line in enumerate(lines):
if j > 0:
etree.SubElement(run, qn('w:br'))
t_elem = etree.SubElement(run, qn('w:t'))
t_elem.text = line
t_elem.set(qn('xml:space'), 'preserve')
parent.insert(pos + i, new_p)
parent.remove(base_paragraph._element)
def insert_paragraph_at(self, index, text, style=None):
"""在指定位置插入段落。⚠️ 如果 text 含 \n\n,自动拆分为多个段落。"""
if '\n\n' in text:
# 先在 index 处插入一个空段落作为锚点
if index == -1 or index >= len(self.doc.paragraphs):
anchor = self.doc.add_paragraph('')
else:
anchor = self.doc.paragraphs[index].insert_paragraph_before('')
self.split_and_insert_paragraphs(anchor, text, style)
else:
if index == -1 or index >= len(self.doc.paragraphs):
p = self.doc.add_paragraph('')
else:
p = self.doc.paragraphs[index].insert_paragraph_before('')
set_paragraph_text_with_breaks(p, text)
if style:
p.style = style
def insert_image(self, paragraph_index, image_path, width=None):
"""在指定段落后插入图片"""
if not os.path.exists(image_path):
raise FileNotFoundError(f"图片不存在: {image_path}")
para = self.doc.paragraphs[paragraph_index]
run = para.add_run()
if width:
run.add_picture(image_path, width=Inches(width))
else:
run.add_picture(image_path)
def save(self, output_path):
self.doc.save(output_path)
print(f"✅ 文档已生成: {os.path.abspath(output_path)}")
使用 {variable_name} 格式:
甲方(购方):{company}
签署日期:{date}
合同编号:{contract_no}
格式:{name|r:x,c:y}
位置:表格最左侧的任意单元格(通常是某一行的第一列)
行为:
示例:
| 序号 | 产品名称 | 规格 | 数量 | 单价 | 金额 |
| ------ | -------- | --- | --- | --- | ---- |
| 1 | 产品A | 规格1 | 10 | 100 | 1000 |
| {items|r:3,c:6} | | | | | |
| | | | | | |
说明:
{items|r:3,c:6} 放在第3行第1列(索引从0开始则为第2行)场景:合同中有两个表格,第一个表格上方有静态说明行
采购合同
甲方:{company}
乙方:{seller}
产品列表(常规采购):
| 产品名称 | 型号 | 数量 | 单价 |
|----------|------|------|------|
| {regular|r:4,c:4} | | | |
| | | | |
| | | | |
| | | | |
紧急采购项(如有):
| 产品名称 | 型号 | 数量 | 要求 |
|----------|------|------|------|
| 说明:紧急采购需24小时内到货 | | | |
| {urgent|r:2,c:4} | | | |
| | | | |
总计金额:{total_amount}
填充代码:
filler = DocxTemplateFiller("template.docx")
filler.fill_all(
text_data={
'company': '北京科技',
'seller': '上海贸易',
'total_amount': '¥50,000'
},
table_data={
'regular': [
['办公椅', '人体工学', '10', '¥800'],
['办公桌', '1.2米', '5', '¥1500'],
['文件柜', '铁皮', '3', '¥600']
],
'urgent': [
['投影仪', '4K激光', '1', '急需'],
['幕布', '100寸', '1', '配套']
]
}
)
filler.save("contract.docx")
filler = DocxTemplateFiller("template.docx")
# ✅ 正确:fill_placeholders 自动处理 \n
filler.fill_placeholders({
'description': '第一段内容\n\n第二段内容\n包含两行',
})
# \n\n → 拆分为两个独立段落
# \n → 段落内 <w:br/> 软换行
# ✅ 正确:单段落设置
filler.set_paragraph(doc.paragraphs[5], '标题\n副标题\n正文')
# ✅ 正确:多段落拆分
filler.split_and_insert_paragraphs(
doc.paragraphs[10],
'第一节标题\n\n第一节正文第一段\n\n第一节正文第二段'
)
占位符所在行上方的所有行(包括表头、说明文字、静态数据)完全不会被修改。
# 模板:
# 第0行:表头 | 名称 | 价格 |
# 第1行:说明 | 这是说明文字 |
# 第2行:占位符 {data|r:2,c:2} | |
# 第3行:空行 | |
# 填充 data = [['A', '100'], ['B', '200']]
# 结果:
# 第0行:表头 | 名称 | 价格 | (不变)
# 第1行:说明 | 这是说明文字 | (不变)
# 第2行:A | 100 | (覆盖占位符)
# 第3行:B | 200 | (填充)
如果模板中占位符下方没有足够的行,程序会自动添加;过多则删除(从末尾)。
无论原表格有多少列,程序会调整为占位符声明的列数。
filler = DocxTemplateFiller("contract.docx")
filler.fill_named_table('products', [
['笔记本电脑', 'ThinkPad X1', '10', '¥5000'],
['显示器', 'Dell 27寸', '20', '¥1500'],
])
filler.save("output.docx")
filler = DocxTemplateFiller("report.docx")
# 含换行的文本会自动正确处理
filler.fill_placeholders({
'summary': '第一部分:概述\n\n第二部分:详细分析\n包含(1)数据(2)结论',
'conclusion': '综上所述,\n本项目具有重要价值。'
})
# 写入一个含多段的长内容,自动拆分为独立段落
filler.split_and_insert_paragraphs(
doc.paragraphs[3],
'一、背景介绍\n\n这是背景的第一段...\n\n这是背景的第二段...\n\n三、小结\n这是小结内容。'
)
{name|r:数字,c:数字},不能有空格提供的数据行数超过声明的 r:x 时,多余数据会被截断。
如果占位符所在行存在合并单元格,填充行为可能不符合预期。建议占位符所在行及下方行为标准行列结构。
填充时会替换单元格的 .text 属性,这可能清除单元格内的特殊格式(如加粗、颜色)。如果需要保留格式,建议使用 python-docx 的低级 API 直接操作 run 对象。
| 功能 | 方法/说明 |
|---|---|
| 加载模板 | filler = DocxTemplateFiller("template.docx") |
| 普通占位符 | {company} → filler.fill_placeholders({'company': '名称'}) |
| 填充表格 | filler.fill_named_table('products', [...]) |
| 一键填充 | filler.fill_all(text_dict, table_dict) |
| 设置段落(自动换行) | filler.set_paragraph(p, text) |
| 拆分长段落 | filler.split_and_insert_paragraphs(p, text) |
| 插入段落 | filler.insert_paragraph_at(idx, text) |
| 上方内容 | 占位符所在行上方的内容自动保留 |
| 覆盖范围 | 从占位符行开始,向下填充 r:x 行 |