用 PyQt5 + PyPDF2 做一个“智能分页”的大 PDF 拆分器(含 GUI 与命令行双版本,附完整源码)
当 PDF 动辄数百 MB、动辄上千页时,网盘上传慢、邮箱投不进去、IM 群发传不上去?传统“按页数固定切分”的脚本又常常不是太大就是太小?
这一次,我们用 PyQt5 + PyPDF2 写一个 “智能分页 + 二分回退” 的 PDF 拆分器:目标大小优先,在不反复手调参数的前提下,自动把一个超大的 PDF 拆成若干个尽量接近目标大小的小文件。界面拖拽即用、并行处理多文件、带进度条、可中途取消;另外还附带一个极轻量的命令行批处理版,方便服务器/终端直接跑。
适用场景:科研大论文(高分辨率插图多)、扫描版教材、会议论文集、投递材料整合包、课程大讲义等。
目录
- 为什么要“按大小”而不是“按页数”拆?
- 方案与核心思路
- 环境与安装
- 使用说明(GUI 版)
- 使用说明(命令行版)
- 关键实现细节
- 性能、边界与常见问题
- 源码(GUI 版 & 命令行版)
为什么要“按大小”而不是“按页数”拆?
- 页数≠体积。图像密集页 > 纯文本页。按页数拆,生成文件大小差异可能很大。
- 外部平台限制以大小为准:邮箱、IM、网盘往往限制单文件 MB 或 GB。
- 用户心智更轻:告诉工具“上限 400 MB”,让它自己试,更省心。
方案与核心思路
- 初始估算:根据 整本 PDF 的页数/体积 粗估每份的页数上限。
- 试切 & 量化:写出一份后,测实际大小,得到“实际页/MB”比率,作为下一份的自适应估算依据。
- 二分回退:若试切超限,立即按二分法缩页数,直到刚好不超。
- 并行多文件:每个输入文件由独立线程处理;主线程只负责 UI 与信号调度。
- 可选内容压缩(轻量级):利用 PyPDF2 的
compress_content_streams()
对页内容流做压缩(对纯图片扫描类 PDF 效果有限,详见 FAQ)。
环境与安装
Python 3.8+(Windows / macOS / Linux 均可)
pip install PyPDF2 PyQt5
可选:若需要更强的“重采样/图片重压缩”,可结合
pikepdf
、qpdf
、ghostscript
等外部工具(本文 GUI 版留有“启用压缩”开关,默认用 PyPDF2 的内容流压缩;进阶重压缩方案见后文 FAQ)。
使用说明(GUI 版)
- 运行
pdf_smart_splitter_gui.py
。 - 拖拽 PDF 到“待处理文件”列表,或点击“添加文件…”。
- 设置最大文件大小(默认 400 MB)。
- 可勾选启用压缩(对矢量/文本/少量图片有效;纯扫描大图提升有限)。
- 点击开始处理。支持并行多个文件,进度条实时滚动;取消处理可随时中止。
- 输出目录位于与原文件同级的
原文件名_拆分/
文件夹中,命名形如xxx_part_1.pdf
、xxx_part_2.pdf
…
使用说明(命令行版)
把脚本放在含有大 PDF 的目录里直接运行:
python batch_split_large_pdf.py
- 脚本会自动遍历当前目录下所有
.pdf
文件,只处理 > 400 MB 的文件。 - 你也可以在
split_large_pdf()
里手动传入路径并修改默认阈值。
关键实现细节
1)智能分页 + 二分回退
- 初始份额用
estimated_pages = ceil(剩余页数 * 目标MB / 原文件MB)
。 - 若上一份实际测得
actual_pages / actual_mb
,则下一份优先用经验比率估页。 - 超限则二分缩小页数,避免线性退让的低效试错。
2)无阻塞 UI 与信号槽
- 每个文件一个
threading.Thread
后台处理。 - 通过
pyqtSignal
更新进度条与状态文本,线程内不触碰 UI 控件,避免崩溃。
3)拖拽与批量
QListWidget
开启拖拽接收;支持多选移除、清空列表。- 并行场景下,全部线程结束后统一回收、恢复按钮状态。
4)轻量压缩(可选)
- 在写出前对
PageObject
调用compress_content_streams()
:
对矢量/文本较友好;对扫描大图整体体积影响有限(图像重采样需外部工具)。
性能、边界与常见问题(FAQ)
Q1:为什么我开了“启用压缩”,体积还是不小?
A:PyPDF2 的 compress_content_streams()
只压缩内容流,不会对嵌入图片做重采样/降质。扫描版 PDF(大图为主)需配合 qpdf --stream-data=compress
、ghostscript
或 pikepdf
做更激进的图片重压缩。
Q2:拆分后大小仍略有波动?
A:PDF 的对象结构复杂,同等页数 ≠ 同等字节。本工具通过“经验比率 + 二分回退”尽量靠近目标值,但无法保证每份都严格相等;不会超过阈值才是第一约束。
Q3:加密或损坏的 PDF 怎么办?
A:加密需要先解密(若有密码);损坏文件可能读页失败,日志会给出错误信息。
Q4:能否指定“按书签/章节”拆?
A:可以扩展,读取 reader.outline
(或 get_outlines
)后把章节起止页当做候选边界,再在候选边界内做“大小优先”的二分;本文代码保持通用版。
源码
建议保存文件名(ASCII):
- GUI 版:
pdf_smart_splitter_gui.py
- 命令行版:
batch_split_large_pdf.py
① 图形界面·智能分页版(PyQt5)
# pdf_smart_splitter_gui.py
import os
import sys
import logging
import threading
import math
import io
from datetime import datetimeimport PyPDF2
from PyQt5.QtWidgets import (QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,QPushButton, QFileDialog, QLabel, QProgressBar, QCheckBox,QSpinBox, QGroupBox, QFormLayout, QMessageBox, QListWidget,QListWidgetItem, QSplitter, QFrame
)
from PyQt5.QtCore import Qt, pyqtSignal, QObject
from PyQt5.QtGui import QDragEnterEvent, QDropEvent# 日志设置:同时输出到控制台与文件
logging.basicConfig(level=logging.INFO,format="%(asctime)s - %(levelname)s - %(message)s",handlers=[logging.FileHandler("pdf_splitter.log", encoding="utf-8"),logging.StreamHandler(sys.stdout),],
)
logger = logging.getLogger(__name__)class WorkerSignals(QObject):"""工作线程信号"""progress = pyqtSignal(int, str) # (0..100, message)completed = pyqtSignal(str, bool) # (file_or_msg, success)file_processed = pyqtSignal(str) # file_pathclass PDFSplitterWorker(threading.Thread):"""PDF 拆分工作线程:智能分页 + 二分回退"""def __init__(self, file_path: str, max_size_mb: int, enable_compression: bool):super().__init__()self.file_path = file_pathself.max_size_mb = max_size_mbself.enable_compression = enable_compressionself.signals = WorkerSignals()self.running = Trueself.last_actual_pages_per_mb = Nonedef run(self):try:self._split_pdf_with_smart_paging()if self.running:self.signals.completed.emit(self.file_path, True)except Exception as e:logger.exception("处理文件出错")self.signals.completed.emit(f"{self.file_path} - 错误: {e}", False)def stop(self):self.running = Falsedef _split_pdf_with_smart_paging(self):input_file = self.file_pathmax_size_bytes = self.max_size_mb * 1024 * 1024logger.info(f"开始处理:{input_file}")self.signals.progress.emit(0, f"准备处理 {os.path.basename(input_file)}")if not os.path.exists(input_file):raise FileNotFoundError(f"文件不存在:{input_file}")file_size_bytes = os.path.getsize(input_file)file_size_mb = file_size_bytes / (1024 * 1024)logger.info(f"源文件大小:{file_size_mb:.2f} MB")if file_size_bytes <= max_size_bytes:msg = f"文件已小于 {self.max_size_mb} MB,无需拆分"logger.info(msg)self.signals.progress.emit(100, msg)self.signals.file_processed.emit(input_file)returnbase_name = os.path.splitext(os.path.basename(input_file))[0]output_dir = os.path.join(os.path.dirname(input_file), f"{base_name}_split")os.makedirs(output_dir, exist_ok=True)with open(input_file, "rb") as f:reader = PyPDF2.PdfReader(f)total_pages = len(reader.pages)logger.info(f"总页数:{total_pages}")current_page = 0part_number = 1while current_page < total_pages and self.running:remaining_pages = total_pages - current_page# 初步估算estimated_pages = max(1,min(remaining_pages,math.ceil(remaining_pages * self.max_size_mb / max(file_size_mb, 1e-6)),),)# 用上一次的“真实页/MB”做自适应调整if self.last_actual_pages_per_mb is not None:estimated_pages = max(1,min(remaining_pages,math.ceil(self.max_size_mb * self.last_actual_pages_per_mb),),)start = current_pageend = min(start + estimated_pages, total_pages)output_file = os.path.join(output_dir, f"{base_name}_part_{part_number}.pdf")writer = PyPDF2.PdfWriter()# 写入页面,并(可选)压缩内容流for p in range(start, end):if not self.running:breakpage = reader.pages[p]if self.enable_compression:try:page.compress_content_streams()except Exception as e:logger.warning(f"第 {p+1} 页压缩失败:{e}")writer.add_page(page)progress = int((p + 1) / total_pages * 100)self.signals.progress.emit(progress, f"正在处理第 {p + 1}/{total_pages} 页")if not self.running:break# 先尝试一次完整写出with open(output_file, "wb") as out:writer.write(out)new_size = os.path.getsize(output_file)new_size_mb = new_size / (1024 * 1024)actual_pages = end - startif new_size_mb > 0:self.last_actual_pages_per_mb = actual_pages / new_size_mblogger.info(f"已创建:{output_file},页 {start+1}-{end},约 {new_size_mb:.2f} MB")# 若超限,进行二分回退if new_size > max_size_bytes:logger.warning(f"{output_file} 超过限制,开始二分回退")try:os.remove(output_file)except Exception:passlow, high = 1, actual_pagesoptimal = 1while low <= high and self.running:mid = (low + high) // 2retry_writer = PyPDF2.PdfWriter()for p in range(start, start + mid):page = reader.pages[p]if self.enable_compression:try:page.compress_content_streams()except Exception:passretry_writer.add_page(page)# 用内存缓冲试算大小,避免反复写盘buffer = io.BytesIO()retry_writer.write(buffer)temp_size = buffer.tell()if temp_size <= max_size_bytes:optimal = midlow = mid + 1else:high = mid - 1end = start + optimalfinal_writer = PyPDF2.PdfWriter()for p in range(start, end):page = reader.pages[p]if self.enable_compression:try:page.compress_content_streams()except Exception:passfinal_writer.add_page(page)with open(output_file, "wb") as out:final_writer.write(out)new_size = os.path.getsize(output_file)new_size_mb = new_size / (1024 * 1024)logger.info(f"回退完成:{output_file},页 {start+1}-{end},约 {new_size_mb:.2f} MB")actual_pages = end - startif new_size_mb > 0:self.last_actual_pages_per_mb = actual_pages / new_size_mbcurrent_page = endpart_number += 1if self.running:self.signals.progress.emit(100, f"拆分完成!共生成 {part_number - 1} 个文件")self.signals.file_processed.emit(input_file)else:self.signals.progress.emit(0, "处理已取消")logger.info("处理已取消")class PDFSplitterApp(QMainWindow):"""PDF 智能拆分器 GUI"""def __init__(self):super().__init__()self.threads = []self._setup_ui()def _setup_ui(self):self.setWindowTitle("PDF 智能拆分工具")self.setGeometry(100, 100, 900, 600)central = QWidget()main_layout = QVBoxLayout(central)self.setCentralWidget(central)splitter = QSplitter(Qt.Vertical)# 文件区file_frame = QFrame()file_layout = QVBoxLayout(file_frame)file_group = QGroupBox("待处理文件")file_group_layout = QVBoxLayout(file_group)self.file_list = QListWidget()self.file_list.setAcceptDrops(True)self.file_list.setDragDropMode(QListWidget.InternalMove)self.file_list.setSelectionMode(QListWidget.ExtendedSelection)self.file_list.dragEnterEvent = self.dragEnterEventself.file_list.dropEvent = self.dropEventbtn_row = QHBoxLayout()btn_add = QPushButton("添加文件...")btn_add.clicked.connect(self.add_files)btn_remove = QPushButton("移除选中")btn_remove.clicked.connect(self.remove_selected)btn_clear = QPushButton("清空列表")btn_clear.clicked.connect(self.clear_list)btn_row.addWidget(btn_add)btn_row.addWidget(btn_remove)btn_row.addWidget(btn_clear)file_group_layout.addWidget(self.file_list)file_group_layout.addLayout(btn_row)file_layout.addWidget(file_group)splitter.addWidget(file_frame)# 设置 & 进度settings_frame = QFrame()settings_layout = QVBoxLayout(settings_frame)group_settings = QGroupBox("拆分设置")form = QFormLayout(group_settings)self.max_size_spin = QSpinBox()self.max_size_spin.setRange(1, 2000)self.max_size_spin.setValue(400)self.max_size_spin.setSuffix(" MB")form.addRow("最大文件大小:", self.max_size_spin)self.chk_compress = QCheckBox("启用压缩(压内容流)")self.chk_compress.setChecked(False)form.addRow("", self.chk_compress)settings_layout.addWidget(group_settings)group_progress = QGroupBox("处理进度")v = QVBoxLayout(group_progress)self.lbl_progress = QLabel("准备就绪")self.bar = QProgressBar()self.bar.setValue(0)v.addWidget(self.lbl_progress)v.addWidget(self.bar)settings_layout.addWidget(group_progress)self.lbl_status = QLabel("状态:就绪")settings_layout.addWidget(self.lbl_status)splitter.addWidget(settings_frame)splitter.setSizes([360, 240])# 底部按钮bottom = QHBoxLayout()self.btn_start = QPushButton("开始处理")self.btn_start.clicked.connect(self.start_processing)self.btn_cancel = QPushButton("取消处理")self.btn_cancel.clicked.connect(self.cancel_processing)self.btn_cancel.setEnabled(False)bottom.addWidget(self.btn_start)bottom.addWidget(self.btn_cancel)main_layout.addWidget(splitter)main_layout.addLayout(bottom)# 拖拽def dragEnterEvent(self, e: QDragEnterEvent):if e.mimeData().hasUrls():e.acceptProposedAction()def dropEvent(self, e: QDropEvent):for url in e.mimeData().urls():path = url.toLocalFile()if path.lower().endswith(".pdf") and not self._in_list(path):self._add_item(path)# 文件列表操作def add_files(self):files, _ = QFileDialog.getOpenFileNames(self, "选择 PDF", "", "PDF 文件 (*.pdf)")for p in files:if not self._in_list(p):self._add_item(p)def _add_item(self, path: str):try:size_mb = os.path.getsize(path) / (1024 * 1024)text = f"{os.path.basename(path)} ({size_mb:.2f} MB)"item = QListWidgetItem(text)item.setData(Qt.UserRole, path)self.file_list.addItem(item)logger.info(f"已添加:{path}")except Exception as e:logger.error(f"添加失败:{e}")QMessageBox.warning(self, "添加失败", f"无法添加文件:{e}")def _in_list(self, path: str) -> bool:for i in range(self.file_list.count()):if self.file_list.item(i).data(Qt.UserRole) == path:return Truereturn Falsedef remove_selected(self):for item in self.file_list.selectedItems():path = item.data(Qt.UserRole)logger.info(f"移除:{path}")self.file_list.takeItem(self.file_list.row(item))def clear_list(self):self.file_list.clear()logger.info("列表已清空")# 处理流程def start_processing(self):if self.file_list.count() == 0:QMessageBox.warning(self, "提示", "请先添加文件")returnself.btn_start.setEnabled(False)self.btn_cancel.setEnabled(True)self.lbl_status.setText("状态:处理中")max_mb = self.max_size_spin.value()enable_compress = self.chk_compress.isChecked()logger.info(f"开始批处理:最大 {max_mb} MB,压缩={enable_compress}")self.threads = []for i in range(self.file_list.count()):path = self.file_list.item(i).data(Qt.UserRole)worker = PDFSplitterWorker(path, max_mb, enable_compress)worker.signals.progress.connect(self.update_progress)worker.signals.completed.connect(self.file_completed)worker.signals.file_processed.connect(self.mark_done)worker.start()self.threads.append(worker)def update_progress(self, percent: int, msg: str):self.bar.setValue(percent)self.lbl_progress.setText(msg)def file_completed(self, message: str, success: bool):logger.info(f"文件完成:{message}(success={success})")# 全部线程是否结束if all(not t.is_alive() for t in self.threads):self.finish_processing(cancelled=False)def mark_done(self, path: str):for i in range(self.file_list.count()):item = self.file_list.item(i)if item.data(Qt.UserRole) == path:if "[已完成]" not in item.text():item.setText(f"[已完成] {item.text()}")breakdef cancel_processing(self):logger.info("用户请求取消")for t in self.threads:t.stop()for t in self.threads:if t.is_alive():t.join(timeout=1.0)self.finish_processing(cancelled=True)def finish_processing(self, cancelled: bool):self.btn_start.setEnabled(True)self.btn_cancel.setEnabled(False)if cancelled:self.lbl_status.setText("状态:已取消")self.lbl_progress.setText("处理已取消")else:self.lbl_status.setText("状态:处理完成")self.lbl_progress.setText("所有文件已处理完成")self.bar.setValue(100)logger.info("批处理结束")def closeEvent(self, e):try:self.cancel_processing()finally:e.accept()def main():logger.info("PDF 智能拆分工具启动")app = QApplication(sys.argv)# 字体可按需调整(若无 SimHei 则保持系统默认)try:f = app.font()f.setFamily("SimHei")app.setFont(f)except Exception:passw = PDFSplitterApp()w.show()try:code = app.exec_()logger.info(f"退出码:{code}")sys.exit(code)except Exception as e:logger.error(f"异常退出:{e}")sys.exit(1)if __name__ == "__main__":main()
② 命令行·轻量批处理版(按大小粗估 + 均匀拆分)
# batch_split_large_pdf.py
import os
import math
import PyPDF2def split_large_pdf(input_file: str, max_size_mb: int = 450):"""将大 PDF 拆成若干份,使每份尽量不超过 max_size_mb(线性估算,简单快速)注:若想更精准地“逼近上限”,请使用 GUI 版的“智能分页 + 二分回退”实现。"""print(f"正在处理:{input_file}")if not os.path.exists(input_file):print(f"[错误] 文件不存在:{input_file}")returnsrc_size_mb = os.path.getsize(input_file) / (1024 * 1024)print(f"源文件大小:{src_size_mb:.2f} MB")if src_size_mb <= max_size_mb:print(f"无需拆分:已小于 {max_size_mb} MB")returnbase = os.path.splitext(os.path.basename(input_file))[0]out_dir = os.path.join(os.path.dirname(input_file), f"{base}_split")os.makedirs(out_dir, exist_ok=True)with open(input_file, "rb") as f:reader = PyPDF2.PdfReader(f)total = len(reader.pages)print(f"总页数:{total}")# 线性估算:按总体“页/MB”推导出每份页数estimated_pages = max(1, math.ceil(total * max_size_mb / max(src_size_mb, 1e-6)))parts = math.ceil(total / estimated_pages)print(f"预计拆分为 {parts} 份,每份约 {estimated_pages} 页")for i in range(parts):start = i * estimated_pagesend = min((i + 1) * estimated_pages, total)writer = PyPDF2.PdfWriter()for p in range(start, end):writer.add_page(reader.pages[p])out_path = os.path.join(out_dir, f"{base}_part_{i+1}.pdf")with open(out_path, "wb") as out:writer.write(out)size_mb = os.path.getsize(out_path) / (1024 * 1024)print(f"已创建:{out_path},页 {start+1}-{end},约 {size_mb:.2f} MB")print(f"完成!输出目录:{out_dir}")def main():# 扫描当前目录,仅处理 > 400 MB 的 PDFpdfs = [f for f in os.listdir(".") if f.lower().endswith(".pdf")]for name in pdfs:size_mb = os.path.getsize(name) / (1024 * 1024)if size_mb > 400:split_large_pdf(name, max_size_mb=450)print("=" * 50)if __name__ == "__main__":main()
写到最后
- 如果你追求尽量“贴着上限”,推荐 GUI 版;它的“经验比率 + 二分回退”能在复杂内容分布里取得更稳定的结果。
- 如果你只是快速、批量把超大 PDF 粗略切开,命令行版已经够用。
- 想进一步大幅压缩扫描版 PDF,建议联动
pikepdf
/qpdf
/ghostscript
做图片重采样与压缩;本文的“启用压缩”仅对内容流有效。
有用就点个赞/收藏,转给同样被大 PDF 折磨的同学吧。祝拆分顺利!