用Python批量生成PDF任务表单:字体处理与自动合并 做任务表单这件事我一开始也走过弯路。最早期帮业务部门做月度任务单他们用的是Word模板每次人员、事项、时间一变就要复制粘贴、手动调对齐、再另存成PDF几十张表改下来一个下午就没了。后来我尝试用Python生成PDF任务表单把数据源换成Excel或CSV脚本跑一遍几十张带编号、带签名的表单一次出齐版式稳定、文件名统一、还能顺手合并成一个总册。这篇文章就把我实际验证过的做法完整写出来从库的选型、中文字体处理到批量生成和合并PDF覆盖一条可以直接照抄的链路。1. 为什么这个场景值得用脚本直接另存为的账算不过来1.1 表单每改一版整批都要重来任务表单和普通文章不同它本质上是个结构化单据任务编号、负责人、截止时间、事项列表、签字栏每一项都是固定的格子。这种单子最麻烦的地方在于业务部门的格式一年可能改好几次有时候仅仅是把“交付物”这一栏加宽 2 毫米有时候是新增一列“验收标准”。如果全靠手工维护Word模板每次调整格式就等于把已填好的几十份内容重新排版一遍耗时不短还容易漏改。用脚本之后格式和内容就分开了。模板形状、字体字号、行高位置都写在代码里调整版式时只需要在代码里改一次列宽或加一个栏位然后重新运行生成脚本所有任务单同步更新。这个“改一次、到处生效”的收益在批量场景里会随着表单数量的增加被放大得非常明显。1.2 数据在不同表格间来回搬运错漏太多更隐蔽的痛点是数据搬运。任务单里的信息通常不是凭空想象的而是来自Excel计划表、工单系统导出的CSV或者钉钉表单收集结果。手工填单时人要从源表格逐行读出来再誊到另一份单据里字段一多就会看错行、写错人、搞混时间尤其是任务编号这种长字符串肉眼复制很容易漏一位数字。把“搬运”交给程序之后数据只在内存里流通一次从源文件读取后直接渲染进PDF对应格子。程序写得好一点还能在生成前做必填项校验比如发现负责人字段是空的就报错而不是等打印出来才发现问题。1.3 分发和打印环节PDF是成本最低的载体很多人会问既然数据最终要展示为什么不用Excel直接发出去因为任务单是要打印、签字、拍照回传的Excel文件在不同电脑上打开字体可能变、行高会跑版接收方一个误操作改了单元格内容责任就说不清了。PDF跨平台版本稳定能锁定版式打印效果和屏幕预览一致也方便按任务编号归档是这类业务单据比较稳妥的载体。所以我对这类需求的判断标准很简单凡是“格式固定、数据重复、数量批量”的单据都值得考虑用Python生成。一次性偶尔填一两张的不需要上脚本但超过 20 张或者每周都要做一遍脚本的节省效果就非常明显。2. 生成PDF的库怎么选四类方案过一遍2.1 fpdf2轻量画布固定版式首选fpdf2 是原 FPDF 的持续维护版本安装包小、依赖简单核心思路很像在一张白纸上用坐标和单元格画图宽度、高度、边框、对齐全部由代码控制。对于任务表单这种“格子化”的版式它上手非常直接你不用理解复杂的文档流只要算好列宽和行高一格格往下放内容就行。它和旧版 fpdf 的区别要注意老的fpdf包已经停止维护安装时要用pip install fpdf2。新版 API 里cell方法的位置参数也发生了变化旧版用ln1表示换行新版更推荐new_xXPos.LMARGIN, new_yYPos.NEXT这种方式后面代码里我会按新版写法给出可运行示例。2.2 reportlab能力全面但上手坡度偏大reportlab 是Python PDF生成领域的老牌库功能非常全制作复杂报表、画矢量图形、精确控制文本流都没问题而且性能好。但它的学习曲线也相对陡尤其是 Platypus 文档框架里Table、Paragraph、Frame这些概念第一次接触时会有种“我在写文档对象树”的违和感。对我来说reportlab 更适合那种需要高度自定义、甚至要做交互式表单字段的出版级场景。任务单这种业务表格用 reportlab 属于“杀鸡用牛刀”可维护成本反而偏高如果你对它的 API 不熟调整一个行高都要翻半天文档。2.3 WeasyPrint用HTML/CSS写表单复杂版式很舒服WeasyPrint 的原理是把 HTML 和 CSS 渲染成 PDF这意味着前端工作者会很亲切。你写一个table用 CSS 控制边框、列宽、跨页行为它就能输出漂亮的PDF复杂多列表格、自动换行、表头跨页重复这些能力都是现成的。代价是它依赖系统层的 Pango、Cairo 等库在干净的 Linux 服务器上需要额外安装系统依赖Windows 下部署也略麻烦一点。如果公司有现成运维支持或者你本来就擅长写 HTML 模板这个库非常适合做“复杂表头说明段落签批区域”的任务单。后面我会把它作为 fpdf2 之外的备选方案来对比。2.4 pdfkit容易被前置命令拖累pdfkit 本身只是个封装真正干活的是 wkhtmltopdf 这个外部命令。早期我图省事用过它因为可以用 HTML 模板渲染效果接近浏览器打印。但问题在于对方机器上必须提前装好 wkhtmltopdf 的可执行文件打包、部署、升级都多一层麻烦不同版本渲染出来中文间距还会不一致后来就慢慢放弃它了。除非只是本机临时转换且团队能接受外置依赖否则我不建议在新项目里引入。2.5 选型结论库上手难度中文字体表格控制依赖情况适合场景fpdf2低手动注册字体文件代码逐格控制轻量纯Python固定版式、批量任务单reportlab中高支持文档流表格对象轻量纯Python复杂出版级别排版WeasyPrint低会HTML就行依赖系统字体渲染CSS天然支持需系统库复杂表格、动态样式pdfkit低依赖外部命令HTML转PDF外部命令不推荐新用我的实际建议是项目里如果只是“固定版式 CSV批量数据”走 fpdf2 就好如果版式本身很复杂甚至需要根据数据动态隐藏栏位那就直接用 WeasyPrint它写起来更像维护网页模板。3. 先用fpdf2跑出一张可用的任务单3.1 字体准备与页面初始化任务单要处理中文第一步永远是解决字体问题。fpdf2 内置的字体不支持中文如果不注册中文字体输出的内容会是一串方块或乱码。我通常用的是文泉驿微米黑或思源黑体前者在 Linux 的字体目录里很容易找到后者可以直接从字体项目下载。下面以wqy-microhei.ttc为例如果你用的是NotoSansSC-Regular.otf路径和字体名替换一下即可。from fpdf import FPDF from fpdf.enums import XPos, YPos FONT_PATH wqy-microhei.ttc # 按实际路径修改 class TaskSheet(FPDF): def __init__(self): super().__init__(orientationportrait, unitmm, formatA4) self.add_font(cjk, , FONT_PATH) self.set_auto_page_break(autoTrue, margin15) self.set_margins(15, 15, 15)页面设置这里A4 尺寸是 210mm × 297mm左右边距 15mm那么内容区宽度就是 180mm后面所有列宽相加必须等于 180否则版式就会错位。set_auto_page_break打开后内容超过页面底部会自动换页这对较长的任务说明很有用。3.2 标题、页眉页脚与基本信息区FPDF 提供了header()和footer()两个钩子方法每次换页都会自动调用。把任务单标题放在header()里每页顶部都会出现适合后续内容跨页的情况。def header(self): self.set_font(cjk, , 22) self.cell(0, 12, 任 务 表 单, alignC, new_xXPos.LMARGIN, new_yYPos.NEXT) self.ln(4) def footer(self): self.set_y(-15) self.set_font(cjk, , 8) self.cell(0, 10, f第 {self.page_no()} 页, alignC)基本信息区是“任务编号、任务名称、负责人、紧急程度、发布日期、截止日期”这种键值对。我习惯用一个 info 列表存放数据每项由标签和值组成运行时循环画两个格子左边标签固定宽 32mm右边值占 148mm加起来正好 180mm。def info_block(self, info): self.set_font(cjk, , 10) for label, value in info: self.cell(32, 9, label, border1, alignC, new_xXPos.RIGHT, new_yYPos.TOP) self.cell(148, 9, value, border1, alignL, new_xXPos.LMARGIN, new_yYPos.NEXT) self.ln(4)这里有个细节第一格cell设置new_xXPos.RIGHT意思是单元格结束位置留在右边而不是回到行首第二格cell设置new_xXPos.LMARGIN, new_yYPos.NEXT结束之后回到左边界并换行。这样两格就在同一行并排显示。3.3 任务内容表格任务表单的核心是任务事项列表。我用三列表格序号、任务内容、交付物或备注。列宽分别是 14mm、96mm、70mm加和也正好是 180mm。def item_table(self, items): self.set_font(cjk, , 10) headers [序号, 任务内容, 交付物/备注] widths [14, 96, 70] for i, w in enumerate(widths): self.cell(w, 9, headers[i], border1, alignC, new_xXPos.RIGHT, new_yYPos.TOP) self.ln(9) for idx, content, remark in items: self.cell(widths[0], 8, str(idx), border1, alignC, new_xXPos.RIGHT, new_yYPos.TOP) self.cell(widths[1], 8, content, border1, alignL, new_xXPos.RIGHT, new_yYPos.TOP) self.cell(widths[2], 8, remark, border1, alignL, new_xXPos.LMARGIN, new_yYPos.NEXT)第一版先假设每个字段都很短单行放得下不做自动换行。真遇到长文本溢出的问题第六节我会专门讲处理办法。先把能跑通的版本立起来再逐步加固是我做这类脚本的习惯。3.4 注意事项、签字区与第一版输出任务单底部要留签字位置和备注说明我用带下边框的空单元格来模拟签字横线。创建主流程时依次调用 info_block 和 item_table最后画签字栏pdf TaskSheet() pdf.add_page() info [ (任务编号, TASK-20250601-001), (任务名称, 机房设备巡检), (负责人, 陈工), (紧急程度, 高), (发布日期, 2025-06-01), (截止日期, 2025-06-07), ] items [ (1, 检查主交换机运行指示灯记录异常状态, 巡检记录表), (2, 核对机房温湿度并拍照留存, 温湿度记录), (3, 测试备用电源切换是否正常, 切换测试报告), (4, 整理当日巡检信息并提交, 巡检汇总邮件), ] pdf.info_block(info) pdf.item_table(items) pdf.ln(12) pdf.set_font(cjk, , 10) pdf.cell(45, 10, 负责人签字) pdf.cell(50, 10, , borderB) pdf.cell(45, 10, 验收人签字) pdf.cell(50, 10, , borderB) pdf.output(task_example.pdf)运行之后你会得到一张带有标题、信息区、任务表格和签字栏的单页PDF。第一版不必追求完美重点是验证字体、宽度计算和输出流程能走通。实际输出后建议先打开PDF目测一段确认不出现中文字体异常再继续做批量。4. 中文字体和排版最容易翻车的三个地方4.1 字体不嵌入换台设备就乱PDF 和 Word 不同它不是一个“可编辑排版文档”而更像是排版后的快照。如果生成时没有把字体嵌入文件内部观看者的电脑上又没有安装对应字体PDF阅读器就会用系统默认字体替代结果可能是行距变化、文字变宽甚至某些字符变成空白方块。fpdf2 的add_font会默认把字体文件嵌入PDF这点比较省心。你需要操心的是字体文件的版权和部署如果随手把系统的微软雅黑字体文件拷到服务器上用严格来说在商用环境可能涉及授权问题更稳妥的方式是用开源的思源黑体、文泉驿微米黑或者阿里巴巴普惠体这类可以免费商用的字体。服务器部署时单独建一个 fonts 目录存放正版字体文件避免依赖操作系统自带的字体路径。4.2 行高太挤汉字会被拦腰截断西文字母的尺寸比例和汉字差别很大同一字号下汉字通常更高如果cell行高给得太小渲染结果会出现内容被裁掉的现象。我早期用cell(h5)放过 10pt 字号的中文底部笔画直接少了一截不细看还以为是字体问题。给中文字体留行高时我的经验是10pt 字号约 3.5mm搭配至少 8mm 行高比较稳如果一行里既有汉字又有字母下划线行高给到 9mm 更安心。任务单的格子高度可以统一取整比如信息区 9mm、表格内容行 8mm既不会太挤也不会让整张表看起来松散。4.3 全角/半角混排时对齐全靠算中文是全角字符每个字占的宽度是固定倍率数字和英文字母是半角宽度又和具体字符有关。混排之后如果想靠空格对齐列几乎不可能因为空格本身在不同字体里宽度就不一样。我处理这个问题的办法很简单设计固定列宽不用空格对齐。标签列宽统一为 32mm内容列就是剩余宽度列宽之和严格等于内容区总宽度。遇到非等宽导致的参差宁可调整列宽也不要靠加空格凑。fpdf2 提供了get_string_width()方法可以先用它计算字符串实际宽度再做列宽调整比如预计文字会超出格子时提前换行或压缩字号。5. 从一张到一百张批量生成完整流程5.1 先设计好任务数据接口批量生成前先得把任务数据从“所见即所得的Word表单”抽象成结构化字典。我常用的任务单字段如下task { task_id: TASK-20250601-001, task_name: 机房设备巡检, owner: 陈工, urgency: 高, publish_date: 2025-06-01, deadline: 2025-06-07, items: [ (检查主交换机运行指示灯, 巡检记录表), (核对机房温湿度并拍照留存, 温湿度记录), ] }这个字典就是代码和信息区的数据接口以后无论是从CSV读取、从数据库查还是接到线上工单接口最终都拼成这个结构再交给渲染函数。5.2 读CSV并循环生成PDFCSV 是最容易和业务方对接的文件格式。Excel 另存为 CSV 的时候默认会带 BOM所以打开文件用utf-8-sig编码比较稳妥否则第一列列名会多出一个看不见的\ufeff字符。import csv from pathlib import Path output_dir Path(output) output_dir.mkdir(exist_okTrue) with open(tasks.csv, encodingutf-8-sig) as f: reader csv.DictReader(f) for row in reader: pdf TaskSheet() pdf.add_page() pdf.info_block([ (任务编号, row[task_id]), (任务名称, row[task_name]), (负责人, row[owner]), (紧急程度, row[urgency]), (发布日期, row[publish_date]), (截止日期, row[deadline]), ]) # items 若是 JSON 字符串需先解析 items row[items] # 实际可按约定解析 pdf.item_table(items) pdf.ln(12) pdf.set_font(cjk, , 10) pdf.cell(45, 10, 负责人签字) pdf.cell(50, 10, , borderB) pdf.cell(45, 10, 验收人签字) pdf.cell(50, 10, , borderB) pdf.output(output_dir / f{row[task_id]}.pdf)任务内容如果是多行事项我建议在CSV里用一行一个事项的格式或者用分号分隔后按行拆开再用multi_cell渲染避免把所有事项堆在一个格子里。5.3 文件名、目录和脏数据兜底批量生成最容易翻车的反而是文件命名。任务编号里可能包含/、:这些字符直接拼到文件名里Windows 会直接报错。我通常做一次清洗import re def safe_filename(value): value re.sub(r[\\/:*?\|], _, value) return value.strip() or unnamed_task另外同一份任务编号重复出现时后来的文件会覆盖之前的输出。防止数据误用我会在生成前加载所有任务编号集合发现重复直接报错或者自动追加后缀。脏数据也要兜底日期为空、负责人为空都会导致表单内容残缺。实际问题中我发现空字段比格式错误更常见所以在批量脚本里加一个最小校验比如负责人和任务名称两个字段为空就直接跳过并记录警告而不是硬着头皮生成废单。有一个我个人的习惯很有效把每次生成的时间戳写进文件名前缀例如batch_20250601_1530作为输出目录名。这样同一批任务单的重新生成不会覆盖历史记录遇到归档问题可以直接回退到上一批结果。5.4 合并单张PDF成总册几十张独立PDF管理起来并不方便尤其是要发给上级部门汇总的时候一个压缩包里几十个文件反而增加沟通成本。我习惯在批量生成完之后再把所有单张PDF合并成一个总册。用pypdf也就是原来的 PyPDF2 的维护版本很直接from pypdf import PdfWriter writer PdfWriter() pdf_files sorted(output_dir.glob(*.pdf)) for pdf_file in pdf_files: writer.append(str(pdf_file)) writer.write(merged_tasks.pdf)PdfWriter.append()会把对应PDF的每一页追加进去合并后每个任务单自然分段不影响各自版式。注意文件名排序时要统一编号格式如果任务编号是TASK-...-10和TASK-...-2字符串排序会不理想前缀补零更稳妥。如果你的表单版式比较复杂比如不同任务需要动态隐藏某些字段我更建议用 WeasyPrint 做批量模板。CSS 天生支持display: none和复杂表格调整样式的成本比 fpdf2 低很多前提是服务器能装好系统依赖。6. 这几个细节最容易被忽视我踩过之后都补上了6.1 表格跨页时表头消失信息区或任务内容超过一页时第二页的表格顶部默认不会有表头阅读时读者会突然不知道每列代表什么。fpdf2 的解决办法是把表头绘制放进header()里并在绘制普通行之前判断当前页码只有到达新页时才重置列位置。更省事的路径是直接用 WeasyPrintHTML 表格的thead区域在跨页时会自动重复这也是我推荐复杂表格用它的一大原因。6.2 长文本把单元格挤破固定行高遇到长文本轻则文字溢出边框重则整行错位。fpdf2 的multi_cell支持多行文本但它的换行逻辑是按英文单词宽度来的中文没有空格效果不完全可控。我补了一个简单的估算函数按可显示字符数和列宽估算行数再动态计算总行高确保多行内容有足够的空间。实际操作里任务内容超过 50 个字的情况并不少见。如果不想在代码层面做复杂的换行计算我的习惯是“源头控制”生成任务单之前要求填写人在CSV里按要点分行每个事项控制在 30 字以内展示效果会比程序自动硬换行更干净。6.3 特殊符号显示成方块任务单里常见的勾选框很多人直接用一个☑字符或者□字符画上去。这类符号不是所有字体都自带的一旦字体文件里没有对应字形PDF里就会显示成空方块。我最稳妥的方案是不依赖字符而是用图形绘制。fpdf2 的rect()可以画空心矩形几行代码就能画出一个规整的勾选框无论观众用什么设备打开都不会错。同样的道理也适用于对勾标记画两条线比找一个通用字符更可靠。6.4 顺手把PDF的元数据补齐任务表单属于业务凭证建议在一开始就设置好文档属性。fpdf2 里这样写pdf.set_title(任务表单 TASK-20250601-001) pdf.set_author(自动化任务系统) pdf.set_subject(任务执行与确认)WeasyPrint 则可以通过 HTML 的title标签和meta nameauthor content...实现。虽然元数据对显示效果没有直接影响但归档、检索、批量识别用途时它能让外人一眼看出这份PDF的来源和版本这种细节在交付中很加分。这几个问题单独看都不致命但集中出现在一张单子上会让人觉得这份表单“不专业”。我在实际项目里基本都会把这四条作为固定检查项打开生成的PDF翻到第二页看表头扫一眼长文本是否越界确认勾选框是否显示正常再右键属性看看文档信息全部通过才批量交付。