乐于分享
好东西不私藏

26_PDF拆分与合并

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 操作并不会深度复制页面内容,而是复制引用。这意味着:

合并快:因为只是在新的 PDF 结构中引用了旧页面的对象 ID。
文件大:如果你从 1GB 的 PDF 中提取了 1 页生成新文件,你会发现新文件可能依然很大,因为它可能包含了原文件中未使用的资源(如字体、图片)。

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_pagePdfWriter.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 块或定时任务)。
本站文章均为手工撰写未经允许谢绝转载:夜雨聆风 » 26_PDF拆分与合并

评论 抢沙发

8 + 9 =
  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址
×
订阅图标按钮