你手头有一批PDF,打开一看,每页四周留了大片白边。打印的时候白纸不够用,在平板上看字被缩成三分之一大小。手动一页页裁不现实,批量处理才是正路。用Python去除PDF白边这件事,看起来简单,实际操作里藏着不少细节:电子导出的正常PDF、扫描件、带表格边框的文档,处理方式完全不同。这篇就把完整的实现思路、代码和踩坑记录都放出来,给同样被白边问题困扰的人一个能直接抄作业的方案。
我默认你已经有基础的Python环境,依赖装起来也比较简单。下面先从"白边到底怎么来的"开始讲,搞清楚根源之后,再上代码,这样你能知道每一步在干什么,遇到奇怪的现象也知道往哪个方向排查。
1. PDF白边从哪来:先搞清楚哪些白边值得去、哪些不能动
1.1 白边的常见来源
PDF页面上的白边不是一种东西,来源不同,处理策略也不同。我在实际处理中把它们分成四类:
- 导出型白边:从Word、WPS、PPT或网页“打印成PDF”时,默认套用了页面版式,内容只占版心,四周全是固定边距。这类白边最规整,也最容易去掉。
- 扫描件白边:高速扫描仪、平板扫描为了保险,通常会在页面四周留出额外空白,防止内容被切掉。扫描件的白边往往不均匀,甚至带黑色背景、装订孔、噪点。
- 印刷装订边:书籍扫描版或正式出版物里预留的装订线区域,严格说不算“废白边”,去掉之后内容可能贴近书脊,阅读反而难受。
- 设计型留白:封面、图表外框、PPT宽屏页为了美观故意留出的空间。这种动了会破坏版式。
在做任何自动裁剪之前,先把PDF翻一遍,判断当前这批文件属于哪一类。我的经验是:电子导出类文档直接裁,扫描件要加噪声处理,设计型留白和装订边不要动。
1.2 一个反直觉的结论:去白边不会让PDF变小
很多人以为去掉白边后文件体积也会变小,实际不是这样。PDF内部记录的是页面上的内容对象坐标,文本、图片、矢量线条都还在原位置。CropBox(裁切框)只是告诉阅读器“显示哪一块区域”,并不会删除区域外的数据。所以文件大小几乎不变,甚至因为保存时的重新压缩,还可能出现略微变化。
如果你同时有“去白边”和“压缩PDF体积”两个需求,建议分开处理。去白边解决的是阅读和打印体验,压缩体积需要靠删除冗余对象、重采样图片来实现。搞清楚这一点,后面就不会对结果产生错误预期。
1.3 哪些场景适合自动化裁剪
适合做批量去白边的典型场景我列了一张表:
| 场景 | 是否适合自动裁剪 | 说明 |
|---|---|---|
| 课堂讲义、PPT导出PDF | 很适合 | 白边规整,内容边界清晰 |
| 论文/合同的扫描件 | 适合,需谨慎 | 要保留最低限度页边距,避免裁到文字 |
| 带满版背景色的页面 | 不适合 | 背景本身就是全页内容,检测结果等于没裁 |
| 书籍扫描版 | 看情况 | 要保留装订线,建议设最低边距 |
| 设计稿、海报 | 不建议 | 留白是版式的一部分 |
判断“适合与否”的标准很简单:如果页面上所有有意义的内容都能被一个矩形框住,且框外是纯白,那这个场景就适合自动裁剪。如果内容是分散的、有背景纹理的、或者白边是有意设计的,自动方案就要非常谨慎。
2. 三条技术路线:为什么PyMuPDF直接算坐标比渲染识别更靠谱
2.1 三条主流的实现路线
用Python处理PDF白边,市面上常见的做法有三条路:
路线A:PyMuPDF(fitz)直接分析页面内容对象
不把页面渲染成图片,而是直接读取页面里的文本块、图片对象、矢量线条坐标,把所有元素的最小外接矩形算出来,再设置CropBox。优点是矢量级精确、速度快、不改变文字的可选中性。
路线B:渲染成图片 + OpenCV找轮廓
用pdf2image把每页变成PNG,再用OpenCV做二值化、膨胀、找非白区域边界。优点是很符合“肉眼看到的白边”,对扫描件特别有效。缺点是速度慢,裁完的PDF变成纯图片,文字不能搜索和选中。
路线C:pdfplumber提取字符坐标
pdfplumber基于PDFMiner,可以拿到每个字符的bbox,取所有字符坐标的并集。优点是代码写起来直观,特别适合以文本为主的普通电子文档。缺点是对纯图片页面无能为力,大文件性能不如PyMuPDF。
三条路线的对比:
| 方案 | 精确度 | 耗时(100页) | 额外依赖 | 定位内容类型 |
|---|---|---|---|---|
| PyMuPDF直接分析 | 矢量级精确 | 1-3秒 | pymupdf | 文本、图片、矢量线条 |
| 渲染+OpenCV | 像素级精确 | 10-30秒 | poppler/opencv | 一切可见内容 |
| pdfplumber坐标 | 字符级 | 3-8秒 | pdfplumber | 文本、表格线 |
2.2 为什么首选PyMuPDF
我的建议很明确:普通电子导出的PDF,优先用PyMuPDF直接分析坐标,不要再绕一圈去渲染图片。
原因是它同时能拿到三类关键坐标:
- 文本块:
page.get_text("blocks")返回每个文本块的矩形坐标; - 图片对象:
page.get_images()配合page.get_image_rects()能拿到图片在当前页面的实际位置; - 矢量绘图:
page.get_drawings()返回所有直线、曲线、填充矩形的边界框,表格线就是靠这个拿到的。
这样一来,不需要任何DPI换算,不需要处理渲染失真,直接在一套坐标体系里计算。而且裁剪之后文字依然是文字,还可以复制、搜索,这对后续归档很重要。
2.3 什么时候必须走图像路线
PyMuPDF方案有一个明显的盲区:纯扫描件页面里没有文本和矢量对象,只有一张铺满整个页面的图片。这种情况下直接分析图片对象,拿到的矩形往往接近整页大小,裁剪等于没裁。
判断标准很简单:页面没有可提取的文本,同时图片对象面积占页面面积超过70%左右,基本可以判定为扫描件。对于这类页面,必须用OpenCV做像素级分析。所以完整方案其实是一个混合策略:
- 页面有文本或矢量内容 → 走坐标分析;
- 页面是整页扫描图 → 渲染成图片,走像素分析。
这套思路在后面的脚本示例中会完整体现。用到的最核心库是PyMuPDF,扫描件检测场景再配合OpenCV。
3. 核心算法:文本块、图片与线条的坐标合并逻辑
3.1 先把“有效内容”拆成三类锚点
计算白边范围,本质是回答一个问题:页面上一共有哪些东西需要保留?我把它们拆成三类:
第一类:文本块
用page.get_text("blocks", sort=True)拿到的每个块,是一个形如(x0, y0, x1, y1, text, block_no, block_type)的元组。其中前四个数字就是块在页面上的坐标。注意这里的block_type:0是文本块,1是图片块。对于普通PDF,文本块是主要锚点。
第二类:图片对象
page.get_images(full=True)返回的是PDF内部资源表里的图片条目,不包含图片在页面上的位置。要拿位置,必须用返回结果里的xref编号去调page.get_image_rects(xref)。这是一个很容易被忽略的点,我见过不少人在这一步卡住,只拿到图片宽高,拿不到坐标。
第三类:矢量线条和图形
page.get_drawings()返回的是页面里所有矢量绘图指令的列表,每项是一个字典,里面有一个"rect"字段,代表这个绘图对象的外接矩形。表格的框线、下划线、装饰性色块都靠它定位。
3.2 噪点过滤与线状对象保护
拿到三类候选对象之后,不能直接取min/max。页面里经常有一些几乎看不见的小噪点:批量生成文档时残留的小圆点、空白处的烫金装饰点、贴片标签。如果不过滤,这个噪点的坐标会把边界撑大,导致白边没去干净。
过滤策略:设置一个“相对面积阈值”,对象面积如果小于页面面积的万分之五,就视为噪点忽略掉。这个比例对绝大多数正常文档都适用,既不会误删正文里的标点符号,又能滤掉各种小杂质。
线状对象例外:表格边框线往往是一个高度只有0.5pt左右的矩形,面积算下来很小,按面积阈值过滤会被丢掉。一旦外框线被忽略,裁切边界就会算到表格内侧,最后的PDF里表格最外一圈线就被裁没了。所以必须加一个保护逻辑:
# 如果矩形非常扁或非常窄,疑似线条,直接保留 is_line_like = rect.width < 3 or rect.height < 3 if is_line_like or rect.get_area() >= area_threshold: candidates.append(rect)宽度或高度小于3pt的对象,当作线条保护,不参与面积过滤。这样表格外框线就不会丢。
3.3 边界合并与安全边距
所有候选矩形收集完毕后,合并方式就是取四个方向的最小值最大值:
x0 = min(r.x0 for r in candidates) y0 = min(r.y0 for r in candidates) x1 = max(r.x1 for r in candidates) y1 = max(r.y1 for r in candidates)计算出来的矩形就是“有效内容”的最小外接框。但这个框还不能直接用,因为字符本身有描边、有防锯齿阴影,裁剪边线如果紧贴字符边缘,视觉上会显得很局促。所以要向外扩充一个安全边距,我通常设2pt。再把边界限制在页面物理范围内,避免越界。
下面是完整的坐标检测函数:
import fitz # PyMuPDF def find_content_rect(page, min_rel_area=0.0005, padding=2.0): """ 检测页面有效内容的边界框。 :param page: fitz.Page 对象 :param min_rel_area: 小于页面面积该比例的对象视为噪点 :param padding: 最终边界向外扩充的pt数 :return: fitz.Rect 或 None(空页面) """ page_w, page_h = page.rect.width, page.rect.height area_threshold = page_w * page_h * min_rel_area candidates = [] # 1. 文本块(get_text("blocks")返回块级坐标,同时覆盖文本和部分图片占位) for b in page.get_text("blocks"): x0, y0, x1, y1 = b[0], b[1], b[2], b[3] rect = fitz.Rect(x0, y0, x1, y1) if rect.get_area() >= area_threshold: candidates.append(rect) # 2. 图片对象(需要根据xref拿实际位置) for img_info in page.get_images(full=True): xref = img_info[0] for rect in page.get_image_rects(xref): if rect.get_area() >= area_threshold: candidates.append(rect) # 3. 矢量绘图对象(表格线、下划线、色块) for drawing in page.get_drawings(): rect = fitz.Rect(drawing["rect"]) if not rect.is_valid: continue # 线状对象保护:很扁或很窄的矩形直接保留 is_line_like = rect.width < 3 or rect.height < 3 if is_line_like or rect.get_area() >= area_threshold: candidates.append(rect) if not candidates: return None # 合并所有候选矩形 x0 = min(r.x0 for r in candidates) y0 = min(r.y0 for r in candidates) x1 = max(r.x1 for r in candidates) y1 = max(r.y1 for r in candidates) # 向外扩充安全边距,并限制在页面范围内 x0 = max(0, x0 - padding) y0 = max(0, y0 - padding) x1 = min(page_w, x1 + padding) y1 = min(page_h, y1 + padding) return fitz.Rect(x0, y0, x1, y1)这个函数是整套方案的基石。几个容易踩的点我提前说明:
get_text("blocks")虽然叫“文本块”,但在部分版本里也会把图片块一起返回,所以前面的图片对象检测会和它有一定重叠。重叠没关系,取并集的结果不受影响。- 绘图的
"rect"字段在少数极简PDF里可能是负坐标或空矩形,加一个is_valid判断可以避免NaN传染。 - padding值不建议给太大,1.5pt到3pt就够了。给大了以后页边距过大,等于白裁。
3.4 扫描件:像素级边界的检测实现
对于扫描件,坐标对象法走不通,我单独写一个像素检测函数。核心流程是:渲染灰度图 → 二值化 → 形态学闭运算 → 找非零区域外接矩形。
import numpy as np import cv2 def pixel_content_rect(page, dpi=150, threshold=200, padding=5): """ 基于像素分析检测页面有效内容边界,适合扫描件。 :param page: fitz.Page 对象 :param dpi: 渲染分辨率。扫描件用150dpi即可,速度和质量平衡 :param threshold: 亮度低于该值视为“内容” :param padding: 向外扩充的pt数 :return: fitz.Rect 或 None """ zoom = dpi / 72 matrix = fitz.Matrix(zoom, zoom) pix = page.get_pixmap(matrix=matrix, colorspace=fitz.csGRAY) # 灰度图直接转numpy数组 img = np.frombuffer(pix.samples, dtype=np.uint8).reshape(pix.height, pix.width) # 二值化:内容为白色(255),背景为黑色(0) _, mask = cv2.threshold(img, threshold, 255, cv2.THRESH_BINARY_INV) # 闭运算:把零散小点连成整体,避免边缘误判 kernel_size = max(3, int(dpi * 0.02)) kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (kernel_size, kernel_size)) mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel) coords = cv2.findNonZero(mask) if coords is None: return None x, y, w, h = cv2.boundingRect(coords) # 像素坐标转PDF坐标 scale = pix.width / page.rect.width x0 = max(0, x / scale - padding) y0 = max(0, y / scale - padding) x1 = min(page.rect.width, (x + w) / scale + padding) y1 = min(page.rect.height, (y + h) / scale + padding) return fitz.Rect(x0, y0, x1, y1)这里面的关键参数解释一下:
- dpi设150就够了。再高只是多了像素点,检测精度不会显著提升,速度却成倍下降。
- kernel_size按DPI缩放,150dpi时是3,300dpi时是6,保证不同分辨率下闭运算的实际物理尺寸一致。
- threshold=200表示亮度200以下的像素都算内容。如果扫描件背景偏灰,可以降到180;如果是黑白对比很清晰的扫描件,220也行。这个值要现场微调。
闭运算的作用是解决“字符边缘断裂”的问题。扫描件里有些笔画比较细,二值化之后中间可能会有断点,直接找外接矩形就会把范围算小,可能把最边缘的字切掉一半。闭运算把断裂处接上,检测范围更稳定。
4. 批量落地:可运行的脚本、性能与文件体积预期
4.1 最简版:单文件逐页裁剪
主处理函数把前面两个检测函数串起来,逻辑是:
- 打开PDF,检查是否加密;
- 逐页判断是普通页面还是扫描件;
- 算出该页的裁切框;
- 调用
page.set_cropbox(rect)应用裁切; - 保存为新文件。
def crop_pdf(input_path, output_path, per_page=True, padding=2.0): """ 去除PDF白边。 :param input_path: 输入PDF路径 :param output_path: 输出PDF路径 :param per_page: True每页独立裁剪;False全局统一裁剪 :param padding: 安全边距pt """ doc = fitz.open(input_path) # 加密保护 if doc.is_encrypted: if not doc.authenticate(""): raise ValueError(f"文件已加密,无法处理: {input_path}") page_boxes = [] for page in doc: # 粗略判断扫描件:无文本且图片面积占比高 text = page.get_text("text").strip() if text: bbox = find_content_rect(page, padding=padding) else: img_area = 0 for info in page.get_images(full=True): for r in page.get_image_rects(info[0]): img_area += r.get_area() if img_area > page.rect.get_area() * 0.7: bbox = pixel_content_rect(page, padding=padding) else: bbox = find_content_rect(page, padding=padding) if bbox is not None: page_boxes.append(bbox) else: # 空页面,保留原样 page_boxes.append(None) if not per_page: # 全局统一模式:取所有非空页面边界的并集 valid = [b for b in page_boxes if b is not None] if valid: merged = fitz.Rect( min(b.x0 for b in valid), min(b.y0 for b in valid), max(b.x1 for b in valid), max(b.y1 for b in valid), ) page_boxes = [merged if b is not None else None for b in page_boxes] for page, bbox in zip(doc, page_boxes): if bbox is not None: page.set_cropbox(bbox) doc.save(output_path, garbage=4, deflate=True) doc.close()几个设计细节我想解释一下:
garbage=4是PyMuPDF保存时做对象清理的最高档,能扔掉的冗余对象都会扔掉。虽然CropBox本身不删内容,但保存时的清理多少能回收一些不再引用的流数据。deflate=True让保存时重新压缩内容流。对文本PDF效果有限,但对图片PDF有一定压缩作用。- 每页独立裁剪模式下,页面之间白边大小可以不同,各自按自己的内容边界来。全局统一模式则把整个文档的内容范围合并成一个矩形,适合扫描版书籍这种所有页面版式一致的场景。
4.2 批量目录处理脚本
实际使用中基本不会只处理一个文件。我把批量入口也整理出来,直接扫描输入目录下所有PDF,处理后输出到指定文件夹:
from pathlib import Path def batch_crop(input_dir, output_dir, **kwargs): input_dir = Path(input_dir) output_dir = Path(output_dir) output_dir.mkdir(parents=True, exist_ok=True) pdf_files = list(input_dir.glob("*.pdf")) for idx, pdf_file in enumerate(pdf_files, 1): out_file = output_dir / pdf_file.name try: crop_pdf(str(pdf_file), str(out_file), **kwargs) print(f"[{idx}/{len(pdf_files)}] 完成: {pdf_file.name}") except Exception as exc: print(f"[{idx}/{len(pdf_files)}] 失败: {pdf_file.name} -> {exc}")批量处理出错时不要整个中断,记录失败文件并继续跑后面的,这个习惯能省很多事。跑完再集中看失败原因。
4.3 每页独立和全局统一,怎么选
我拿实际文档测试过这两种模式,差别是实打实的。
每页独立裁切的适用场景:PPT导出的PDF、网页打印的文档、体系结构混乱的合并文件。这类文件每页内容的排版范围可能完全不同:一页是标题页,内容居中偏下;下一页是正文,内容铺满。统一裁剪会让边距忽大忽小,明显不自然。
全局统一裁切的适用场景:批量扫描的书籍、版式统一的论文PDF。这类文档所有页面规格一致,每页独立裁剪出来的宽度可能相差几个像素,翻页时看起来有跳动感。统一取并集后,所有页面裁的一样大,更整齐。
4.4 性能预期与文件体积的清醒认识
我用一份200页的普通教学PPT导出PDF做了测试,坐标检测方案全程耗时约4秒,每页平均20毫秒。同样文件如果用渲染加OpenCV方案,200页大约要40到80秒,差距一个数量级。
至于输出文件体积,这组测试数据很说明问题:原始文件8.3MB,裁切后保存为7.9MB,只缩小了约5%。这个减少量几乎全部来自garbage=4和deflate=True的对象整理,而不是裁掉白边。如果你拿到一个PDF,裁完发现体积没变化,这是正常现象,不需要怀疑代码写错了。
真正想让文件显著变小,路线是压缩图片分辨率、丢弃嵌入的冗余字体子集。这和去白边是两件事,不建议混在一起做。
5. 实测踩坑:扫描件噪声、表格外框线和加密PDF处理
5.1 扫描件背景噪声把边界撑大
这是我遇到最多的问题。扫描件如果背景偏灰,或者有淡淡的网格斑点,二值化之后这些浅灰点也会被识别成内容。直接后果是检测出来的边界被撑开,白边去除效果极差,有时甚至等于没裁。
排查过程大概是这样的:
第一次跑像素检测,发现输出的CropBox几乎接近原页面大小。先检查渲染图,把PDF那页导成PNG用肉眼看,发现背景确实有均匀的浅灰噪点。然后试着把threshold从200降到185,结果好了一些,但页面边缘的装订孔留下的黑色墨迹又成了假内容。最后把闭运算的kernel调大,同时加上阈值过滤,边界才正常。
我总结的调整顺序是:
- 先降threshold,把浅灰背景排除掉;
- 再调kernel_size,把内容区域内的细小断点连起来;
- 最后给检测到的bbox再加一道“最小白边”限制,比如强制四周至少保留5pt,避免裁到墨迹边缘。
5.2 表格外框线被裁掉
还有一个高频问题:检测结果把表格最外层框线裁没了。场景是某企业财务月报PDF,页面主体是一个大表格,四周留白其实不多。裁剪后表格上边框线消失,只留下几行文字和竖线,看起来非常诡异。
根因在前面已经提到了:表格线的矩形面积太小,被面积阈值过滤掉。比如一个横跨页面宽度的高0.2pt的线条,面积是页面宽度的0.2倍pt,相对页面面积比例往往低于万分之五,确实会被过滤。
修复方式就是那个“线状对象保护”逻辑。但这策略有个副作用:页面上如果有装饰性短线条(比如标题下的小横线),它也会被当作锚点保留下来,可能让边界小幅外扩。从实际效果看,这个代价可以接受。
调试的时候可以用下面这段代码快速验证哪些对象被选中:
candidates = find_content_rect(page) print("检测范围:", candidates) print("文本块数量:", len(page.get_text("blocks"))) print("图片数量:", len(page.get_images(full=True))) drawings = page.get_drawings() print("绘图对象数量:", len(drawings)) for d in drawings[:10]: print(fitz.Rect(d["rect"]))确认表格线是否进入了候选集合,比瞎猜下一步往哪个方向改有效得多。
5.3 页眉页脚误判与显式忽略区域
还有一种尴尬局面:页面底部有页码、顶部有页眉,这些内容按字面意义属于“有效内容”,应该保留。但用户的实际需求是连页眉页脚一起去掉。检测算法不会懂业务意图,它只会忠实地把页码和页眉也纳入边界。
对付这种情况,我给检测函数加了两个可选参数:ignore_top_ratio和ignore_bottom_ratio,分别表示忽略页面顶部和底部一定比例区域内的内容。
def find_content_rect(page, min_rel_area=0.0005, padding=2.0, ignore_top_ratio=0.0, ignore_bottom_ratio=0.0): page_h = page.rect.height top_limit = page_h * ignore_top_ratio bottom_limit = page_h * (1 - ignore_bottom_ratio) # 在收集候选对象时,过滤掉完全位于忽略区域内的对象 def inside_ignore(rect): if rect.y1 <= top_limit: return True if rect.y0 >= bottom_limit: return True return False # ... 原有收集逻辑,每个候选对象append前加判断: # if inside_ignore(rect): continue比如设置ignore_bottom_ratio=0.05,就会忽略页面底部5%高度的内容。取值要谨慎:如果正文本身就排到接近底部,这个参数就会把正文最后一行也忽略掉,边界会往上跳,正文倒数的内容就露在裁切区之外了。
实际使用中我会先跑一次不带忽略参数的检测,打印输出的bbox,对比页面的实际排版,再决定要不要启用忽略。
5.4 加密PDF与畸形MediaBox
批量处理时加密文件会直接抛异常。PyMuPDF打开加密PDF时,如果权限锁是打开状态,可以尝试空密码认证;如果文件设了打开密码,就必须由人工提供密码。
if doc.is_encrypted: if not doc.authenticate(""): raise ValueError("需要密码或密码错误")另一种情况是页面MediaBox异常。我遇到过某些不规范的PDF,MediaBox宽高有一个方向为0,或者坐标范围是负值。PyMuPDF读取这类文件时,page.rect会出现异常,检测函数自然拿不到合理结果。稳妥做法是在函数入口做一次合法性校验:
if page.rect.width < 10 or page.rect.height < 10: return None顺便说一句,遇到异常页面时,我的方案是跳过并记录日志,而不是中断整个批处理。一个文件里几十页只有一页坏掉,没必要让整个任务失败。
5.5 先预览后全量,永远保留原始文件
批量跑之前,务必先拿一两页做预览验证。我的做法是写一个小辅助函数,把原页面和裁剪后的页面分别渲染成图片保存到本地,肉眼对比一次。
def preview(page, bbox, page_index): # 原页面 pix = page.get_pixmap(matrix=fitz.Matrix(2, 2)) pix.save(f"preview_{page_index}_before.png") # 裁切后 page.set_cropbox(bbox) pix2 = page.get_pixmap(matrix=fitz.Matrix(2, 2)) pix2.save(f"preview_{page_index}_after.png")确认检测范围符合预期后,再放全量跑。这个习惯帮我避免了好几次批量事故。另外,处理前一定要把原始PDF复制一份备份。CropBox操作说到底只是改元数据,但PDF文件在生成、流转过程中经过太多工具处理,偶尔会遇到解释方式很奇怪的实现,万一输出结果不对,原始文件还在就完全不影响。
5.6 一个小技巧:用页边距估算法快速判断检测结果
调试的时候不用每次都渲染图片肉眼看。我常用一个更快的验证方式:把检测出来的bbox宽高和页面原始宽高做个比值,打印出来扫一眼。
for i, page in enumerate(doc): bbox = find_content_rect(page) if bbox: ratio_w = bbox.width / page.rect.width ratio_h = bbox.height / page.rect.height print(f"第{i+1}页: 宽保留{ratio_w:.1%}, 高保留{ratio_h:.1%}")正常页面的保留比例通常在50%到95%之间。如果某页比例接近100%,说明要么页面本身内容铺满(正常),要么扫描件背景噪点干扰(要查);如果某页比例低于40%,多半是内容极少(比如只有一行字),也要确认是不是误判。一眼扫过去就能发现异常页面。
最后说一个我自己的使用习惯:处理之前永远先备份原始PDF,CropBox方案虽然只是元数据层面的修改,但文件经过多次预览生成和在线工具流转后,偶尔会出现非标准CropBox解释,万一原文件丢了会很被动。另一个小技巧是,批量处理时先拿一页做边界检测并把检测结果打印出来,确认内容范围符合预期再放全量跑,省掉来回检查的功夫。这套方案我用了大半年,处理过的PDF有上千份,只要按“先分类、再预览、后全量”的顺序来,基本不会出什么问题。