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