26_PDF拆分与合并
摘要:在上一章中,我们掌握了
pypdf的基本用法。但在实际业务中,用户经常需要对 PDF 进行更细粒度的操作,例如:从一份 500 页的合同中提取第 3-5 页生成新文件,或者将数十个发票 PDF 合并成一个文件以便打印。本章将深入探讨 PDF 的拆分(Split)与合并(Merge)技术。我们将实现按范围提取页面、按固定页数切分,以及解决合并过程中的页面重排序问题,并重点讨论在大文件场景下的内存优化策略。
关联的小程序样例:薄荷百宝箱
26.1 PDF 页面提取
26.1.1 场景描述
用户上传了一个 PDF 文件,只需要其中的某几页(例如第 1 页和第 3-5 页)。我们需要生成一个新的 PDF 文件,仅包含用户选定的页面。
26.1.2 核心代码实现
我们使用 pypdf.PdfWriter 来构建新文件,并从 PdfReader 中拷贝页面。
# app/utils/pdf_helper.py
from pypdf import PdfReader, PdfWriter
from io import BytesIO
from typing import List, Union
def extract_pages(
file_stream: BytesIO,
page_ranges: List[Union[int, str]]
) -> BytesIO:
"""
提取指定页面生成新 PDF
:param page_ranges: 页面范围列表,支持单个页码(1)或范围字符串("3-5")
"""
reader = PdfReader(file_stream)
writer = PdfWriter()
total_pages = len(reader.pages)
selected_indices = set()
# 1. 解析页码范围 (转换为 0-based索引)
for item in page_ranges:
if isinstance(item, int):
# 单个页码
idx = item - 1
if 0 <= idx < total_pages:
selected_indices.add(idx)
elif isinstance(item, str) and "-" in item:
# 范围 "3-5"
start, end = map(int, item.split("-"))
# 转换为 range (包含 end)
for i in range(start - 1, end):
if 0 <= i < total_pages:
selected_indices.add(i)
# 2. 按顺序添加页面
# sorted 确保页面顺序,或者根据需求保留 selected_indices 的添加顺序
for idx in sorted(list(selected_indices)):
writer.add_page(reader.pages[idx])
output = BytesIO()
writer.write(output)
output.seek(0)
return output
使用示例: extract_pages(stream, [1, "3-5"]) 将提取第 1, 3, 4, 5 页。
26.2 PDF 按页拆分
26.2.1 场景描述
将一个大 PDF 文件切分为多个小文件。例如,每 1 页切分一个文件,或者每 10 页切分一个文件。这在处理扫描件(每一页是一张发票)时非常常见。
26.2.2 核心代码实现
import zipfile
def split_pdf_by_step(
file_stream: BytesIO,
step: int = 1
) -> BytesIO:
"""
按步长拆分 PDF,并打包成 ZIP 返回
"""
reader = PdfReader(file_stream)
total_pages = len(reader.pages)
zip_buffer = BytesIO()
with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zf:
# range(start, stop, step)
for i in range(0, total_pages, step):
writer = PdfWriter()
# 获取当前分片的页面
end_page = min(i + step, total_pages)
for page_idx in range(i, end_page):
writer.add_page(reader.pages[page_idx])
# 写入单个 PDF
temp_pdf = BytesIO()
writer.write(temp_pdf)
# 添加到 ZIP
filename = f"split_{i+1}-{end_page}.pdf"
zf.writestr(filename, temp_pdf.getvalue())
zip_buffer.seek(0)
return zip_buffer
工程实践: 如果拆分产生的文件非常多(如 1000 页每页拆分),直接生成 ZIP 可能会占用大量内存。在生产环境中,建议将拆分后的文件异步上传到对象存储 (MinIO),然后返回一个下载链接列表,而不是直接返回二进制流。
26.3 多 PDF 文件合并
26.3.1 场景描述
用户上传了多个 PDF 文件,希望按照上传顺序合并成一个 PDF 文件。
26.3.2 核心代码实现
def merge_pdf_files(file_streams: List[BytesIO]) -> BytesIO:
"""
合并多个 PDF 流
"""
writer = PdfWriter()
for stream in file_streams:
# 必须重置指针,防止读取空内容
stream.seek(0)
reader = PdfReader(stream)
# 将所有页面追加到 writer
writer.append_pages_from_reader(reader)
output = BytesIO()
writer.write(output)
output.seek(0)
return output
进阶技巧:带书签合并 append_pages_from_reader 默认不会保留源文件的书签(Outline)。如果需要生成带目录的合并 PDF,需要手动处理书签结构,这比较复杂,通常作为高级付费功能。
26.4 PDF 页面重排序
26.4.1 场景描述
用户发现扫描的文件页码乱了(例如:第 2 页和第 3 页颠倒了),希望手动拖拽调整页面顺序。前端会传入一个新的页码顺序列表,如 [1, 3, 2, 4]。
26.4.2 核心代码实现
def reorder_pdf_pages(
file_stream: BytesIO,
new_order: List[int]
) -> BytesIO:
"""
根据新索引重排页面
:param new_order: 页码列表,如 [1, 3, 2, 4] (1-based)
"""
reader = PdfReader(file_stream)
writer = PdfWriter()
total_pages = len(reader.pages)
for page_num in new_order:
idx = page_num - 1
if 0 <= idx < total_pages:
writer.add_page(reader.pages[idx])
else:
# 容错处理:忽略无效页码或报错
pass
output = BytesIO()
writer.write(output)
output.seek(0)
return output
26.5 PDF 拆分性能优化
26.5.1 内存陷阱
在使用 pypdf 处理大文件(如 500MB,几千页)时,简单的 add_page 操作并不会深度复制页面内容,而是复制引用。这意味着:
26.5.2 优化策略:Reduce File Size
如果希望生成的 PDF 尽可能小,我们需要切断未使用的引用,或者对 PDF 进行重压缩。pypdf 提供了压缩功能,但更彻底的优化通常需要调用 Ghostscript 或 pikepdf(基于 QPDF,C++ 编写,速度极快)。
使用 pikepdf 优化(替代方案):
# 需安装 pip install pikepdf
import pikepdf
def optimized_extract(input_path: str, output_path: str, page_idx: int):
with pikepdf.Pdf.open(input_path) as pdf:
new_pdf = pikepdf.Pdf.new()
new_pdf.pages.append(pdf.pages[page_idx])
# save 时会自动剔除无用资源
new_pdf.save(output_path)
在 FastAPI 中,对于普通任务使用 pypdf(纯 Python,部署简单),对于 VIP 大文件处理任务,建议使用 pikepdf。
本章面试题与知识点总结
1. PdfWriter.add_page 和 PdfWriter.append_pages_from_reader 有什么区别?
参考答案:
●add_page(page):一次添加一个PageObject。适用于需要精细控制(如重排序、旋转特定页面)的场景。●append_pages_from_reader(reader):这是一个批量操作,将 Reader 中的所有页面追加到 Writer。它的内部实现通常比循环调用add_page更高效,且代码更简洁。在做全文件合并时推荐使用后者。
2. 如何处理从大 PDF 中提取单页后文件体积依然很大的问题?
参考答案: 这是一个常见问题。
pypdf默认采用增量更新或保留引用的方式,新生成的 PDF 可能会包含原文件中共享的资源字典(如嵌入的字体文件、大图片),即使该页面并没有用到这些资源。 解决方案:1.简单处理:在writer.write()之前调用writer.compress_identical_objects()(pypdf 新版特性)。2.彻底处理:使用基于 QPDF 的库pikepdf。它在保存时会重构对象树(Object Tree),自动剔除未引用的“死对象”(Dead Objects),从而显著减小文件体积。
3. 在 FastAPI 中处理 100MB 以上的 PDF 合并,应该注意什么?
参考答案:
1.避免内存加载:不要使用await file.read()一次性读入内存。应使用SpooledTemporaryFile或将文件先保存到磁盘,通过文件路径传递给 PDF 处理函数。2.异步 IO:PDF 处理是 CPU 密集型任务,严禁在async def路由中直接运行。必须使用run_in_threadpool或发送到 Celery/Dramatiq 任务队列中执行,防止阻塞主线程导致服务不可用。3.磁盘空间:合并过程会产生新的大文件,要注意及时清理临时文件(使用try...finally块或定时任务)。
夜雨聆风
