Install any skill in seconds. Free to start, no credit card required.
Get Started Free →当用户需要基于模板填充 Word 文档(.docx)、从模板生成报告、创建包含动态数据的合同,或自动化文档生成时使用此技能。包括替换普通占位符 {name} 替换文本、使用 {name|r:x,c:y} 格式标记的智能表格填充(支持从标记行开始向下填充,保留上方内容)、插入图片、批量生成文档等。如果用户提及 .docx 模板、邮件合并功能或以编程方式填写 Word 表单,请使用此技能。
.claude/skills/leoyeai-fill-docx-template/SKILL.md| Model | Eval pass | Runs |
|---|---|---|
| gemini-3.6-flash | 78% | 9 |
| Test case | Without → With | Effect | Δ tokens | Δ turns |
|---|---|---|---|---|
| case-01 | ✗→✓ | ▲ Improved | 143% | 0% |
| case-02 | ✗→✓ | ▲ Improved | 133% | 0% |
| case-03 | ✗→✓ | ▲ Improved | 152% | 0% |
| case-04 | ✗→✓ | ▲ Improved | 206% | 0% |
| case-05 | ✗→✓ | ▲ Improved | 315% | 0% |
本指南介绍如何使用 Python 向 Word(.docx)模板填充动态数据。支持:
{variable_name} 替换文本{name|r:x,c:y} 标记在表格左侧任意位置,自动从标记行向下填充,保留上方内容,并自动调整表格行列数表格并非总是"提前自动扩展" - 这取决于占位符是否被正确识别:
{name|r:x,c:y} 格式完全正确且位于该行的最左侧单元格(第一列)时,表格会在 DocxTemplateFiller 初始化时立即调整为声明的行列数{name | r:5, c:4})、或不在第一列,表格将保持原样,不会自动扩展或收缩pythonfrom docx import Document from docx.shared import Inches from docx.oxml import OxmlElement from docx.oxml.ns import qn import re import os 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 # 目标列数 } # 调整表格大小:上方保留 row_idx 行,从 row_idx 开始有 target_rows 行 self._resize_table(table, row_idx, target_rows, target_cols) # 清除占位符文本(可选,因为填充时会覆盖,但清除更干净) # 保留单元格其他可能的文本(占位符前后文本) new_text = pattern.sub('', first_cell.text).strip() if new_text: first_cell.text = 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}(不包括表格定义格式)""" # 匹配普通占位符,排除表格定义格式 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): """在段落中执行替换""" 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 and paragraph.runs: paragraph.runs[0].text = new_text for run in paragraph.runs[1:]: run.text = "" def fill_named_table(self, table_name, data): """ 填充指定名称的表格 从占位符所在行开始(包括该行)向下填充,保留上方内容 :param table_name: 表格名称(来自占位符 {name|r:x,c:y}) :param data: 二维列表,如 [['产品A', '规格1', '10'], [...]] """ if table_name not in self.named_tables: raise KeyError(f"未找到名为 '{table_name}' 的表格,请确保模板中存在 '{{{table_name}|r:x,c:y}}' 格式的占位符在最左侧单元格") 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] # 从 start_row 开始填充(包括该行) 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 table.cell(actual_row_idx, col_idx).text = str(value) def fill_all(self, text_data=None, table_data=None): """ 一键填充所有内容 :param text_data: 普通占位符字典,如 {'company': 'ABC公司'} :param table_data: 表格数据字典,如 {'products': [[...], [...]]} """ 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 insert_paragraph_at(self, index, text, style=None): """在指定位置插入段落""" if index == -1 or index >= len(self.doc.paragraphs): p = self.doc.add_paragraph(text) else: p = self.doc.paragraphs[index].insert_paragraph_before(text) if style: p.style = style return p 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}
填充代码:
pythonfiller = 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'] # 第4行不会填充,因为只声明了r:3,但提供了3行数据 ], 'urgent': [ ['投影仪', '4K激光', '1', '急需'], ['幕布', '100寸', '1', '配套'] ] } ) filler.save("contract.docx")
结果:
占位符所在行上方的所有行(包括表头、说明文字、静态数据)完全不会被修改。
python# 模板: # 第0行:表头 | 名称 | 价格 | # 第1行:说明 | 这是说明文字 | # 第2行:占位符 {data|r:2,c:2} | | # 第3行:空行 | | # 填充 data = [['A', '100'], ['B', '200']] # 结果: # 第0行:表头 | 名称 | 价格 | (不变) # 第1行:说明 | 这是说明文字 | (不变) # 第2行:A | 100 | (覆盖占位符) # 第3行:B | 200 | (填充)
如果模板中占位符下方没有足够的行,程序会自动添加:
python# 模板只有3行,占位符在第2行,声明 {data|r:5,c:3} # 程序会自动添加行,使从第2行开始有5行(总行数至少为2+5=7行)
如果模板中占位符下方行数过多,程序会删除多余行(从末尾删除,保留上方内容)。
无论原表格有多少列,程序会调整为占位符声明的列数:
pythonfiller = DocxTemplateFiller("contract.docx") # 表格占位符在模板中位于第2行(索引2,即第3行),声明 {products|r:5,c:4} # 第0-1行是表头和说明,会被保留 filler.fill_named_table('products', [ ['笔记本电脑', 'ThinkPad X1', '10', '¥5000'], # 填充到第2行(覆盖占位符) ['显示器', 'Dell 27寸', '20', '¥1500'], # 填充到第3行 ['键盘', '机械键盘', '30', '¥300'] # 填充到第4行 # 第5-6行保持为空(声明了5行,只提供3行数据) ]) filler.save("output.docx")
pythonfiller = DocxTemplateFiller("report.docx") # 模板中有: # 表1:左上角某行有 {sales|r:10,c:5} # 表2:左上角某行有 {expenses|r:5,c:3} filler.fill_all( table_data={ 'sales': [ ['一月', '产品A', '100', '¥50', '¥5000'], ['二月', '产品A', '120', '¥50', '¥6000'], # ... 最多10行 ], 'expenses': [ ['办公费', '¥2000', '行政部'], ['差旅费', '¥5000', '销售部'], # ... 最多5行 ] } )
即使模板中的表格只有占位符那一行,声明 r:20 后也会自动扩展:
python# 模板表格: # | 项目 | 数量 | 金额 | # | {items|r:20,c:3} | | | filler = DocxTemplateFiller("template.docx") # 自动扩展到20行数据+表头 filler.fill_named_table('items', large_data_list) # 最多填充20行
{name|r:数字,c:数字},不能有空格提供的数据行数超过声明的 r:x 时,多余数据会被截断:
python# 声明 {data|r:3,c:2},表示从占位符行开始只有3行空间 filler.fill_named_table('data', [ ['A', '1'], ['B', '2'], ['C', '3'], ['D', '4'] # 这一行会被忽略,因为只声明了3行 ])
如果占位符所在行存在合并单元格,填充行为可能不符合预期。建议占位符所在行及下方行为标准行列结构。
填充时会替换单元格的 .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) | | 上方内容 | 占位符所在行上方的内容自动保留 | | 覆盖范围 | 从占位符行开始,向下填充 r:x 行 |
| Case | Status | Duration (ms) | Turns | Tokens | Tool calls | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Without | With | Δ | Without | With | Δ | Without | With | Δ | Without | With | Δ | ||
case-01 | fail→pass | 15,017 | 12,633 | -16% | 1 | 1 | 0% | 3,395 | 8,237 | +143% | 0 | 0 | — |
case-02 | fail→pass | 16,090 | 12,147 | -25% | 1 | 1 | 0% | 3,804 | 8,848 | +133% | 0 | 0 | — |
case-03 | fail→pass | 15,007 | 12,171 | -19% | 1 | 1 | 0% | 3,386 | 8,517 | +152% | 0 | 0 | — |
case-04 | fail→pass | 9,650 | 4,679 | -52% | 1 | 1 | 0% | 2,014 | 6,154 | +206% | 0 | 0 | — |
case-05 | fail→pass | 7,843 | 6,732 | -14% | 1 | 1 | 0% | 1,540 | 6,385 | +315% | 0 | 0 | — |
case-06 | pass→pass | 8,498 | 3,346 | -61% | 1 | 1 | 0% | 1,834 | 5,747 | +213% | 0 | 0 | — |
case-07 | fail→pass | 7,312 | 4,136 | -43% | 1 | 1 | 0% | 1,708 | 5,883 | +244% | 0 | 0 | — |
case-08 | fail→pass | 9,618 | 5,196 | -46% | 1 | 1 | 0% | 1,584 | 6,186 | +291% | 0 | 0 | — |
case-09 | fail→pass | 8,855 | 11,018 | +24% | 1 | 1 | 0% | 1,915 | 7,801 | +307% | 0 | 0 | — |
case-10 | pass→pass | 6,383 | 4,430 | -31% | 1 | 1 | 0% | 1,184 | 6,083 | +414% | 0 | 0 | — |
case-11 | pass→pass | 9,618 | 5,988 | -38% | 1 | 1 | 0% | 1,884 | 6,258 | +232% | 0 | 0 | — |
case-12 | pass→pass | 10,791 | 2,885 | -73% | 1 | 1 | 0% | 2,109 | 5,687 | +170% | 0 | 0 | — |
case-13 | pass→pass | 8,864 | 1,802 | -80% | 1 | 1 | 0% | 1,953 | 5,413 | +177% | 0 | 0 | — |
case-14 | pass→pass | 4,524 | 2,547 | -44% | 1 | 1 | 0% | 859 | 5,664 | +559% | 0 | 0 | — |
case-15 | fail→pass | 5,889 | 4,161 | -29% | 1 | 1 | 0% | 1,382 | 6,011 | +335% | 0 | 0 | — |
case-16 | pass→pass | 11,697 | 8,850 | -24% | 1 | 1 | 0% | 2,228 | 7,241 | +225% | 0 | 0 | — |
case-17 | pass→pass | 6,874 | 4,837 | -30% | 1 | 1 | 0% | 1,183 | 6,277 | +431% | 0 | 0 | — |
case-18 | fail→pass | 9,218 | 10,330 | +12% | 1 | 1 | 0% | 1,932 | 8,180 | +323% | 0 | 0 | — |
case-19 | fail→pass | 7,912 | 5,443 | -31% | 1 | 1 | 0% | 1,669 | 6,360 | +281% | 0 | 0 | — |
case-20 | pass→pass | 9,585 | 8,776 | -8% | 1 | 1 | 0% | 2,012 | 6,911 | +243% | 0 | 0 | — |
case-21 | pass→pass | 7,278 | 6,574 | -10% | 1 | 1 | 0% | 1,648 | 6,395 | +288% | 0 | 0 | — |
case-22 | pass→pass | 6,554 | 5,437 | -17% | 1 | 1 | 0% | 1,382 | 6,503 | +371% | 0 | 0 | — |
DecimalAI ran this skill against gemini-3.6-flash twice over the same eval suite — once with the skill loaded and once without — and compared the two runs case by case. 22 cases were attempted. The headline lift of +50 percentage points is the difference between those two pass rates over the 22 comparable cases.
Without the skill loaded, the model failed this case. With it loaded, the same prompt on the same model passed. This is one improved case from the latest verified run; every case, including any that regressed, is in the table above.
Other measured skills in the registry, with their headline benchmark lift.