☰
Python自动化生成PDF任务表单:基于fpdf2的批量出表实践
2026/10/15 15:20:31 网站建设 项目流程

做项目管理和流程审批的时候,最烦的事情之一就是手工做任务表单。明明是一个结构固定的文档,却要反复打开Word调整格式,复制粘贴内容再导出PDF,任务一多,光整理表单文件就能耽误半天时间。后来我把这个流程改成Python脚本自动生成以后,只需要维护一份任务数据,无论是单个生成还是批量输出,几十份格式统一的PDF任务表单几十秒内就能全部搞定。这个项目真正解决的问题,就是把“人工排版PDF”变成“数据驱动生成PDF”。

这个项目本身是一个基于Python的PDF自动生成工具:输入是任务的基础信息,输出是格式规范、可直接打印归档的任务表单PDF。整个实现围绕轻量级库fpdf2展开,配合真实的中文字体处理、坐标排版和分页控制,能在不依赖Office环境的前提下稳定产出符合日常需求的表单文件。适合有Python基础的后端开发、项目助理、流程管理人员参考,也适合任何想把自己手头报表生成流程自动化的人。下面我把方案选型、版面设计、核心代码、踩坑记录完整整理出来,看到最后至少能少踩一半坑。

1. PDF生成工具怎么选:主流路线与我的落地选择

1.1 四种路线的核心差异对比

Python生态里生成PDF的路子不少,真正常用的可以归成四类:基于绘图原语的库、基于模板渲染的库、基于HTML/CSS转换的库、基于PDF对象操作的库。

技术路线典型代表上手难度中文支持适合场景
绘图原语fpdf2、reportlab中等需手动注册字体结构固定的表单、票据、报表
模板渲染jinja2 + weasyprint中等依赖CSS字体设置复杂排版、带样式丰富的页面
HTML转PDFweasyprint、wkhtmltopdf较高需要环境依赖网页截图式报告
PDF对象操作PyMuPDF、pypdf较高一般读取、合并、拆分既有PDF

reportlab是很多人第一反应会想到的,功能非常强,表格模块做复杂报表很顺手,但它的API设计偏底层,写文档要多记住一堆对象属性,对只想快速产出几张表单的场景来说有点牛刀杀鸡。weasyprint走的是HTML+CSS渲染路线,排版能力最强,缺点是部署时需要额外的系统库支持,在纯内网服务器上安装不一定顺畅。PyMuPDF更多是用来读和改PDF,而不是从零造一份表单。

fpdf2真正占便宜的地方是轻巧和直接。它面向的恰恰是“版面已知、字段固定、批量生成”这类表单场景,API足够简单,同时又保留了对坐标、字体、绘图的完整控制权。对任务表单来说,不需要复杂排版引擎,需要的就是把每个区块按确定位置画出来,这一点fpdf2几乎是最高效的选择。

1.2 选fpdf2的落地理由

选型不能只看功能列表,还要看实际维护成本和运行环境。我最终落地fpdf2,主要是这几点考虑:

第一,依赖极少。fpdf2的核心安装包只有几MB,不需要安装额外系统组件,也不依赖浏览器内核,特别适合放在无GUI的服务器上用定时任务跑批。

第二,中文字体处理直白。只要有一份ttf格式的中文字体文件,注册以后就能正常显示。这在某些部署环境里很关键,因为服务器系统不一定预装了合适的中文字体库,用reportlab时如果没配好字体文件,中文输出会经常翻车。

第三,coordinated坐标体系符合直觉。fpdf2默认以毫米为单位定位元素,比如set_x、set_y、cell这些方法都用数字就能精确控制位置,画表格线、排信息行非常直观。比起用像素和磅值换算,毫米单位在A4版面设计上省掉很多脑力。

第四,它内置了自动分页机制。开启自动分页后,文本到了页面底部附近会自动换页,保证内容不会溢出,这对批量生成多个任务表单尤其重要。

另外也提一句,如果任务表单里有很多动态表格列、复杂页眉页脚、带嵌入图片的模板,我会重新考虑weasyprint。但就绝大多数任务表单而言,fpdf2是投入产出比最高的选择。

2. 任务表单的内容结构与版面布局设计

2.1 表单信息区块拆解

设计表单之前,先把一份合格的任务表单里必须出现的元素列清楚。我看过很多团队的模板,内容五花八门,但核心信息基本跑不出下面这些区块:

  • 标题区:表单名称、表单版本号或二维码标识。
  • 基本信息区:任务编号、任务名称、负责人、协办人、优先级、开始日期、截止日期。
  • 任务描述区:目标任务、背景说明、验收标准、备注。
  • 进度与节点区:里程碑节点、计划时间、实际时间、完成状态。
  • 审批与签字区:提交人签字、审批人意见、签字日期。

如果把所有字段都排在一页,视觉上会很拥挤。实际编排时采用“两列信息行+单列描述区+表格区+签字区”的组合方式:基本信息按两列排,阅读效率高;任务描述需要足够书写空间,必须铺满整行;进度节点用表格承载;审批区放在最底部,方便打印后手写签字。

2.2 页面尺寸、边距与坐标换算

任务表单通常用A4纸,尺寸是210mm x 297mm。fpdf2初始化时指定毫米为单位:

pdf = FPDF(orientation="P", unit="mm", format="A4")

页面物理宽度是210,不用纠结像素、DPI,直接按毫米思考。设计版面时我习惯把左右边距统一设为15mm,上下边距设为18mm,这样内容区域宽度是210 - 15 - 15 = 180mm。所有元素的横向定位都基于这个180mm去计算,不会出现右边溢出。

纵向布局上,标题区占约25mm,信息区占约30mm,任务描述区约50mm,进度表格区约50mm,签字区约35mm,剩余空间留给页边距和留白。这不是硬性规定,而是一个经验模板。任务描述文字多时,需要把描述区高度调大,可以通过简单估算行数来动态决定,后面代码部分会详细说。

坐标系统的关键点在于:y轴从页面顶部往下增长,初始add_page后光标位置在左上角。使用set_xy可以一次性设置当前绘制起点,再用cell、multi_cell往右往下铺内容。理解了这套逻辑,布局就是数学问题:确定每个区域的x起点、宽度、y起点、高度,再依次摆放即可。

3. 核心代码实现:从建文档到批量输出

3.1 初始化文档和中文字体注册

解决中文字体是第一步。fpdf2默认的字体不支持中文,必须手动注册一个支持中文的ttf字体文件。我这里用的是思源黑体Regular和Bold两个文件,放在脚本同级的fonts目录下。

from fpdf import FPDF pdf = FPDF(orientation="P", unit="mm", format="A4") pdf.set_margins(left=15, top=18, right=15) pdf.add_font("NotoSansSC", "", "fonts/NotoSansSC-Regular.ttf") pdf.add_font("NotoSansSC", "B", "fonts/NotoSansSC-Bold.ttf")

注册之后,每次设置字体都要调用set_font,格式是三个参数:字体族名、样式、字号。Bold对应粗体,普通文字用空字符串表示。写标题和表头时切到Bold,正文回归常规即可。

有一点要注意:不同版本的fpdf2,add_font的参数形态略有差异。老版本fpdf 1.7的风格是add_font('字体名', '', 'xxx.ttf', uni=True),而fpdf2从2.x开始把uni参数取消了。如果你照搬网上老代码发现报错,优先检查fpdf2版本,使用pip show fpdf2看看当前版本。

3.2 标题、信息行与分割线绘制

页面新建后,先画标题。标题横向居中是基本操作:

pdf.add_page() pdf.set_font("NotoSansSC", "B", 16) pdf.cell(180, 12, "任务表单", align="C", new_x="LMARGIN", new_y="NEXT")

cell的参数里,180是宽度,12是高度,new_x和new_y控制绘制完成后光标去向。new_x="LMARGIN"表示水平回到左边界,new_y="NEXT"表示垂直移动到下一行,这样后续元素就不会和标题重叠。

然后是基本信息区,这里有一个节省时间的技巧:把字段按两列一排处理。每行左右各放一组“标签+值”,每格都用边框,打印出来就是一张规整的表格。

info_lines = [ ("任务编号", task["no"]), ("任务名称", task["name"]), ("负责人", task["owner"]), ("协办人", task["collaborator"]), ("优先级", task["priority"]), ("截止日期", task["deadline"]), ] for i in range(0, len(info_lines), 2): y = pdf.get_y() pdf.set_xy(pdf.l_margin, y) label, value = info_lines[i] pdf.set_font("NotoSansSC", "B", 10) pdf.cell(28, 8, label, border=1) pdf.set_font("NotoSansSC", "", 10) pdf.cell(62, 8, value, border=1) if i + 1 < len(info_lines): pdf.set_xy(pdf.l_margin + 90, y) label, value = info_lines[i + 1] pdf.set_font("NotoSansSC", "B", 10) pdf.cell(28, 8, label, border=1) pdf.set_font("NotoSansSC", "", 10) pdf.cell(62, 8, value, border=1) pdf.ln(8)

左侧格子宽28,右侧值格子宽62,两列之间的起始差值是90mm,正好是28 + 62 = 90。左右两列合计宽度是90 + 28 + 62 = 180,刚好占满内容宽度。这种“先算后画”的方式能避免元素错位。记住一个原则:在PDF坐标系统里,横坐标加法一定要盯住画布总宽度。

任务名称这类字段内容可能超过格子宽度,直接塞进cell会被截断或挤压。实际使用中,任务名称特别长的情况,可以把任务名称单独拆成一行,用multi_cell换行显示。我一般会把名称放在另一行处理,让名称有足够的展示空间。

3.3 任务描述的多行文本处理

任务描述是最容易翻车的地方。如果直接使用cell把一整段文字丢进去,文字不会自动换行,只会溢出或截断。这里必须用multi_cell:

pdf.set_font("NotoSansSC", "", 10) pdf.multi_cell(180, 7, task["description"], border=1)

宽度传180表示占满整行,高度7表示每行文本的行高。multi_cell会自动根据文字内容和宽度换行,并且能正确处理中文字符,这是它和cell最关键的区别。

但这里有个隐藏问题:multi_cell的高度是动态的,后面要画分割线或下一个区块时,必须先知道这段描述实际占了多少高度。fpdf2提供了一个dry_run参数,可以提前计算这段文字会分成几行,而不实际绘制:

lines = pdf.multi_cell(180, 7, task["description"], dry_run=True, output="LINES") desc_height = len(lines) * 7

拿到desc_height后,就能动态决定页面是否需要提前分页。比如当前y位置到了250mm,而desc_height有60mm,那就该在画描述之前主动新建一页,否则内容会冲破页面底部。

另外,任务描述里如果用户输入了换行符,multi_cell也会识别并换行。不过需要注意,如果原始数据里有很多连续换行,生成的PDF版面中间会空出一大块,看起来不太整洁。我一般在写入前会把多行文本里的连续空行压缩成一个换行符。

3.4 进度表格的绘制与自动分页

进度节点表格我选择手动绘制,而不是依赖复杂表格组件。原因是任务表单的表格结构固定,手动画起来更直观,也能完全控制分页行为。

table_data = [ ["节点名称", "计划日期", "实际日期", "状态"], ["需求确认", task["plan_req"], task["actual_req"], "已完成"], ["UI设计", task["plan_ui"], task["actual_ui"], "进行中"], ["后端开发", task["plan_dev"], task["actual_dev"], "未开始"], ] col_widths = [45, 30, 30, 15] row_height = 8 for row_index, row in enumerate(table_data): x = pdf.l_margin for col_index, text in enumerate(row): pdf.set_xy(x, pdf.get_y()) if row_index == 0: pdf.set_font("NotoSansSC", "B", 10) else: pdf.set_font("NotoSansSC", "", 10) pdf.cell(col_widths[col_index], row_height, text, border=1, align="C") x += col_widths[col_index] pdf.ln(row_height)

这套逻辑最核心的地方是每一格都先set_xy到当前坐标,再绘制一个固定大小的cell。内层循环结束后用ln将光标移回下一行。列宽之和120mm,相比内容宽度180mm偏窄,如果需要更宽可以加大列宽,但要注意四列总和不能超过180mm。

分页是这个环节最容易出问题的地方。fpdf2开启了set_auto_page_break以后,文字输出到页面边缘会自动分页,但手动绘制表格时,如果当前y位置接近底部,画到一半就会跨页,表格线在第二页没有延续。我的处理方式是绘制表格前先判断剩余空间:

if pdf.get_y() + (len(table_data) * row_height) > pdf.page_break_trigger: pdf.add_page()

page_break_trigger是fpdf2自动分页的触发高度,默认是页面底部减去边距。加上这层判断后,表格要么完整出现在当前页,要么整体挪到下一页,不会出现劈叉情况。

如果表格行数很多,需要在第二页重复表头,那就要在add_page之后重新画一遍表头行,再继续输出数据行。这个逻辑可以用一个变量记录是否已经输出表头,在循环里判断。

3.5 从数据列表批量生成PDF

批量生成是这套脚本真正的价值所在。维护一个任务列表,循环调用封装好的生成函数即可:

from io import BytesIO def generate_task_pdf(task: dict) -> bytes: pdf = FPDF(orientation="P", unit="mm", format="A4") pdf.set_margins(left=15, top=18, right=15) pdf.add_font("NotoSansSC", "", "fonts/NotoSansSC-Regular.ttf") pdf.add_font("NotoSansSC", "B", "fonts/NotoSansSC-Bold.ttf") draw_header(pdf, task) draw_info_section(pdf, task) draw_description(pdf, task) draw_table(pdf, task) draw_signature(pdf) add_page_number(pdf) buffer = BytesIO() pdf.output(buffer) buffer.seek(0) return buffer.getvalue() for task in task_list: pdf_bytes = generate_task_pdf(task) with open(f"output/{task['no']}.pdf", "wb") as f: f.write(pdf_bytes)

输出目录建议按日期创建子目录,方便归档。对接Web接口时,直接把BytesIO内容作为HTTP响应体返回,指定media_type="application/pdf"和Content-Disposition,前端就能直接下载或预览。

这里有个小经验:生成函数不要直接写死文件名和路径,而是返回bytes,让上层决定是存文件还是走网络响应。这样脚本既能本地跑批,也能丢进Flask或FastAPI服务里复用,后期扩展不用改核心逻辑。

4. 样式细节与避坑心得:字体、坐标、分页

4.1 中文字体的三个关键细节

中文字体是这套方案里翻车率最高的地方,我总结下来有三点必须注意。

字体文件务必使用ttf或otf格式,并且确认字体本身覆盖中文区。有些看起来很正常的英文字体,注册成功但中文全显示成方框,原因就是字体里根本没有中文字形。稳妥做法是先看字体文件大小,一般支持中文的完整字体在8MB以上,低于这个规格的就要留个心眼。

字体注册名称要统一。add_font时第一个参数是字体族名,这个名字是自定义的,但set_font时必须完全一致,连大小写都不能差。我习惯都用英文名加版本号,避免混用中文名导致编码问题。

嵌入方式需要关注。fpdf2默认开启字体子集化,生成的PDF里只保留用到的字符子集,文件体积会小很多。这可以手动确认一下,如果发现生成文件很大,优先检查是不是subsetting被关了。

最后,字体文件路径不要用相对路径模糊匹配,尤其当脚本被定时任务调度时,工作目录可能不在脚本目录下,导致字体找不到。建议用pathlib.Path(file).parent来定位字体文件绝对路径,确保任何启动方式都能读到。

4.2 坐标错位与内容溢出排查

坐标错位是另一个高频问题,根源通常是对光标位置理解不到位。cell和multi_cell用完后,光标位置会发生改变,如果随意使用set_y而不把x恢复,后续内容就会往右偏移。我规定自己在每个区块绘制前都执行一次set_xy,明确指定起点,不依赖上一次操作留下的光标坐标。

pdf.set_xy(pdf.l_margin, pdf.get_y())

这一行的意思是:x回到左边距,y保留当前位置。它像“段落开头”一样,保证每个区块都从行的起点开始排布,避免累积偏移。

另一个容易踩的是分页后坐标混乱。自动分页后光标会跳到新页面顶部,如果此时仍用旧的y值继续绘制,元素可能跑到页面之外。所有容器型内容都建议放在分区函数里,每个函数内部开头重新定位,并用get_y动态读取当前光标位置,而不是缓存固定数值。

内容溢出方面,只要记住一个核心:cell不换行,multi_cell才换行。所有可能超过格子宽度的文本,都不该用cell硬塞。单元格内容如果必须居中且不换行,那么在写入前用字符串截断或缩略处理,把内容长度控制到格子宽度能容纳的范围内。

4.3 表格高度估算与自动换页

手动绘制表格最怕数据量大了以后跨页。除了之前提到的整块判断,还有一个更细的做法:逐行判断剩余空间,空间不足就换页并重绘表头。

for row_index, row in enumerate(table_data): if pdf.get_y() + row_height > pdf.page_break_trigger: pdf.add_page() if row_index > 0: draw_table_header(pdf) draw_table_row(pdf, row)

这种方式在节点数较多时依然能保持表头连续。注意draw_table_header也要调用set_xy,确保在新页面的正确位置开始绘制。

我实际使用中还发现,fpdf2的auto page break默认只在通过写文本触发时生效,手动set_xy后直接画边框不会触发自动分页,所以手动判断是必须的。不要指望开自动分页后所有元素都不会溢出,涉及自绘线和表格时,代码里必须显式检查。

5. 常见问题与排查技巧实录

5.1 高频问题速查表

问题常见原因解决办法
中文全部显示为方框未注册中文字体,或字体本身不含中文字形add_font注册支持中文的ttf,set_font后再写文本
文本被截断或溢出用了cell去容纳多行文本,宽度不够改用multi_cell,控制可用宽度
表格跨页后表头丢失分页后没有重新绘制表头在循环中判断add_page后重绘表头行
元素位置越排越偏右使用cell后没有重置x坐标每个区块前set_xy(pdf.l_margin, pdf.get_y())
生成的文件浏览器下载乱码HTTP响应头缺少PDF标识或文件名未编码media_type指定application/pdf,Content-Disposition中文件名URL编码
PDF文件体积异常大字体子集化未生效,或嵌入多个字体文件确认fpdf2版本,检查subsetting设置
add_font报参数错误fpdf2版本与老代码api不一致查看当前版本,按新版参数传递
内容到了第二页起点变高分页后旧y值仍然被引用分页后重新get_y,不要用之前的缓存值

5.2 工作流内的效率提升技巧

跑通基础生成之后,有几个小技巧能让这套流程产生更大的价值。

把任务数据做成JSON或Excel导入。Excel可以用pandas读取,但要注意日期格式统一;JSON则更简单,直接dict就可以。我在任务数据源里增加了一个默认值机制:字段缺省时自动套用模板里的默认值,避免某条任务漏填字段导致PDF上有空块。

再加一个模板配置层。把标题、公司名、签批栏文字都做成配置项,不同团队用同一套脚本时只需改配置,不用动代码。配置可以简单到一个Python dict,也可以做成yaml文件。这个动作看起来小,实际上能让脚本从“自用工具”变成“团队可复用工具”。

最后建议配合定时任务使用。比如团队成员每天更新任务状态到数据表,服务器每周五下午自动把本周任务表单PDF输出到共享目录。整个流程完全无人值守,而且每次生成的文件格式都一模一样,再也不会出现各人交上来的表单排版各异的窘境。我在实际使用中最直观的感受是,这套脚本帮我彻底告别了“手工复制进Word再导出”的流程,省出来的时间可以做比排版更有价值的事。如果你也频繁处理这类固定结构的表单,照着上面的思路跑通第一版,后面会越用越顺手。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询