












档案系统里有个基础功能:把一卷档案的几十上百张扫描图合并成一个 PDF(卷内文件一个 PDF,方便浏览、打印、归档)。需求看起来人畜无害:
读取目录下的 50 张 JPG → 合并成一个 PDF → 保存
第一版实现 10 分钟搞定,本地测试 50 张毫无压力。结果上线后,生产环境跑 200 页的卷宗时——内存溢出,服务直接挂。更麻烦的是:挂的时候是半夜的批量任务,第二天客户发现一批卷宗 PDF 没生成。
我们用 Python 的 img2pdf / PIL 方案合并 PDF。典型错误写法:
from PIL import Image
import os
def merge_to_pdf(image_dir, output_pdf):
images = []
for fname in sorted(os.listdir(image_dir)):
if fname.lower().endswith(('.jpg', '.png', '.tif')):
# 坑:Image.open() 后直接 .copy() 或转 RGB,全量载入内存
img = Image.open(os.path.join(image_dir, fname)).convert('RGB')
images.append(img) # 所有图片对象驻留内存
# 坑:所有图片同时传给 save(),再叠一份内存
images[0].save(output_pdf, save_all=True, append_images=images[1:])
return output_pdf
问题链条:
Image.open() 本身是惰性的(没真正读像素),但 .convert('RGB') 强制全量解码——一张 300dpi A4 灰度图约 2500×3500×3 字节 ≈ 26MB;images 列表里;save(save_all=True, append_images=...) 时 PIL 内部还会再复制一份,内存峰值再翻倍;from PIL import Image
import os
def merge_to_pdf_streaming(image_dir, output_pdf, quality=85):
"""流式合并:逐张处理,单张内存占用"""
files = sorted(
f for f in os.listdir(image_dir)
if f.lower().endswith(('.jpg', '.png', '.tif'))
)
first = True
for fname in files:
# 逐张打开、转格式、保存到 PDF(写盘即释放)
with Image.open(os.path.join(image_dir, fname)) as img:
rgb = img.convert('RGB')
if first:
rgb.save(output_pdf, 'PDF', resolution=300.0,
save_all=True, append_images=[])
first = False
else:
# 追加模式:把当前页 append 到已存在的 PDF
# 注意:PIL 追加 PDF 时 append_images 一次一张
rgb.save(output_pdf, 'PDF', resolution=300.0,
save_all=True, append_images=[])
return output_pdf
(注:PIL 追加 PDF 的 API 在不同版本行为有差异,发布前请按你们实际库版本验证;更稳妥的生产方案见下。)
关键点:每张图处理完立即释放(with 块),内存峰值 = 单张图片大小,而不是总页数 × 单张。
PIL 处理大批量 PDF 仍偏慢且 API 别扭,生产环境我们换用 img2pdf(超快、流式) 或 pypdf 的流式合并:
# 方案:img2pdf(把图片直接打包成 PDF,流式、极快)
import img2pdf
import os
def merge_to_pdf_img2pdf(image_dir, output_pdf):
files = sorted(
f for f in os.listdir(image_dir)
if f.lower().endswith(('.jpg', '.png', '.tif'))
)
# img2pdf 内部流式处理,不吃内存;还支持指定 DPI 与压缩
with open(output_pdf, "wb") as f:
f.write(img2pdf.convert(
[os.path.join(image_dir, f) for f in files],
layout_fun=img2pdf.get_layout_fun((img2pdf.mm_to_pt(210), img2pdf.mm_to_pt(297)))
))
return output_pdf
对比实测(200 页卷宗,发布前替换为你们环境数值):
| 方案 | 峰值内存 | 耗时 | 稳定性 |
|---|---|---|---|
| 错误版(全量载入) | 5GB+(OOM) | 崩溃 | 差 |
| PIL 流式(逐张) | ~120MB | 45s | 中 |
| img2pdf 流式 | ~80MB | 8s | 好 |
convert('RGB') 能转但慢;img2pdf 对部分格式直接不支持——合并前统一做格式归一化(统一转 8bit RGB 或灰度);ImageOps.exif_transpose 处理;Image.open 抛异常导致整个任务失败。逐张 try/except,坏图跳过并记录日志(而不是整批失败)。即使改成流式,生产环境还是要防"一次挂全挂":
def merge_batch_safe(batch_list):
"""批量合并任务:逐卷处理,单卷失败不拖垮整批"""
results = []
for batch in batch_list:
try:
results.append(merge_to_pdf_img2pdf(batch["dir"], batch["out"]))
except Exception as e:
# 失败记录到任务表,人工/自动重试,不中断整批
mark_task_failed(batch["id"], str(e))
return results
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。