尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Python批量裁剪PDF页面空白:基于PyMuPDF的内容边界检测与自动裁剪

Python批量裁剪PDF页面空白:基于PyMuPDF的内容边界检测与自动裁剪 前几天整理一批旧合同打印的时候差点崩溃页面明明设的是A4可左右两边留出大片白边正文被挤在中间一条不到一半宽度的区域里。翻到电子版看一眼问题不在打印机是PDF本身就这么排的。这种PDF你在网上随手就能遇到网页另存、某些票据系统导出、扫描平板直接扫出来的PDF几乎都有左右空白过剩的通病。手动用PDF编辑器裁倒是能裁但几十上百页等着处理一页页框选显然不现实。这篇文章就从一个真实需求出发如何把一批PDF里多余的页面边缘空白自动清理掉尤其是左右两侧。我会用Python写一个批量裁剪工具通过检测每页真实内容的位置自动决定裁剪范围最后统一应用到全部页面。读完你既可以拿现成脚本处理自己的文件也能理解PDF页面裁剪背后的原理遇到更复杂的版面问题知道往哪个方向排查。1. 大空白是怎么来的四种常见来源与手动裁剪的死穴1.1 四种最常见的PDF大留白来源很多用户以为是PDF阅读器的显示问题其实是文件本身在生成时就把版面做“宽”了。我经手过的PDF里左右空白特别明显的逃不出下面四种来源。第一种是网页直接打印成PDF。无论浏览器还是各种“网页转PDF”工具默认都会把网页内容渲染在一个固定宽度区域内。很多网页的正文容器也就 800 到 900 像素转成 A4 后左右两侧自然空出一大段。比如博客文章、在线合同、系统导出的报表这类PDF非常典型正文往往只占页面中间 50% 到 60% 的宽度。第二种是某些排版工具或业务系统自动生成的PDF。常见于ERP、OA、网银回单这类系统界面固定直接调用打印组件生成PDF内容宽度按软件界面来根本没考虑A4纸面利用率。还有一些是将A3版面强行缩到A4里打印内容变小空白变大。第三种是扫描件。扫描仪会把整块稿台都扫进去又不会自动裁剪纸张边缘所以经常四周都是黑边左右尤其明显。如果扫描的是装订成册的书靠近书脊一侧还会出现大片阴影区比单纯的白边更让人头疼。第四种是论文、电子书、技术文档的PDF。很多作者为了版式美观默认就设置很大的页边距左右各留三四厘米也很常见下载下来打印时才发现纸面上字那么少。1.2 手动裁剪为什么治标不治本处理单页PDF用Adobe Acrobat或者任何带裁剪功能的编辑器框一下就能解决。但一旦文件有几十上百页手动方案的痛苦就会成倍放大。一方面Acrobat标准版的裁剪工具默认只处理当前页想要批量处理同一组边距得靠专业版里的“批量裁剪”功能不是每个人都有授权。另一方面不同页面的内容位置并不一致有的页正文偏左有的页偏右页眉页脚高度也不一样。如果全用一个固定边距去裁总有页面被裁掉一个字或空白仍然剩下不少。我自己试过一条“折中”路径抽几页目测出最大边距然后统一裁剪。结果遇到一份合同前二十页右边距都一样突然在中间插入一页带表格的表格右侧探出了框打印出来右侧表格线被切断。从那以后我就决定不再用肉眼估算而是写代码去检测每一页的真实内容范围。2. 我选择的技术路线自动检测内容边界而不是肉眼估算边距2.1 固定边距方案为什么不稳也许有人会问既然PDF左右空白大那直接给每一页设置一个固定的左、右边距比如左右各裁剪 80 点不就行了在版式完全统一的文件里这样确实能凑合。但实际业务文件里页面内容的位置是有波动的。有的页面图片占满整行有的页面只有几行文字居中有的页面表格从最左边开始。用一个固定裁剪框要么裁掉多出来的内容要么对内容窄的页面毫无帮助。更麻烦的是PDF里的边距单位是“点”point1 英寸等于 72 点A4 页面宽约 595 点。不同来源的PDF正文距离页面边缘的偏移量完全不一样。同一个固定值换一个文件就失灵。所以需要让程序自己去识别“正文到底在页面的哪个矩形范围内”。2.2 内容包围盒检测的底层逻辑PDF虽然看起来是个平面实际上每个文字、图片、绘图路径都有明确的位置坐标。我们要做的就是把一页里所有可见元素的坐标范围取出来合并成一个大的矩形这个矩形就是“内容包围盒”。听起来很复杂但PyMuPDFPython库导入名是fitz把这些操作封装得很友好。它允许我们分别读取文本块、图片、绘图的坐标。文本块用page.get_text(blocks)拿到图片通过page.get_image_info()拿到绘图路径通过page.get_drawings()拿到。每一项都带有(x0, y0, x1, y1)坐标代表元素左上角和右下角。把这些矩形逐个合并用矩形运算的“并集”功能就能得出整页内容的最小覆盖范围。有了这个范围裁剪就变成两件事一是把页面裁剪框设置成内容区域二是为保险起见在四周额外留出几个点的空余。打个比方页面像一块白板文字和图片像白板上贴的便利贴我们要找的就是能刚好罩住所有便利贴的最小矩形框。找到后把白板的可见区域缩小到这个框白边自然就消失了。3. 批量裁剪脚本完整实现从单页测量到全本统一3.1 准备运行环境我用的Python 3.10核心库只有PyMuPDF一个。安装命令很简单pip install PyMuPDF导入时注意不是import pymupdf而是import fitz这个导入方式来自MuPDF的绑定包很多第一次接触的人会卡在这里。装好后可以用fitz.open(input.pdf)打开文件遍历doc拿到每一页。3.2 单页内容边界检测函数怎么写以下是我最常用的检测函数。默认忽略顶部和底部指定高度的内容把真正有用的正文框出来。import fitz # PyMuPDF def detect_content_box(page, ignore_top30.0, ignore_bottom30.0, min_area4.0, include_drawingsFalse): 检测一页PDF中正文内容的包围盒。 ignore_top / ignore_bottom: 忽略页面顶部/底部指定高度的元素单位点。 min_area: 面积小于这个值的元素忽略用于过滤扫描噪点。 include_drawings: 是否把绘图路径也纳入检测。 page_height page.rect.height rects [] # 1. 文本块 for block in page.get_text(blocks): x0, y0, x1, y1 block[:4] # 页眉页脚过滤跳过顶部或底部的块 if y1 ignore_top or y0 page_height - ignore_bottom: continue area (x1 - x0) * (y1 - y0) if area min_area: continue rects.append(fitz.Rect(x0, y0, x1, y1)) # 2. 图片 for info in page.get_image_info(): x0, y0, x1, y1 info[bbox] area (x1 - x0) * (y1 - y0) if area min_area: continue rects.append(fitz.Rect(x0, y0, x1, y1)) # 3. 绘图路径默认关闭原因见第5章 if include_drawings: for drawing in page.get_drawings(): rect drawing[rect] # 忽略几乎占满整页的背景色块 if rect.get_area() page.rect.get_area() * 0.9: continue rects.append(rect) # 如果没有检测到任何元素退化为整页 if not rects: return fitz.Rect(page.rect) # 所有矩形求并集 union rects[0] for r in rects[1:]: union | r return union这里有几个设计考虑。page.get_text(blocks)返回的文本块通常已经按阅读顺序做了分组比一个字一个词提取要稳。扫描版PDF如果没有OCRget_text拿不到文本但图片信息仍能拿到所以图片检测需要默认开启。ignore_top和ignore_bottom是专门对付页码和页眉页脚的。大多数PDF正文上下各有 30 到 40 点的留白页码就在这个区域内。不提前滤掉检测边界时会把这些小元素也算进内容导致上下的裁剪幅度被打折扣。3.3 统一裁剪策略中位数边距比逐页裁剪更稳检测出每一页的内容包围盒后下一步是决定使用哪些边界。一个很直接的思路是每页单独检测、单独裁剪这样每一页内容区域命中率最高。但对于需要打印装订、双面打印的文档我不推荐逐页裁剪。为什么因为每页的内容包围盒宽度不同裁剪后页面尺寸就会不一致双面打印时正面和背面的边框对不上装订后参差不齐。对纯屏幕阅读的文件倒是可以接受但我们要解决的是打印和阅读两个场景所以统一裁剪更好。统一裁剪的关键在于所有页面的内容包围盒并不一样到底听谁的如果追求绝对安全取所有页面包围盒的最左边界、最上边界、最右边界、最下边界形成一个“并集矩形”那确实不会裁掉任何内容但左右空白还是很大因为只要某一页内容特别靠左整个裁剪后页面都会被拖过去。我的做法是取中位数或者更灵活一点取指定百分位。每页包围盒的x0、y0、x1、y1分别排序然后取中位数形成统一的目标裁剪框。这样大多数页面的内容都能落在框内个别极端页面即使有一点点边缘押到还可以靠margin参数向外扩几pt兜底。完整流程如下import statistics from pathlib import Path import fitz def uniform_box_from_boxes(boxes, margin6.0): 取所有页面内容包围盒的中位数值外加边距返回统一裁剪框。 x0 statistics.median([b.x0 for b in boxes]) y0 statistics.median([b.y0 for b in boxes]) x1 statistics.median([b.x1 for b in boxes]) y1 statistics.median([b.y1 for b in boxes]) # 保证不越出页面范围 page_rect boxes[0] if False else None return fitz.Rect(max(0, x0 - margin), max(0, y0 - margin), min(595.276, x1 margin), min(841.89, y1 margin))上面代码里的595.276和841.89是A4尺寸实际应用时建议从文档第一页的page.rect读取页面尺寸。如果文档是 Letter 或 A3硬编码的尺寸就会出问题。更稳妥的写法是def uniform_box_from_boxes(boxes, page_rect, margin6.0): x0 statistics.median([b.x0 for b in boxes]) y0 statistics.median([b.y0 for b in boxes]) x1 statistics.median([b.x1 for b in boxes]) y1 statistics.median([b.y1 for b in boxes]) x0 max(0, x0 - margin) y0 max(0, y0 - margin) x1 min(page_rect.width, x1 margin) y1 min(page_rect.height, y1 margin) return fitz.Rect(x0, y0, x1, y1)把page_rect作为参数传进去就能适配不同页面尺寸。如果文档里混合了多种页面大小这个简单版本不适用建议先统一页面尺寸或者按页面尺寸分组再分别裁剪。拿到统一矩形后应用到每一页def crop_pdf(input_path, output_path, margin6.0, ignore_top30.0, ignore_bottom30.0): doc fitz.open(input_path) boxes [] for page in doc: box detect_content_box(page, ignore_top, ignore_bottom) boxes.append(box) # 根据第一页获取页面尺寸假设所有页面大小一致 page_rect doc[0].rect crop_box uniform_box_from_boxes(boxes, page_rect, margin) for page in doc: page.set_cropbox(crop_box) doc.save(output_path) doc.close()page.set_cropbox()是PyMuPDF里设置裁剪框的方法。它不会真的删除页面上的内容只是把显示区域缩小到指定范围。内容还在PDF里理论上还能找回这给了我们不少安全感。3.4 封装成命令行工具批量处理整个目录单文件处理写好之后批量才是重点。我在外层加了一个目录扫描和命令行参数这样可以直接扔一个文件夹进去自动处理里面所有PDF。import argparse from pathlib import Path def process_folder(input_dir, output_dir, margin6.0, ignore_top30.0, ignore_bottom30.0): input_dir Path(input_dir) output_dir Path(output_dir) output_dir.mkdir(parentsTrue, exist_okTrue) pdf_files list(input_dir.glob(*.pdf)) if not pdf_files: print(没有找到PDF文件) return for pdf_file in pdf_files: output_path output_dir / f{pdf_file.stem}_cropped.pdf try: crop_pdf(str(pdf_file), str(output_path), margin, ignore_top, ignore_bottom) print(f完成: {pdf_file.name}) except Exception as exc: print(f失败: {pdf_file.name}: {exc}) def main(): parser argparse.ArgumentParser(description批量裁剪PDF页面空白) parser.add_argument(input_dir, help输入目录里面放PDF文件) parser.add_argument(-o, --output-dir, default./cropped) parser.add_argument(--margin, typefloat, default6.0, help四周额外保留边距单位pt默认6) parser.add_argument(--ignore-top, typefloat, default30.0, help忽略顶部范围内的元素) parser.add_argument(--ignore-bottom, typefloat, default30.0, help忽略底部范围内的元素) args parser.parse_args() process_folder(args.input_dir, args.output_dir, args.margin, args.ignore_top, args.ignore_bottom) if __name__ __main__: main()保存成pdf_crop.py然后在命令行执行python pdf_crop.py ./待处理PDF/ -o ./裁剪结果/ --margin 8 --ignore-top 36 --ignore-bottom 36输出文件会统一放在裁剪结果目录里不会动原文件。3.5 参数说明与建议值用表格整理一套我实测比较稳的默认值参数作用建议值--margin裁剪后四周额外留的白边6到10扫描件建议10--ignore-top忽略顶部多少点高度的元素30到40有页码可设40--ignore-bottom忽略底部多少点高度的元素30到40有页码可设40include_drawings是否把矢量图形纳入检测默认False遇奇怪版式再开单位是点1 点约等于 0.353 毫米。A4 高度 841.89 点30 点大约 1.06 厘米通常足够盖住页码。4. 真实效果复盘网页PDF、扫描件、论文PDF的裁剪差异4.1 三类样本的原始情况我只靠测试脚本跑了几个典型文件结果差异很明显也验证了不同来源的PDF对算法的影响。第一类是从某个知识社区导出的文章PDF一共 28 页。原文件正文区域大致是 x0130x1465页面宽度 595 点左右白边加起来约 200 点正文只占页面宽度的 56%。这算是典型的网页导出PDF左右留白非常严重。第二类是扫描版合同12 页。扫描区域是一个稍微扭曲的A4纸左边缘大约 40 点右边缘约 540 点看起来白边不多但底部有扫描仪留下的黑色噪点顶部还有阴影带。如果直接检测噪点会影响边界所以min_area参数在这里很关键。第三类是论文PDF46 页。排版本身很规范左右边距各 80 点不算离谱但配合双面打印页边距仍然有点浪费。4.2 裁剪后的尺寸和打印体验变化用统一策略处理后三类文件的效果对比如下文件原始左/右边距(pt)裁剪后左/右边距(pt)页面内容占比提升网页导出的文章130 / 13015 / 15约 56% → 约 94%扫描版合同40 / 5512 / 12约 83% → 约 96%论文PDF80 / 8020 / 20约 73% → 约 93%最让我惊喜的是那个 28 页的文章PDF裁剪后内容区域几乎撑满整页打印出来字也变大了读起来舒服很多。扫描版合同因为有黑边和噪点我没有把边距设成0留了10点的安全区否则黑边一旦紧贴内容会显得很突兀。文件大小方面裁剪并不会重新编码图片所以文件体积基本不变只有内嵌的裁剪框元数据发生变化。我处理的130页PDF生成速度大概 3 到 4 秒完全可以接受。5. 必然要遇到的四类坑页面元素检测的边界情形5.1 背景色块让内容区域变成整页我最早写脚本时把include_drawings默认设为True心想把所有元素都检测进来不是更保险吗结果翻车了。有些PDF为了视觉效果每一页都会铺一个占满整页的浅色背景矩形。这个矩形本身是“页面元素”get_drawings()会把它识别出来内容包围盒直接变成整页裁剪等于没裁。后来我在检测绘图路径时加了一个条件如果某个矩形的面积超过页面面积的 90%就认为是背景直接跳过。但这也只能应付“整页背景”的情况。如果背景是一张铺满页面的图片get_image_info()同样会返回整页尺寸的矩形那就得用另一个策略忽略面积超过页面面积 90% 的图像。不过实际项目里纯整页图像背景的PDF并不多如果遇到需要人工判断是否要保留背景。所以现在的默认参数里include_drawings是False。不是所有图形都不需要而是这类整页背景太常见默认关闭更稳妥。只有当页面里有一些无文本的纯图形区域需要检测时再手动打开。5.2 页眉页脚和页码干扰边界判断页码是裁剪时最容易忽略的“钉子户”。它存在于每一页底部居中的位置高度通常在页面底部 30 到 50 点之间。如果不提前过滤内容包围盒的下边界会被页码钉在页面底部附近导致下面一大块空白裁不掉。页眉也类似尤其是章节标题、公司LOGO、水印这类元素会被识别成文字块或图片块。我的处理方式是在检测函数里增加ignore_top和ignore_bottom直接跳过这些区域内的元素。但有一个前提你最好先目测一下页面上页码距离底部有多远再设置参数。还有一种更隐蔽的情况有些页面的页脚离正文特别近只有十几点跳过页脚后正文包围盒可能已经包含了这部分空白问题不大可如果页脚和正文文字在同一高度范围跳过参数就会误伤正文内容。这时候需要放大ignore_top还是缩小它只能根据实际预览来调没有一劳永逸的答案。5.3 旋转页面里的坐标陷阱PDF页面是可以带旋转属性的常见的有 90 度、180 度、270 度翻转。如果直接对旋转页面调用get_text(blocks)获取坐标拿到的坐标是相对于“旋转后页面”还是“物理页面”不同版本表现不一样但一旦页面旋转内容包围盒和页面尺寸的对应关系就会变得混乱。我处理过一个把扫描表格旋转了 90 度再合进去的PDF脚本裁剪完表格区域反而被切了一部分。排查后确认是页面旋转矩阵的问题。最简单的对策是在批量处理前先把所有页面统一旋转为 0 度。可以用page.set_rotation(0)将页面摆正后再检测和裁剪。当然这会改变页面的视觉方向如果原文档本来就横向排版不能强行摆正。稳妥做法是先检查page.rotation如果非0再决定是否处理。for page in doc: if page.rotation ! 0: print(f第 {page.number 1} 页旋转了 {page.rotation} 度)遇到旋转页占比很小的文件可以在检测函数里临时将页面旋转角度视为0但这对实际布局判断不准确。我的建议是如果是整本都旋转的文档先把整个文档旋转到正常方向再裁剪如果只有个别横向插入的大图或表格就先把它剪出来单独处理不要混在主流程里。5.4 扫描件噪点让裁剪结果不干净扫描件和纯电子文件最大的不同是扫描内容中有大量灰尘、污渍、纸张边缘的阴影。这些噪点面积通常很小但会因为过于靠近页面边缘而被当作内容的一部分。我在检测函数里加了min_area4.0的过滤条件意思是面积小于 4 平方点的元素直接无视。对于普通扫描件这个阈值能过滤掉绝大部分单点噪点。但遇到比较脏的扫描件4 平方点不够可以调到 16 甚至 36。代价是页面上真正的小元素比如坐标点、细小的脚注数字也可能被误删导致包围盒往里缩最终裁掉真实内容。另一个更稳妥的办法是“先腐蚀后膨胀”的思路这在图像处理中常用PDF坐标检测里也可以模拟先检测所有元素并取并集然后把边界向内收缩几个点再向外扩张几个点用来抵抗偶发的孤点。实际操作中我直接用margin参数向外扩就够了因为min_area过滤之后噪点影响已经大幅减少。6. 不想写代码时的替代路径三个现成工具与源头预防6.1 pdfCropMargins一条命令完成自动裁剪如果你不想折腾Python可以试试pdfCropMargins这个命令行工具。它做的事情和我的脚本类似解析PDF页面内容检测内容边界然后自动裁剪。安装方式要看操作平台Linux和macOS上很常见Windows下需要Python环境才能装。最简单的用法是pdf-crop-margins -o output.pdf input.pdf它也有相当多参数比如-p指定百分位边界-m设置边距。它的底层也依赖PyMuPDF或者matplotlib的PDF后端因此在Windows上运行前同样需要把Python相关依赖装好。总体而言它是纯命令行里最省事的现成方案。6.2 Briss图形化手动框选如果文件页数少或者页面特别复杂用图形界面更直观。Briss是一个老牌开源工具专门用来裁剪PDF空白。它能根据内容自动分页并叠加重叠区域你只要用鼠标框选一个或多个页面需要保留的区域程序就会把同样位置应用到所有页。Briss的优势在于所见即所得出问题可以立刻预览。缺点是处理扫描版PDF时它把每一页图像和文字混在一起检测有时自动分块很蠢需要手动合并块。不过对于不想写代码、又希望可视化控制的人来说Briss已经足够好用。6.3 Acrobat专业版和其他编辑器Adobe Acrobat Pro 的“裁剪页面”功能也支持对多个页面应用统一边距。操作路径是工具-页面-裁剪先设置好裁剪边距然后选择“页面范围”应用即可。但它的自动检测能力有限不会根据每一页内容位置自动调整只能统一人工填入边距值。其他国产PDF编辑器也大都支持批量设置页面边距但相同的问题是“按固定值裁”。对付那种每一页内容区域相差不大的文档没问题遇到内容位置跳变大的还是自动检测靠谱。6.4 在生成PDF时就控制好空白裁剪终究是后期补救。从源头防空白效果最好也最省心。在自己生成PDF时注意几个点网页打印请使用浏览器的“无边距”或“缩放至页面宽度”选项很多网页在打印预览里默认只有 80% 缩放文字处理软件导出PDF前把页边距设小一点左右 1.5 厘米以内扫描时尽量使用“自动裁切纸张”功能扫描软件通常有“自动检测纸张大小”选项。还有一条可以偷懒的路子如果最终目标是屏幕阅读而不是打印可以不用裁剪PDF本身而是通过支持“裁剪视图”的阅读器或标注工具来隐藏空白区域但这属于阅读侧的技巧文件本身的空白并没有消失。回到这次的批量裁剪需求我个人体会是自动检测内容边界再统一应用是所有方案里平衡效果、风险和工作量最好的方式。脚本跑出来的结果打印纸省了将近三分之一双面阅读时左右翻页的视觉负担也轻了不少。最后再提醒一句裁剪前一定保留原始文件脚本默认输出到新目录已经帮我躲过好几次“裁过头”的灾难。文件多的时候宁可多花两秒检查几页预览也不要一股脑替换掉原文件。
返回列表