前几天帮一位做课程资料整理的朋友处理扫描版教材,打开PDF一看,每一页里都挤着左右两页书的内容。横向排版、双栏混排,平板和手机上看字小得费劲,想打印又等于在烧纸。折腾了pypdf、pdfplumber、PyMuPDF几个库之后,我用Python写了几十行脚本,把600多页的双栏PDF拆成了1200多页单栏PDF,阅读体验直接翻倍。这篇就把这个“PDF拆分左右并排页面”的完整方案从头到尾说清楚,包括原理、代码、还有那些文档里不会写的坑。
1. 左右并排PDF的产生原因与拆分的三种刚需场景
1.1 你手里的PDF为什么会“左右并排”
先说产生原因。绝大多数左右并排PDF不是设计者故意做的双栏排版,而是扫描和导出阶段产生的物理布局。
最常见的是双页扫描。书本扫描仪为了效率,常常把翻开的两页书并排放在一个画幅里,导出PDF时没有做自动切分,就变成“一页PDF = 左右两个书页”。这类PDF通常是横向页面,左右内容各自独立,中间往往还有书脊阴影。
第二种常见来源是PPT或讲义的横向双栏导出。有些人做课件时设置成宽屏16:9,然后把两页内容横向拼在一页里打印保存,电脑上看还行,换到手机或在阅读器上放大就非常痛苦。
第三种是外文文献、会议海报、工程图纸的排版习惯。双栏学术论文本身就要求左右栏分开阅读,扫描成PDF后如果不拆,引用和批注都很难做。
这三种场景的共同特征是:页面本身是完整的PDF,但内容逻辑上是两个独立单元。拆分的目标就是把一个逻辑分页还原成两个物理页面。
1.2 不拆会有什么麻烦
不拆的话,问题不只是“字小”。
- 阅读字号损失:原页面宽如果是A4横向,左右两栏各占一半,实际阅读时能显示的文字量只有完整页面的一半,放大后翻页频繁,眼睛来回扫,效率极低。
- 打印浪费:双栏页面直接打印会占据横向空间,打印出来后文字被缩得很小,或者需要旋转后打印,普通家用打印机不友好。
- 标注和检索失真:PDF阅读器的高亮、批注都是基于矩形坐标的。双栏页面里你只想高亮左栏某一行,工具却会把右侧栏同高度的区域一起框进去,选中范围永远多一块。
- OCR后处理麻烦:如果后续要做OCR识别或文本提取,左右栏文字会交错读取,段落顺序完全错乱。
我自己遇到过最尴尬的情况:某次会议材料是双栏PDF,用文字提取工具导出纯文本后,左右栏内容混在一起,段落顺序像洗过牌,整理了一下午才归位。所以后来只要看到左右并排PDF,第一反应就是先拆分再处理。
2. 选型对比:PyMuPDF的矩形裁剪能力凭什么最合适
2.1 主流Python PDF库的分裂能力对比
我知道很多人第一反应是用pypdf,或者干脆把PDF转成图片再用OpenCV切。这些路都能走通,但体验差距很大。我列个对比表,这是我实际跑过之后的结论:
| 库/方案 | 页面裁剪能力 | 是否保留文字层 | 处理速度 | 依赖复杂度 | 推荐度 |
|---|---|---|---|---|---|
| PyMuPDF (fitz) | 支持clip参数精准裁剪 | 保留矢量与文字层 | 极快 | 单一pip包 | 首选 |
| pypdf / PyPDF2 | 支持cropbox设置 | 保留文字层 | 中等 | 单一pip包 | 可用但API繁琐 |
| pdfplumber | 侧重文本解析,不适合页面切分 | 不参与 | 慢 | 依赖pdfminer等 | 不推荐 |
| pdf2image + OpenCV | 栅格化为图片再拆分 | 不保留文字 | 很慢 | 系统级poppler依赖 | 仅用于图像增强场景 |
实践结论:拆PDF页面这种操作,最核心的诉求是“物理切分,但不要重新渲染”。PyMuPDF的show_pdf_page接口可以直接把源页面某个矩形区域“引用”到新页面,内部保留PDF原生内容流,拆出来的单页文字仍然可以搜索、选择、复制。这一点是转图片再拼接的方案完全做不到的。
2.2 PyMuPDF的坐标与矩形裁剪原理
PyMuPDF(内部模块名fitz)把每个PDF页面看作一个坐标系。原点在页面左上角,x轴向右延伸,y轴向下延伸,单位是“点”(1点 = 1/72英寸)。页面所有内容的内容边界都可以用page.rect拿到,它是一个fitz.Rect对象,有x0, y0, x1, y1四个坐标,分别是矩形左上角x、左上角y、右下角x、右下角y。
左右并排页面的拆分逻辑非常简单:假设页面宽度为width,那么左侧半页的矩形是(0, 0, width/2, height),右侧半页的矩形是(width/2, 0, width, height)。
但这里有一个关键区别,新手很容易掉坑:
page.set_cropbox(rect)是修改当前页面的可显示区域。它不会创建新页面,只是把原页面的“取景框”改小。如果直接对源文档做这个操作,源页面本身的内容还在,只是显示区域变了,保存后原页面信息被改变,后续操作容易混乱。new_page.show_pdf_page(dest_rect, src_doc, src_page_number, clip=src_rect)则是把源页面某个矩形区域的内容绘制到新页面上。clip参数指定从源页面取哪一块,dest_rect指定放到新页面哪个位置。新页面是独立存在的,源文档完全不受影响,而且内容流是原生引用,不是截屏图片。
我强烈建议用show_pdf_page而不是set_cropbox。原因很简单:拆分后源文件往往还要保留原始版本,而且set_cropbox在页面旋转处理不当时会触发很多怪异行为,show_pdf_page配合clip参数反而更可控。
3. 用show_pdf_page实现左右半区拆分的完整代码
3.1 环境准备与依赖安装
环境方面只需要一个库:
pip install PyMuPDF导入时注意,PyMuPDF的顶层模块名是fitz,不是pymupdf。虽然新版也提供了import pymupdf的别名,但我习惯用fitz,历史兼容性最好。
import fitz验证版本:
print(fitz.version)实测下来PyMuPDF的版本差异对show_pdf_page的影响很小,我用过1.18到1.24的版本,这个接口的行为基本一致,升级依赖不会出幺蛾子。
3.2 固定中线拆分的基础版本
先给一个最直接、能跑的版本。它不处理空白页,不考虑歪斜,就是把每个源页面按宽度一半切成左右两个新页面,交替写入输出文档。
import fitz import pathlib def split_pdf_by_midline(src_path, out_dir): src_path = pathlib.Path(src_path) out_dir = pathlib.Path(out_dir) out_dir.mkdir(parents=True, exist_ok=True) src_doc = fitz.open(src_path) out_doc = fitz.open() for pno in range(len(src_doc)): page = src_doc[pno] rect = page.rect # 如果页面有旋转角,先归零,避免坐标判断错乱 if page.rotation != 0: page.set_rotation(0) rect = page.rect half = rect.width / 2 left_rect = fitz.Rect(0, 0, half, rect.height) right_rect = fitz.Rect(half, 0, rect.width, rect.height) # 先左后右,保持阅读顺序 for clip_rect in (left_rect, right_rect): new_page = out_doc.new_page(width=clip_rect.width, height=clip_rect.height) new_page.show_pdf_page(new_page.rect, src_doc, pno, clip=clip_rect) out_path = out_dir / f"{src_path.stem}_split.pdf" out_doc.save(out_path, deflate=True) out_doc.close() src_doc.close() print(f"拆分完成: {out_path},页数 {len(fitz.open(out_path))}") if __name__ == "__main__": split_pdf_by_midline("input.pdf", "output")这个脚本有几个细节值得解释:
page.set_rotation(0)为什么放在前面?因为有些扫描件自带旋转角,比如页面源尺寸是竖向,但被旋转了90度,直接取page.rect会得到旋转后的坐标,左右中线就会对不上。先把旋转归零,rect才会反映真实的可视方向。new_page的宽度设置为clip_rect.width,也就是原页面宽度的一半。这样拆分后的单页比例接近标准A4纵向,阅读器打开后就是正常的单页模式。out_doc.save(path, deflate=True)开启流压缩,输出文件体积会更小一点,尤其是扫描件。
3.3 识别并跳过空白半页:处理扫描件的真实状况
上面这个基础版本会假定每个源页面左右两半都有内容。但扫描书时有一个非常常见的情况:一本书最后一页在左侧,右侧是空白页;或者封面封底只有单面内容。如果机械地把空白半页也拆出来,输出文档会混进去一页纯白纸,打印和翻页都很烦。
判断半页是否为空,有两种思路:
- 文本层判断:
page.get_text()能提取文字,如果某个矩形区域没有文本,就认为是空白。但这个方法对扫描图版PDF无效,因为它们没有文字层。 - 像素渲染判断:把特定区域渲染成低分辨率位图,统计灰度方差。纯白或纯黑背景的方差非常小,有内容的区域方差明显大。
我写过这样一个实用的检测函数:
import statistics def is_blank_clip(src_doc, pno, clip_rect, dpi=40, std_threshold=6.0): """ 渲染clip_rect区域并计算灰度标准差,低于阈值视为空白。 dpi调小可以加快检测速度,对空白判断影响不大。 """ # Matrix(dpi/72, dpi/72) 等价于把渲染分辨率设为 dpi pix = src_doc[pno].get_pixmap( matrix=fitz.Matrix(dpi / 72, dpi / 72), clip=clip_rect ) samples = pix.samples # samples 是 bytes,每个字节是一个颜色分量 std = statistics.pstdev(samples) return std < std_threshold把这个函数插进拆分循环里:
for pno in range(len(src_doc)): page = src_doc[pno] if page.rotation != 0: page.set_rotation(0) rect = page.rect half = rect.width / 2 left_rect = fitz.Rect(0, 0, half, rect.height) right_rect = fitz.Rect(half, 0, rect.width, rect.height) for clip_rect in (left_rect, right_rect): if is_blank_clip(src_doc, pno, clip_rect): continue new_page = out_doc.new_page(width=clip_rect.width, height=clip_rect.height) new_page.show_pdf_page(new_page.rect, src_doc, pno, clip=clip_rect)注意,statistics.pstdev在纯Python下遍历大bytes对象会有点慢,但对半页区域来说,40dpi大概只有十几万字节,几百页循环下来也没有明显卡顿。如果你处理的是超大扫描书,还可以用pix.samples配合numpy:
import numpy as np std = float(np.frombuffer(pix.samples, dtype=np.uint8).std())3.4 输出文件命名、目录组织与页面验证
实际批量处理时,我习惯每个输入PDF对应一个独立输出目录,目录名沿用源文件名,避免几十个文件堆在一起互相覆盖。
out_dir = pathlib.Path("split_results") / src_path.stem out_dir.mkdir(parents=True, exist_ok=True) out_path = out_dir / f"{src_path.stem}_单页.pdf"输出之后建议做一次快速验证,至少统计页数、检查前几页能否正常渲染:
def quick_verify(pdf_path, expected_min_pages): doc = fitz.open(pdf_path) if len(doc) < expected_min_pages: print(f"警告: 输出页数偏少,只有{len(doc)}页") # 渲染第一页,确保可读 doc[0].get_pixmap(dpi=72) print(f"验证通过: {pdf_path} 共 {len(doc)} 页") doc.close()这一步别省。尤其批量处理大量文件时,很多问题(比如某些源页面损坏)只有渲染才会暴露,页数统计只能证明“文件存在”,不能证明内容正常。
4. 拆分中遇到的分页、空白、歪斜和出血这些问题
4.1 扫描件歪斜和旋转:先摆正再切
实际扫描件不总是四平八稳。有的页面会有轻微旋转角,有的页面因为扫描仪自动纠偏失败,文字整体斜了个两三度。这种情况如果直接按物理中线切,结果会怎样?
轻微歪斜下,左右半页仍然能用,只是中缝附近的文字可能被切掉一点点,或者文字行在页面边缘处不水平。对于阅读类需求可以忍,但对于要转成Word或做OCR的后处理来说,歪斜页面切出来的效果不够好。
处理歪斜的常规思路是先把整个PDF页面渲染成图片,用OpenCV做倾角检测和旋转校正,再重新组合成PDF。但这会丢失文字层,代价很大,我不太建议在拆分阶段做。一个更稳妥的做法是:
- 先看源PDF页面是否本身带旋转属性,用
page.rotation判断,归零处理; - 歪斜严重的页面试着用图像预处理先校正,再走拆分流程;
- 歪斜在3度以内的扫描件,直接拆问题不大,视觉上可以接受。
我真实项目里遇到的多是第三种情况。扫描仪通常有自动纠偏,歪斜严重的反而是手机拍照生成的PDF,那种页面往往还有边框,直接用视觉裁剪更合适,而不是单纯按物理中线拆分。
4.2 中缝装订线阴影与边缘出血:给切割线加一点余量
双页扫描PDF的左右半页之间,几乎必然有一条书脊阴影或黑白交界线。如果切割线正好压在阴影正中,可能把左页的右侧文字和右页的左侧文字都削掉一点。解决方案是给中线加偏移余量,让左半页稍微多包含一点中线右侧的区域,右半页稍微多包含一点中线左侧的区域。
mid_gutter = 8 # 单位:点。约3mm左右,具体扫描件可以调大调小 left_rect = fitz.Rect(0, 0, half + mid_gutter, rect.height) right_rect = fitz.Rect(half - mid_gutter, 0, rect.width, rect.height)注意,左右两半分到的中缝区域是重叠的,这没问题。因为我们拆分生成的是两个独立PDF文件,重叠只会让两侧都更完整,不会产生视觉冲突。如果最后你要把左右半页重新合并成一个连续文档,那重叠区域可能会让相邻两页内容重复,不过那是另一个场景,先不展开。
根据我的经验,扫描书籍的阴影宽度通常在5到15点之间。你可以在拆分前先用阅读器放大到中线位置肉眼看一下,估个数值,再写死进脚本。
4.3 页面尺寸不一致的处理
有些PDF前面几页是封面,后面是正文,封面和正文页面宽度都不一样。按“宽度一半”来切,如果一页特别宽一页特别窄,输出的单页尺寸就会忽大忽小。
处理办法是拆分前先检查整个文档的页面尺寸分布:
sizes = set() for page in src_doc: sizes.add((round(page.rect.width, 1), round(page.rect.height, 1))) print(sizes)如果只有一种尺寸,直接跑;如果出现多种尺寸,最好分组处理:按尺寸分组,分别拆,最后再合并输出。简单粗暴地按第一页尺寸切,会让小尺寸页面被放大或产生大量空白边。
4.4 旋转页面的方向陷阱
这是最容易踩但看起来最不明显的坑。有些扫描PDF的页面rect宽度大于高度,看起来是横向页面,但页面内部实际上是一个纵向排版被旋转了90度。用page.get_text()提取坐标时,你会发现文字的y方向和视觉方向完全不同。
不处理旋转直接拆,得到的结果是:左右两半物理上切开了,但每半页里的文字是横躺的。
所以我在所有正式版本的拆分脚本里都把旋转检测放最前:
if page.rotation != 0: page.set_rotation(0)page.rotation会直接返回0、90、180或270。执行set_rotation(0)后,page.rect会更新为旋转后的尺寸,切割方向自然就对了。不过提醒一句:set_rotation会修改源文档的内存状态,如果你之后还要用源文档做别的操作,最好先src_doc.toreader()或者用副本。
4.5 拆分后的文件大小异常
有几次拆分后我注意到输出文件比源文件大一倍甚至更多。原因通常是show_pdf_page把同一个源页面的两个区域分别引用到两个新页面时,某些PDF结构会产生重复的对象,尤其是图片类内容。虽然PyMuPDF的deflate=True能压缩一部分,但大图片还是会被重复存储。
碰到这种情况,我的处理顺序是:
- 接受一定程度的体积膨胀,因为文字层保留的收益大于空间开销;
- 如果是扫描件,图片本身就是jpeg压缩,体积增加通常不明显;
- 如果真的膨胀得离谱,再单独对输出PDF做一次
page.get_pixmap栅格化压缩,但这一步会牺牲文字层,谨慎使用。
5. 批量拆分、进度展示与常用进阶玩法
5.1 批量处理整个目录的PDF
如果只有一个PDF,上面的脚本就够了。但实际需求往往是“把一个文件夹里几十本PDF全部拆分”。这时我给脚本加一个外壳:
from pathlib import Path from tqdm import tqdm def batch_split_directory(input_dir, output_dir): input_dir = Path(input_dir) output_dir = Path(output_dir) pdf_files = list(input_dir.glob("*.pdf")) print(f"发现 {len(pdf_files)} 个PDF文件") for pdf_path in tqdm(pdf_files, desc="拆分进度"): split_pdf_by_midline(str(pdf_path), str(output_dir / pdf_path.stem))tqdm不是必需,但批量处理大文件时看一眼进度总比干等强。如果你不想额外装依赖,也可以自己打印文件名:
for pdf_path in pdf_files: print(f"正在处理: {pdf_path.name}") split_pdf_by_midline(str(pdf_path), str(output_dir / pdf_path.stem))5.2 按页码范围或书签拆分
有时候你不需要拆整个PDF,只想拆正文部分。比如前面10页是封面目录,最后几页是索引,中间200页才是需要左右分页的内容。
两种常见的指定范围方式:
- 按页码:传入
start_page和end_page,只对这个范围内的源页面执行拆分。 - 按书签:PyMuPDF读取目录,找到每个章节起点的页码,把每个章节单独拆出来。
按页码的范围控制很简单,循环条件改一下就行:
for pno in range(start_page, end_page + 1):按书签拆会稍微复杂一点,核心是拿到书签页码:
toc = src_doc.get_toc() print(toc) # 输出形如 [[1, "第一章", 3], [1, "第二章", 20]]拿到页码列表后,把每章的页码范围提取出来,逐章调用拆分逻辑。这种需求常见于把一本大书按章节拆分,方便分发给不同的人阅读。
5.3 新页面尺寸标准化与合并
拆分后的页面尺寸是源页面一半宽度、原高度。如果一个源页面是非常规尺寸,比如方形扫描件,拆出来就是方形单页。阅读器显示时不一定会自动填满屏幕,但在打印场景下可能出现“页边距不对称”的观感。
如果希望输出页面统一成标准A4纵向,可以做一次尺寸标准化:
new_page.set_mediabox(fitz.Rect(0, 0, 595, 842)) # A4纵向的近似点数不过set_mediabox会有缩放或平移的风险,需要配合set_cropbox控制显示区域,这步比较琐碎。我的建议是:只要输出页面比例不是特别离谱,保持原尺寸就行,阅读器会自适应。
5.4 多进程加速
PyMuPDF的拆分速度已经非常快,普通文字版PDF每秒能拆几十页。但如果是高分辨率扫描件,is_blank_clip里的像素渲染会比较耗时。这时可以上多进程:
from concurrent.futures import ProcessPoolExecutor def process_one(args): pdf_path, output_dir = args split_pdf_by_midline(pdf_path, output_dir) with ProcessPoolExecutor(max_workers=4) as pool: list(pool.map(process_one, [(str(p), str(output_dir / p.stem)) for p in pdf_files]))注意:fitz.open在fork模式下会有共享资源冲突的风险,稳妥的做法是每个worker函数内部重新fitz.open,也就是把split_pdf_by_midline作为独立入口函数再传入进程池,不要跨进程传递同一个Document对象。
5.5 封装成命令行工具
如果要长期用,可以顺手封装成命令行:
import argparse parser = argparse.ArgumentParser(description="拆分左右并排PDF") parser.add_argument("input", help="输入PDF路径或目录") parser.add_argument("-o", "--output", default="output", help="输出目录") parser.add_argument("-s", "--start", type=int, default=0, help="起始页码") parser.add_argument("-e", "--end", type=int, default=None, help="结束页码") args = parser.parse_args()这样配合定时任务或右键发送到菜单,就能在不打开Python代码的情况下给非技术同事使用。我自己就在Windows上注册了一个右键脚本,PDF文件拖上去自动拆,非常省事。
最后再分享一个小经验。拆分这类任务,脚本本身十分钟就能写完,真正的坑都在数据质量上。我遇到过的页面旋转、中缝阴影、空白页、尺寸不一致,没有一个是靠改库能解决的,全靠先把文档特性摸清楚。所以拿到陌生PDF,先花两分钟列页数、查尺寸、抽几页渲染看看,再决定要不要调中缝余量,这个习惯能帮你少返工很多次。
无论你是想把扫描书拆成单页在平板上看,还是要把双栏文献拆开做批注,只要理解了show_pdf_page的clip参数和坐标判断逻辑,这套方案能覆盖绝大多数需求。剩下的就是按自己手头文件的实际情况,调整页面范围和中缝参数了。