做批量文档生成这件事,我前后折腾过好几种路子:最早用 Python 直接操控 Word,后来试过在 Excel 里套公式拼字符串,再后来才落到 Excel + JSON + Word 模板这套组合上。今天想把这套方案的完整链路拆开讲一遍,包括为什么选这个架构、Excel 数据要做什么预处理、Word 模板怎么写标签才能稳、以及脚本落地时的批量生成逻辑。如果你也被“每周要出几十份合同/通知书/报告”这种事折磨过,这篇应该能帮你省下不少时间。
这套方案的核心说起来其实很简单:把数据放在 Excel 里管理,把版式固定在 Word 模板里,中间用 JSON 作为数据交换的桥梁。Excel 负责录入和更新数据,Word 模板负责呈现样式,脚本只做一件事——把两边对接起来。好处是业务同事不需要碰代码,改数据就打开 Excel 改;版式调整也不用手动去改几十份文件,只改一个模板文件重新跑一遍就行。
适合谁来参考?行政、销售助理、HR、财务、运营、测试工程师,以及所有需要成批生成“带有个人化数据的正式文档”的人。哪怕你的文档不是合同而是工单、成绩单、体检报告、产品标签、发票说明,方法论完全通用。下面我从头到尾拆解这套流程。
1. 整体思路拆解:为什么是 Excel、JSON 和 Word 模板各管一段
1.1 先认清一个事实:批量文档的本质是“数据 + 版式”的组合
你仔细想一下任何批量文档:无论是劳动合同还是检测报告,每一份之间的差异其实都体现在一小撮字段上——姓名、编号、日期、金额、商品清单。版式、段落、字体、公司标志,几乎一模一样。
所以批量生成的核心任务,就是把“变化的字段”和“固定的版式”分离,然后用程序把两者组合起来。如果数据量小,手动替换字段可行;到了几十上百份还手动弄,一定会出现两种问题:一是漏替换,名字没改过来之类的低级错误;二是格式漂移,有人复制粘贴后调了缩进,最终文件五花八门。
1.2 Excel 在流程里承担的角色:数据的最友好载体
我刻意强调 Excel 是数据载体而不是数据源头。也就是说,让业务人员用 Excel 维护字段信息,是因为它几乎不需要学习成本,而且支持筛选、排序、校验、多 Sheet 管理。相比直接让人编辑 JSON 或 XML,Excel 不知道友好到哪里去了。
当然,Excel 有个天然缺陷:它不擅长表达“嵌套结构”。比如一份合同里要带多行商品明细,纯用一张扁平的 Excel 表很难表达。我的处理方式是用“主表 + 子表”配合:主表一行对应一份文档,子表多行对应文档里的重复段落,两者通过一个单号或编号关联。后面脚本部分我会展示具体怎么合并。
1.3 JSON 是桥梁不是终点
有人会问,既然 Excel 都能读,为什么还要转一道 JSON?因为 JSON 是一种极简的数据交换格式,它让“数据获取”和“文档渲染”两个环节彻底解耦。你可以从 Excel 读数据,也可以从接口读数据,甚至从数据库查出来组装成 JSON,后面的 Word 渲染逻辑完全不用改。
而且 JSON 可以直接预览和调试。模板渲染出问题,我先打开 JSON 文件看数据结构是否缺失,再决定是数据错了还是模板标签写错了。这一点在排查问题上帮了大忙。
1.4 Word 模板负责 100% 的版式,而不是拿代码调格式
这是整套方案里我最看重的一点:版式只存在于 Word 模板里,代码里不应该出现任何字体名、字号、缩进量、页面边距。原因是版式是视觉活,交给代码做既繁琐又难调,而且一旦版本更新,Python 脚本里的几十个格式参数拆起来比写还麻烦。
在 Word 里先把模板排好版,该加粗的加粗,该居中的居中,然后只留出占位符区域。渲染的时候,程序只在指定的占位符位置填入文本,其他内容一概不动。哪个字体大小不满意,改模板重新生成即可,不动一行代码。
2. Excel 数据预处理:数据质量决定文档质量
2.1 表头规范化的三条准则
Excel 表长什么样直接决定脚本的复杂程度。我踩过不少坑之后总结出三条准则:
第一,主表的每一行必须是一份完整的文档数据,不要出现跨行合并单元格。程序按行读取,合并单元格会造成字段缺失,极难补。
第二,表头建议直接用英文名,不要用中文。比如customer_name、contract_no、amount。原因不是程序不支持中文,而是后面的 Word 模板标签也建议用英文,两边保持一致时排查问题最方便。如果业务同事看不惯英文表头,可以放一行中文备注在下面,或者用另一张“说明 Sheet”做字段映射解释。
第三,字段命名要全局唯一。不同 Sheet 里不要出现相同的字段名但含义不同,否则合并 JSON 时会互相覆盖。
2.2 类型问题:日期、金额、空值,三个最头疼的坑
表格里最坑的永远是类型问题。日期在 Excel 里看着是"2024-01-15",用 pandas 读出来后实际是一个Timestamp对象,直接转 JSON 会报错,就算转成字符串也可能是2024-01-15 00:00:00,带上了多余的时间尾巴。
我的做法是统一预处理:日期字段全部先转成datetime,再格式化为YYYY-MM-DD。金额字段全部保留两位小数,转成字符串而不是浮点数,这样能避免 JSON 序列化时出现5999.999999999999这种浮点误差。
空值先想清楚业务语义。金额为空是填 0 还是留空?日期为空是填“待定”还是留空?在预处理阶段就把这些规则定死,不要让模板在渲染时去处理逻辑分支,太容易出意外。
import pandas as pd import numpy as np import json from decimal import Decimal def excel_to_json(excel_path, output_path): df = pd.read_excel(excel_path, sheet_name="主表", dtype=str) # 统一处理缺失值:文本空 => 字符串空,数值类缺失 => 0 df = df.replace({np.nan: ""}) records = [] for _, row in df.iterrows(): record = { "contract_no": row.get("contract_no", "").strip(), "customer_name": row.get("customer_name", "").strip(), "amount": f"{Decimal(row.get('amount') or 0):.2f}", "sign_date": pd.to_datetime(row.get("sign_date")).strftime("%Y-%m-%d") if row.get("sign_date") else "", } records.append(record) with open(output_path, "w", encoding="utf-8") as f: json.dump(records, f, ensure_ascii=False, indent=2) return records上面这段是主表的基础处理脚本。注意我用了dtype=str先把整张表当字符串读,这样每个字段都能统一走字符串清洗逻辑。pd.to_datetime的作用是把各种怪格式的日期都收敛成标准日期对象,加上strftime输出纯日期字符串,彻底绕开 JSON 序列化对日期类型的抱怨。
2.3 嵌套数据的处理:主表 + 子表关联合并
之前说过,嵌套结构是 Excel 的短板。比如一份合同里要列出多行商品明细,数据实际上存在另一个 Sheet 里:
| contract_no | goods_name | quantity | unit_price | |-------------|-----------|----------|------------| | CT001 | 键盘 | 2 | 99 | | CT001 | 显示器 | 1 | 1299 | | CT002 | 鼠标 | 5 | 25 |把这份明细合并到主表记录里,逻辑就是按contract_no做分组再挂到主记录下面。处理完成后,JSON 里应该形成这样的层级:
{ "contract_no": "CT001", "customer_name": "某某公司", "items": [ { "goods_name": "键盘", "quantity": "2", "unit_price": "99.00", "line_total": "198.00" }, { "goods_name": "显示器", "quantity": "1", "unit_price": "1299.00", "line_total": "1299.00" } ] }这个 JSON 结构里,items是一个数组,对应 Word 模板中的循环区块。我强烈建议在任何非扁平型文档生成前,先手动构造一份目标 JSON 样例来规划层级,而不是在 Excel 阶段就想把所有嵌套关系压平。数据该有几层就让它是几层,不要为了 Excel 的扁平特性去扭曲数据结构。这样即使将来换数据源,JSON 结构也不用变。
3. Word 模板设计:占位符语法决定自动化成败
3.1 别用文本替换,用真正的模板引擎
很多人自然想到用 Python 读 Word 后做字符串替换:document.text.replace("${姓名}", name)。我早期也这么干过,结果非常不可控——文本可能被拆散在多个 XML 节点里,replace 匹配不上;替换后原有格式丢失;表格里的文本又是另一套遍历逻辑。这个方向不建议深入研究,直接放弃。
正确的做法是使用专门的 Word 模板引擎。我用得最多的是 docxtpl(它对 Python 比较友好,底层封装了 jinja2 语法和 docx 操作)。如果前端团队介入,也可以用 docxtemplater(JavaScript 版本),语法上的核心思想一致,都是“在 Word 里写占位符标签,渲染时替换/循环/条件输出”。
3.2 基础标签、循环区块、条件区块的写法
Word 模板里最基本的占位符是一对花括号包裹字段名,例如{contract_no}。占位符直接写在 Word 正文里,像写普通文本一样敲进去。正式排版时,你不需要给占位符设置特殊样式,渲染完成后它会继承占位符所在段落的所有格式属性。
循环区块的写法是:用一对特殊标签包裹需要重复的区域。比如合同里需要重复每一行商品明细,就在一个区块内写:
{% for item in items %} 商品名称:{{ item.goods_name }} 数量:{{ item.quantity }} 单价:{{ item.unit_price }} 小计:{{ item.line_total }} {% endfor %}这在 docxtpl 里默认使用 jinja2 语法。重点是,这个 for 区块可以跨段落,也可以包住表格里的整行。我希望在表格里循环时,做法是选中表格的一整行,在行的每个单元格写{{ item.字段名 }},然后在这行的开头和结尾分别放入{% for %}和{% endfor %}标签。注意,这两个标签本身要放在表格行的开头和结尾单元格里,并且要把标签文字设置为白色、字号极小,渲染后不影响排版。
条件区块也常用:比如某个字段非空才输出对应段落,用{% if 字段名 %} ... {% endif %}。这里我提醒一个要点:不要过度依赖条件分支。条件逻辑放多了,模板会变得极其难维护,别人打开模板根本看不懂哪些段落会在什么条件下出现。更好的做法是把条件判断放在脚本里,最终传给模板的 JSON 就是“已经可以展示的数据”。
3.3 模板设计实操:直接在 Word 里一点点排
这次的“实操环节”不在代码里,而在 Word 编辑器里。打开 Word,新建一个文档,先把合同/报告的所有静态段落写出来,标好一级标题、二级标题、正文样式。然后按数据的位置插入占位符:
- 把需要替换的字段名用英文命好,比如
{customer_name}。 - 段落内多次出现同一字段,直接复制占位符,不要手打第二遍,减少单词拼错的概率。
- 花括号必须使用半角符号
{ },如果 Word 的“自动更正”把输入的括号转成了全角中文括号,渲染时一定匹配不上。 - 模板里的占位符最好单独用一个段落管理,或者集中在文档开头加一个“字段清单页”,渲染之前删掉或设置为条件隐藏。
模板制作完成后,我建议专门跑一遍“空数据渲染”——把所有字段都传空字符串或空数组,看模板会不会报错、有没有遗漏占位符。这个动作能筛掉大部分低级问题,而且这个测试模板可以长期保留,每次改模板后都跑一遍当冒烟测试。
3.4 一个值得注意的模板细节:样式继承
Word 里的样式继承逻辑比较特殊:占位符所在段落如果设置了首行缩进,渲染出来的文本同样带首行缩进;如果占位符在表格单元格里,那么文本就沿用该单元格的对齐方式。这个特性既是优点也是坑。
优点在于你可以在 Word 里把版式一次性调好。踩坑点在于:有时模板里看起来字体正常的文字,渲染后却变了字体。原因往往是占位符本身的字符样式被改过,比如手滑给某一个占位符改了字体或字号,结果所有渲染文本都继承了这个字符样式。排查这类问题,鼠标放到占位符上查看样式信息,删除字符级样式覆盖就好。
4. 脚本实现:从 Excel 到 JSON 再到批量 Word
4.1 技术栈选择
前面提到 docxtpl 是我目前的主力方案,它的实现方式是:程序读入一个 .docx 模板,把 JSON 数据传入模板上下文,再渲染生成新的 .docx。核心的模板语法是 jinja2,而 jinja2 本身就是 Python 生态里最成熟的模板引擎,所以这套方案质量和生态都比较稳。
配套的库有:
pandas+openpyxl:读 Excel,处理表格数据。docxtpl:渲染 Word 模板。python-docx:docxtpl 底层依赖,一般不需要直接用,但偶尔要用来做模板检查和文件操作。
安装一句命令搞定:
pip install pandas openpyxl docxtpl python-docx4.2 完整脚本:批量生成合同
下边这个脚本是我实际在模拟项目 X 里跑通过的形态,把一个合同清单转换成几十份独立合同文档。整段脚本的核心流程是:读取 Excel 主表 → 合并子表明细 → 逐条渲染模板 → 按规则命名输出。
import pandas as pd import json from pathlib import Path from docxtpl import DocxTemplate BASE_DIR = Path(__file__).parent EXCEL_FILE = BASE_DIR / "合同数据.xlsx" TEMPLATE_FILE = BASE_DIR / "合同模板.docx" OUTPUT_DIR = BASE_DIR / "output" def load_main_sheet(): df = pd.read_excel(EXCEL_FILE, sheet_name="主表", dtype=str) df = df.fillna("") records = [] for _, row in df.iterrows(): records.append({ "contract_no": row.get("contract_no", "").strip(), "customer_name": row.get("customer_name", "").strip(), "sign_date": format_date(row.get("sign_date")), "amount": format_amount(row.get("amount")), "remark": row.get("remark", "").strip(), }) return records def load_detail_sheet(): df = pd.read_excel(EXCEL_FILE, sheet_name="商品明细", dtype=str) df = df.fillna("") details = {} for _, row in df.iterrows(): contract_no = row.get("contract_no", "").strip() if not contract_no: continue item = { "goods_name": row.get("goods_name", "").strip(), "quantity": row.get("quantity", "0").strip(), "unit_price": format_amount(row.get("unit_price")), "line_total": format_amount( float(row.get("quantity") or 0) * float(row.get("unit_price") or 0) ), } details.setdefault(contract_no, []).append(item) return details def merge_records(main, details): for rec in main: rec["items"] = details.get(rec["contract_no"], []) return main def format_date(value): if not value: return "" try: return pd.to_datetime(value).strftime("%Y-%m-%d") except Exception: return str(value).strip() def format_amount(value): if value in (None, "", "nan"): return "0.00" try: return f"{float(value):.2f}" except Exception: return str(value).strip() def render_documents(records): OUTPUT_DIR.mkdir(exist_ok=True) for rec in records: doc = DocxTemplate(TEMPLATE_FILE) doc.render(rec) # 文件名规则:合同编号 + 客户名 + 日期,避免重名 filename = f"{rec['contract_no']}_{rec['customer_name']}_{rec['sign_date']}.docx" safe_filename = filename.replace("/", "-").replace("\\", "-") doc.save(OUTPUT_DIR / safe_filename) print(f"已生成: {safe_filename}") if __name__ == "__main__": main_records = load_main_sheet() detail_records = load_detail_sheet() all_records = merge_records(main_records, detail_records) with open(BASE_DIR / "preview.json", "w", encoding="utf-8") as f: json.dump(all_records, f, ensure_ascii=False, indent=2) render_documents(all_records)脚本里有两个细节专门说一下。format_amount里对NaN的处理,是因为即便我前面用了fillna(""),某些列如果原表里是数值类型,读出来后仍可能变成nan字符串。不做过滤的话,模板里会出现“客户名称:nan”这种鬼东西。所有字段在进模板前都要经过“清洗 + 转换”,这是数据质量问题,不能让模板去兜底。
render_documents里的文件名规则值得多说一句。合同编号理论上绝对唯一,但现实里数据录入可能重复。所以在文件名里叠加客户名和日期,能显著降低覆盖概率。文件名里不能包含/和\,这是文件系统的保留字符,所以我做了替换。中文、空格、长文件名都没问题,但注意 Windows 下文件名不能超过 255 字符,这在实际业务里一般不会触发。
4.3 两个批量方向的取舍
上面脚本是“一份 Excel 行生成一份 Word 文档”,这是最常见的需求形态。还有一种形态是“一张表生成一本书/一份报告”,比如把所有员工信息循环成一个花名册,整个 Excel 的所有行生成到一个 Word 文件里。两种形态在代码上的差异很小:把render_documents的外层循环去掉,一次只加载一个模板,把 records 作为整个上下文传入即可。
形态取舍的关键在于:文档之间的边界在哪里?如果一批合同每份都是独立文件,就用逐行渲染;如果一批数据最终要合订成一册,就用单文档渲染 + 模板内循环。二者模板设计差异较大,建议在项目启动前期就定下来,不要边做边改。
5. 生产环境里的常见问题与排查实录
5.1 占位符不生效,大部分是模板编辑问题
我遇到过用户反馈“同一个字段在模板里有三个地方,渲染出来只有两个替换了”。排查下来,那是因为第三个占位符所在的文本框里其实是一个“文本框”,docxtpl 对文本框内的内容处理比较特殊,需要额外设置,不是普通段落那样直接支持。
所以我的模板规范里会明确一条:不要在文本框、页眉页脚里放动态占位符。页眉页脚里如果必须有动态内容,比如“本文件编号:{contract_no}”,那就放弃用 docxtpl 生成,改为在生成完文档后用 python-docx 单独改页眉页脚,或者直接让页眉页脚只保留静态信息。
还有一种情况是 Word 自动更正把花括号改成了全角。{变成{,这属于视觉上几乎看不出来的问题,但渲染时必然失败。这种情况没有太好的程序化兜底,只能靠模板检查:渲染前用脚本读模板里的所有文本,正则匹配一遍,找出所有包含“{”或“}”的段落并打印告警。
5.2 中文乱码的真相与对策
JSON 转 Word 时出现中文乱码,绝大多数不是 Word 的问题,而是中间环节编码不一致。如果你查看中间预览 JSON 时中文正常,那渲染环节就不会乱码。如果 JSON 文件里中文直接变成\u5f20\u4e09这种转义序列,那是因为json.dump没加ensure_ascii=False。
还有一个容易踩的坑是脚本文件本身的编码。如果.py文件不是 UTF-8 保存,且代码里含有中文字符串,运行时会直接报SyntaxError或显示乱码。开发工具左下角检查一下文件编码,全部统一成 UTF-8,这个问题就永远不会出现。输出 Word 文档内部本身就是 Unicode 编码,只要数据进入模板时是正常的 Python 字符串,出来的 docx 一定是正常中文。
5.3 表格循环里千万不要合并单元格
我最早试过在模板里做“商品明细表”,想把表头合并出一行“商品信息”,然后下面循环数据行。结果发现,docxtpl 里处理表格内的循环时,对合并单元格支持很差,经常会渲染出多余的空行或者格式错乱。
后来我把思路改成了:表头用普通单元格,数据行用循环区块,表尾放合计。也就是说,模板表格的结构是:一行表头(不循环)+ 一行循环体 + 一行合计(不循环)。这样最稳妥,而且 Word 里的“跨页重复表头”功能还能继续用——循环体长得超过一页时,表头自动出现在下一页顶部,效果很专业。
5.4 模板标签和 JSON 字段对不上,靠校验脚本兜底
模板字段 = JSON key 这一规则,理解起来很简单,但实际项目里很容易出现拼写不一致。比如模板里写的是custom_name,JSON 里是customer_name,差一个字母,渲染后就是一段空白或者报错。
我的做法是写一个小校验脚本:遍历模板中出现的所有{{ 标识符 }}和{% for %}中的循环变量,再去 JSON 的 key 集合里检查,凡是找不到的打出一个警告清单。这个脚本放到每次渲染之前自动执行,一旦模板和数据结构有偏差,立刻报错而不是等到生成 50 份文件后才发现每份都少了字段。
import re from docxtpl import DocxTemplate check_keys = ["contract_no", "customer_name", "sign_date", "amount", "remark", "items"] def extract_template_vars(template_path): doc = DocxTemplate(template_path) xml = doc.get_xml() # 提取所有 {{ name }} 和 {% for name in ... %} vars_found = set() for m in re.findall(r"\{\{\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*\}\}", xml): vars_found.add(m) for m in re.findall(r"for\s+([a-zA-Z_][a-zA-Z0-9_]*)\s+in", xml): vars_found.add(m) return vars_found missing = extract_template_vars("合同模板.docx") - set(check_keys) if missing: print("模板中存在未定义变量:", missing) else: print("模板字段校验通过")这段脚本是简化版,正则匹配在复杂标签场景下可能不够周全,但作为第一道防线已经能拦住大部分拼写错误。如果你希望更严谨,可以在 rendering 完毕后检查输出文档里的字段占位符是否还有残留:用 python-docx 打开输出文件,全文搜索有没有{{或{%残留,只要还有,就说明数据没有覆盖到这些字段。
5.5 浮点数金额的 0.1 + 0.2 问题
钱这个东西不能随便用浮点数。几个字段单独看可能都觉得没问题,一算小计、合计,浮点误差就出来了。比如19.99 * 3在计算器里是59.97,在 Python 里可能是59.969999999999999。
进模板前一定要做金额格式化。你不能只在展示层round一下就完事,因为round可能在任何步骤被丢弃。最稳的路径是:金额列从一开始就用Decimal解析,所有乘除用Decimal完成,最终字符串化只保留两位小数。我之前代码里用了f"{float(value):.2f}",在大多数业务场景下够用,但如果你对金额有严格精度要求,建议改成Decimal全套处理,订单行小计、合计都走同一套逻辑。
5.6 输出文档后续编辑问题
模板引擎渲染出的 docx 是“一次性成品”,不是“可编辑模板”。如果用户想对生成结果做小修改,直接改输出文件没问题。但如果修改之后还想再跑一次脚本,那修改内容肯定会被覆盖,因为脚本每次都是从模板重新生成的。
这一点要在项目启动时给业务方说清楚:模板是模板,输出是输出,两者不要混用。业务方如果频繁在输出文件上修订固定内容,说明模板里缺了某个固定字段,应该反馈需求把缺失内容补进模板,而不是不断手工改输出文档。这样迭代几轮后,模板会越来越接近“零修订”状态。
6. 把方案推广到更多文档场景
这套 Excel + JSON + Word 模板的流程,可以复用到非常多的场景。我列几个典型的:
- 采购订单/发货通知:主表是订单头,子表是行项目,一套流程直接出客户版 PDF(其实先出 docx,再转 PDF 也行)。
- 员工录用通知书:主表里每个人名、岗位、薪资、入职日期不同,套同一个模板,再按“姓名+入职日期”命名文件。
- 测试报告/质检报告:模板里嵌入产品图片,数据里放图片路径,循环输出每一批次的结果。图片的插入用 docxtpl 的
{{ my_image }}语法,配合 `