手头这批要归档的 PDF,多半是扫描仪一页一页扫出来的,夹杂着大量空白页:双面扫描翻到背面留下的空页、章节之间的分隔页、原件本身就没印字的衬页。几百页的文档翻起来没事,可一旦要做全文检索、要打印装订、要上传到资料库,这些空白页就是纯粹的负担——占体积、干扰阅读、打印浪费纸、OCR 时还白白跑一遍。Python 批量删除 PDF 空白页这件事,听起来像个三行脚本能解决的问题,真动手做才发现坑全在判定逻辑上:删多了丢内容,删少了等于没做。
这篇就来拆像素检测与文本层预检这套组合打法。核心思路不复杂——先用文本层做一次几乎零成本的粗筛,再用渲染成图后的像素统计做二次复核,两关都过才判为空白。适合三类人看:手上有一批扫描件要清理的整理者、想把 PDF 处理流程自动化的开发者、以及被"空白页删不干净"折腾过的运维同学。代码全部用 Python,主力库是 PyMuPDF 和 NumPy,不依赖任何商业组件,跑在普通笔记本上就行。
1. 先搞清楚要删的是什么:空白页的三种形态
动手写代码之前,我强烈建议先抽十个样本 PDF,把里面的空白页挨个翻一遍。这一步花不了二十分钟,但能省掉后面两天反复调参的时间。因为你很快会发现,"空白页"根本不是一种东西,它至少分三类,每类的判定策略都不一样。
1.1 真空白、伪空白与"看起来空白"
第一种是真空白页。页面对象存在,但上面什么都没有——没有文字、没有图形、没有图像。这类通常出现在文档合并、转换工具的补页环节,判定最省事,文本层一查就是空的,像素渲染出来纯白一片。
第二种是伪空白页。页面上确实有东西,但东西不是内容:页码、页眉横线、骑缝章的一角、"此页无正文"的占位说明、扫描仪盖板边缘蹭出来的一道黑边。这类页删不删要看业务规则,但从"有效信息量"角度确实该删。麻烦在于它文本层不空、像素层也有墨点,一刀切的阈值必然误判。
第三种是看起来空白但绝对不能删的页。典型是整页插图、满版底色的封面、深色背景的设计稿、以及那种浅灰底纹的表格页。如果阈值定得太激进,这些页会被当成空白删掉,而且是静默删除——脚本跑完不报错,等你发现的时候原件已经覆盖了。这是整个任务里风险最高的地方。
这三类页混在一起,就决定了我们不能用一个if 页面文本 == ""的粗暴判断。必须至少在两个维度上看同一页:文本维度回答"有没有可选中的字符",视觉维度回答"渲染出来到底有没有东西"。两个维度的答案不一致的页,才是最需要人工介入的页。
1.2 单一检测手段为什么必然翻车
先说只用文本层会怎样。扫描件是图片,整页没有一个字符,文本层永远是空的。你一跑,整个文档被判定为"全是空白",删得干干净净。即便做了 OCR 加文本层,OCR 引擎对空白页也可能吐出几个噪声字符,或者把页脚的页码识别成正文内容,判定照样失真。
再说只用像素检测会怎样。把页面渲染成位图统计非白像素,理论上对所有页面一视同仁,但你得为"多少个非白像素才算有内容"定一个阈值。这个阈值要同时满足:容忍扫描噪点和纸张纹理,又不能放过一页只有一行字的正文。A4 纸在 72 DPI 下大约 50 万像素,一行 10pt 的文字大概只占 3000 到 5000 个暗像素,占比不到百分之一。你把阈值定在百分之一,那么单行页就有被误删的风险;定得太低,噪点又把空白页全救回来了。
所以正确的做法是两级门控串联:文本层能给出确定结论的(比如整页有几百个字符),直接放行,不浪费算力去渲染;文本层给不出结论的(空的、或者只有几十个字符的可疑页),才进像素检测环节做复核。这样既准确又快——绝大多数有内容的页在第一关就被放过了,渲染只发生在少数页上。
2. 双通道方案的整体设计
把这两级门控串起来,整个处理流程其实是五个阶段:读文件、逐页初筛、可疑页复核、决策落盘、重建输出。每一阶段的输入输出都要明确,尤其是"决策"这一步,必须留下可追溯的记录,不能直接改原文件。
2.1 文本层预检打头阵,像素复核兜底
我给这套流程定的规则是这样的:文本层抽出来的有效字符数大于等于某个下限(我默认用 8),直接判为有内容,跳过像素检测,省一次渲染;字符数小于下限,进入像素检测;像素检测算出的墨迹占比低于阈值,判为空白;高于阈值,判为有内容。两级都不确定的灰色地带,标记为待人工复核,不自动删除。
注意:默认策略一定是"宁可漏删,不可错删"。空白页漏掉一页,最多是白打一张纸;正文页被误删,损失的是不可逆的信息。所以所有阈值都往保守方向偏,复核名单必须输出。
这里有个容易被忽略的细节:删除决策必须在整份文档的粒度上做,而不是逐页就地删除。如果你一边遍历一边delete_page,后面的页索引会全部错位,书签、链接、页面标签全部对不上。正确做法是先遍历所有页收集"要删的页号集合",遍历结束后一次性执行删除,再统一重映射书签。
2.2 渲染库与文本库的选型对比
工具选型上我试过不少组合,最后稳定用的是 PyMuPDF(包名pymupdf,老版本叫fitz)一个库全包。原因很实在:它同时提供文本抽取和页面渲染,一次打开文件两件事都能干,不用在 pdfplumber、pdf2image、Pillow 之间来回倒数据。
| 方案组合 | 文本抽取 | 页面渲染 | 依赖重量 | 实测感受 |
|---|---|---|---|---|
| PyMuPDF 单库 | 支持,速度快 | 支持,内置 | 轻,单包 | 首选,接口一致 |
| pdfplumber + pdf2image | 支持,表格更强 | 需外部渲染器 | 中,要装额外程序 | 渲染环节容易卡环境 |
| pypdf + Pillow | 仅文本,弱 | 不支持 | 极轻 | 只能做第一关 |
| pdfminer.six + 自建渲染 | 文本细致 | 无 | 中 | 太绕,不推荐 |
pdfplumber 在表格结构提取上确实更细,但这个任务只需要判断"有没有字",用不上它的强项。反过来,渲染环节一旦引入外部程序依赖,部署到别人的机器上就容易出问题,而 PyMuPDF 是纯包依赖,pip install完就能跑。
再补一个选型要点:渲染时一定要用灰度色彩空间(colorspace=fitz.csGRAY)。彩色渲染的内存开销是灰度的三倍,而我们只关心明暗,不关心色相。一张 A4 在 72 DPI 灰度下只有 50 万字节,彩色是 150 万,处理几千页时差距很明显。
3. 文本层预检:先花最少的力气筛掉一半
第一关的目标不是精确,是快且不漏。它的任务是把明显有内容的页面挡在门外,让它们不进入耗时的渲染环节。所以这一关的判定要宽——只要页面看起来像有字,就放行。
3.1 文本抽取与"有效字符"判定
直接拿page.get_text()的返回值去判空是不够的。PDF 里的空白页经常带着一堆看不见的东西:空格、制表符、换行符、零宽字符、以及某些生成工具留下的占位文本。这些东西让字符串长度不为零,但显然不算内容。
我的处理是把所有空白类字符全部剥掉,再看剩余长度:
import re import pymupdf # 匹配所有空白与零宽字符,包括不换行空格、零宽空格、BOM BLANK_PATTERN = re.compile(r"[\s\u00a0\u200b\u200c\u200d\ufeff]+") def text_layer_check(page, min_chars=8): """返回 (是否有内容, 有效字符数, 原始文本片段)""" raw = page.get_text("text") cleaned = BLANK_PATTERN.sub("", raw) has_content = len(cleaned) >= min_chars return has_content, len(cleaned), cleaned[:40]min_chars=8这个值是我反复试出来的。定得太小(比如 1),页码"3"、页眉上的"- 12 -"这类都会让判定通过,空白页被漏掉;定得太大(比如 30),那种只有一行标题的分隔页会被误判成空白,跑进像素检测——虽然第二关能兜住,但白白多渲染一次。8 个字符大约对应"第一章 绪论"这种短标题的长度,是个比较平衡的位置。
抽取模式上,get_text("text")拿的是纯文本流,速度快;get_text("blocks")返回带坐标的文本块,能顺便看出文字在页面上的分布。如果你需要更细的判断(比如"这一页只有一段页脚文字"),用 blocks 模式更合适:
def footer_only_check(page, footer_ratio=0.88): """判断页面是否只有底部一小条有文字,常用于识别纯页码页""" page_height = page.rect.height blocks = page.get_text("blocks") if not blocks: return True for block in blocks: # block 的结构是 (x0, y0, x1, y1, text, block_no, block_type) y0, text = block[1], block[4] if y0 < page_height * footer_ratio and BLANK_PATTERN.sub("", text): return False # 上半部分有字,不是纯页脚页 return True这个函数在清理报告类文档时特别有用——很多系统导出的 PDF 每页底部都有"第 X 页 共 Y 页",如果按字符数判定,每页都算有内容。加上这个判断,就能把"只有页脚"的页识别出来,交给后续环节决定删不删。
3.2 文本层预检的四个陷阱
陷阱一:扫描件的文本层是完全空的。这是最要命的一个。纯图片 PDF 每一页抽出来都是空字符串,如果只用文本层判定,整份文档会被清空。所以必须有一个前置检查:抽样几页,算一下整份文档的平均字符数,如果均值接近零,说明这是图片型 PDF,文本层预检直接失效,全部页面必须走像素检测。
def is_image_based(doc, sample=20): """抽样判断是否为图片型 PDF""" pages = doc.page_count step = max(1, pages // sample) total_chars = 0 count = 0 for i in range(0, pages, step): text = BLANK_PATTERN.sub("", doc[i].get_text("text")) total_chars += len(text) count += 1 return count > 0 and (total_chars / count) < 5陷阱二:OCR 层带来的假文本。有些文档做过 OCR,空白页上也会残留几个识别错误的字符,比如"的"、"一"孤立地飘在角落。这类页面字符数通常是 1 到 5,正好落在min_chars=8的防线之内,会被送进像素检测复核——这也是我把阈值定在 8 而不是 1 的原因之一。
陷阱三:隐藏文本与渲染模式。PDF 规范里有渲染模式 3(不可见文本)这种用法,字符在文本层里存在,视觉上却看不见。有些工具用它做搜索优化。判断时可以用get_text("dict")看每个 span 的颜色和渲染模式,但说实话这个场景很少见,我的做法是不特殊处理,让像素检测去否决它——文本层说有内容,像素层说没有,最终判为空白,正好。
陷阱四:中文字符与全角符号。剥离空白时如果把全角空格\u3000漏掉,纯全角空格填充的页面就判不出来。正则里的\s在 Python 3 默认 Unicode 模式下能匹配全角空格,但为了保险,我把常见的中文空白字符也显式列进去了。这种小细节,等你在几百页文档里发现有一页怎么都删不掉的时候,就知道值不值得了。
4. 像素级检测:把页面当图片来看
第二关是真正的判定核心。它的逻辑很朴素:把页面渲染成灰度位图,数一数有多少像素明显比纸白更暗,算出占比,低于阈值就判空白。真正难的不是这个思路,是三个参数怎么定——渲染分辨率、暗像素的判定门限、墨迹占比的阈值。
4.1 DPI、灰阶与阈值到底怎么定
先说 DPI。有人觉得检测就该用高分辨率,300 DPI 才靠谱。我实测下来完全没必要,而且代价很大。72 DPI 下一张 A4 是 595×842 像素,渲染耗时在 5 到 15 毫秒之间;300 DPI 是 2480×3508,像素数涨了 17 倍,单页渲染能到 100 毫秒以上,处理两千页的文档就是三四分钟的纯等待。更关键的是,检测空白页不需要看清字的笔画,只需要知道那里有墨。72 DPI 下一个小四号字大约是 10×10 的暗块,足够被统计到。
我最终用的是100 DPI。比 72 稍高一点,是为了给那些字号特别小(比如 6pt 的脚注)留一点余量,同时渲染耗时还能接受。如果你处理的都是正常排版的文档,72 DPI 完全够用。
再说暗像素门限。渲染出来的灰度图,像素值 0 是全黑,255 是纯白。纸白一般不是 255,扫描件通常在 240 到 252 之间浮动。如果把门限定在 255,纸张纹理和压缩噪点全被算成墨迹;定在 128,浅灰色的细线和小字就被漏掉了。我用的是250,同时配合一个背景自适应的补偿(下一节讲)。
最后是墨迹占比阈值。这个值得算一下。100 DPI 下 A4 页面约 827×1169,去掉 2% 的边缘裁剪后核心区域约 793×1121,约 88.9 万个像素。设阈值为千分之二,对应 1778 个暗像素。一个 10pt 的汉字在 100 DPI 下大约占 14×14 的方框,笔画覆盖率按 25% 估,约 49 个暗像素。1778 除以 49,大约相当于36 个汉字。
这个数量级是合理的:一页正文随便就是几百字,远超 36;而扫描噪点、装订孔的阴影、背面透印的残影,通常加起来也就几百个像素,够不到这个线。所以我默认用0.002,同时把它做成可调参数——不同来源的文档,这个值需要微调。
import numpy as np import pymupdf def pixel_check(page, dpi=100, ink_ratio=0.002, margin=0.02): """渲染页面并统计墨迹占比,返回 (是否空白, 墨迹占比, 暗像素数)""" pix = page.get_pixmap(dpi=dpi, colorspace=pymupdf.csGRAY, alpha=False) img = np.frombuffer(pix.samples, dtype=np.uint8).reshape(pix.height, pix.width) h, w = img.shape my, mx = int(h * margin), int(w * margin) core = img[my:h - my, mx:w - mx] if my or mx else img # 背景自适应:用高分位数估计纸张底色 paper = int(np.percentile(core, 90)) threshold = max(0, paper - 12) dark_count = int((core < threshold).sum()) ratio = dark_count / core.size return ratio < ink_ratio, ratio, dark_count这段代码里有三个点值得单独说。
第一,alpha=False。不加这个参数,某些 PDF 会渲染出带透明通道的位图,samples的长度是像素数的四倍,reshape直接报错。我在这上面浪费过半小时。
第二,margin边缘裁剪。扫描仪的盖板边缘经常蹭出一道黑边,宽度从几像素到几十像素不等。不裁掉的话,每一个双面扫描的背面页都会因为这道黑边被判成"有内容"。裁 2% 是经验值,如果你手上的扫描件黑边特别宽,临时调到 3% 到 5% 都行,但别裁太多,否则页面边缘的正文会被切掉。
第三,np.percentile(core, 90)。这是整段代码里最关键的一行。它取的是核心区域 90% 分位的灰度值,近似等于"这一页的纸是什么颜色"。正常白纸算出来是 250 上下,泛黄的旧纸可能只有 225,灰底扫描件甚至能到 200。如果用一个固定的 250 当门限,灰底扫描件的整页像素都会被算成暗像素,墨迹占比直接飙到 0.5 以上,每一页都判成有内容,空白页一个都删不掉。
4.2 背景自适应:对付灰底扫描件
把背景自适应单独拎出来讲,是因为它决定了这套方案能不能用在老旧文档上。我处理过一批上世纪九十年代的复印件扫描,纸张发黄发灰,扫描仪还带自动增益,同一份文档里不同页的底色能差出三十个灰度级。
固定门限在这类文档上必然失效。而用分位数估计背景,本质上就是让门限跟着纸的颜色走,纸变暗,门限就跟着下移,只有明显比纸更暗的像素才算墨。paper - 12里的 12 是容差,用来吸收同一页内部的纸张明暗不均。这个值太小(比如 5),纸张纹理会变成墨迹;太大(比如 40),浅色的细线和小字就被吃掉了。12 到 20 之间是个比较稳的区间,我一般用 12,遇到噪点特别多的文档调到 18。
还有一种更鲁棒的做法是 Otsu 大津法,自动找灰度直方图的双峰分割点。它的问题是在"近乎纯白、只有一个很小的双峰"的页面上算出来的分割点很不稳定——而空白页恰恰就是这种分布。所以在这个场景下,分位数法反而比大津法好用,因为它只需要一个"纸大概多白"的估计,不需要真正的分割。
提示:如果你处理的文档同时包含白底页和灰底页,可以让背景估计在整份文档范围内先跑一遍抽样,得到一个底色的分布,再决定是否需要给不同页面用不同参数。这一步多花的时间很少,但能显著降低误判率。
4.3 边缘裁剪与残影过滤
除了黑边,双面扫描还有一个更隐蔽的问题:背面透印。薄纸两面印刷时,正面的字会隐隐透到背面,扫描出来是一层很淡的灰色笔画。用 250 门限去数,这些残影全被算成墨,背面页判定失败。
处理办法有两个层次。轻量做法是把门限再往下压,比如改成paper - 30,残影灰度一般在纸白下 10 到 25 个级差,压到 30 就能滤掉,但代价是浅色正文也可能被漏。稳妥做法是加一个面积过滤:残影是弥散的、没有清晰边界的,而真正的文字笔画是集中的。可以先用一个 3×3 的均值滤波把画面模糊一下,让残影平均到接近纸白,同时文字的主干依然明显:
def pixel_check_with_blur(page, dpi=100, ink_ratio=0.002, margin=0.02, blur=True): pix = page.get_pixmap(dpi=dpi, colorspace=pymupdf.csGRAY, alpha=False) img = np.frombuffer(pix.samples, dtype=np.uint8).reshape(pix.height, pix.width).astype(np.int16) h, w = img.shape my, mx = int(h * margin), int(w * margin) core = img[my:h - my, mx:w - mx] if my or mx else img if blur: # 3x3 均值滤波,用累积和实现,避免引入额外依赖 kernel = np.ones((3, 3), dtype=np.int16) / 9 pad = np.pad(core.astype(np.float32), 1, mode="edge") hh, ww = core.shape acc = np.zeros_like(core, dtype=np.float32) for dy in range(3): for dx in range(3): acc += pad[dy:dy + hh, dx:dx + ww] * kernel[dy, dx] core = acc paper = float(np.percentile(core, 90)) threshold = max(0.0, paper - 8) dark_count = int((core < threshold).sum()) ratio = dark_count / core.size return ratio < ink_ratio, ratio, dark_count滤波之后,paper - 8这个容差就可以收得更紧,因为残影已经被平均掉了。这一套下来,双面扫描件的背面页基本能干净地识别出来。
另外还有一个专门对付"纯页码页"的技巧:把页面下半部分的暗像素单独统计一次。如果墨迹几乎全部集中在底部 8% 的区域,且总量很小,那基本可以确定是页码或者装饰线,判为可删。这个判断放在像素层做比在文本层做更可靠,因为扫描件的页码是图片,文本层抽不出来。
5. 批量工程化:从脚本到能跑一整晚的工具
单文件跑通之后,接下来要解决的是"几百个文件、几万个页面"的规模化问题。这一步踩的坑跟算法无关,全是工程细节:文件遍历、进程分工、输出重建、日志追溯。
5.1 目录遍历与多进程分工
任务粒度的选择很关键。按页分给多进程是不行的——PyMuPDF 的 Document 对象不能跨进程共享,而且同一份文档的页面写回需要串行。按文件分工才是正确的粒度:一个进程处理一整份 PDF,进程之间完全独立,不需要任何锁。
import os from concurrent.futures import ProcessPoolExecutor import pymupdf def collect_pdfs(root): result = [] for dirpath, _, filenames in os.walk(root): for name in filenames: if name.lower().endswith(".pdf"): result.append(os.path.join(dirpath, name)) return result def process_one(src_path, dst_path, ink_ratio=0.002, min_chars=8, dry_run=False): doc = pymupdf.open(src_path) if doc.is_encrypted and not doc.authenticate(""): return {"file": src_path, "status": "encrypted"} image_based = is_image_based(doc) to_delete, review = [], [] for pno in range(doc.page_count): page = doc[pno] if not image_based: has_text, n_chars, snippet = text_layer_check(page, min_chars) if has_text: continue # 文本层已有结论,直接放行 blank, ratio, dark = pixel_check(page, ink_ratio=ink_ratio) if blank: to_delete.append(pno) elif ratio < ink_ratio * 3: review.append({"page": pno + 1, "ratio": round(ratio, 5), "dark": dark}) if not dry_run and to_delete: doc.delete_pages(to_delete) doc.save(dst_path, garbage=3, deflate=True) doc.close() return { "file": src_path, "pages": doc.page_count if doc else 0, "deleted": [p + 1 for p in to_delete], "review": review, "image_based": image_based, "status": "ok", }doc.delete_pages接收页号列表,内部会正确处理索引偏移,比一页一页删安全得多。save时的garbage=3, deflate=True两个参数不能省:前者清理被删除页留下的孤儿对象,后者压缩对象流。实测下来一份 50MB 的文档,删掉三成页面后能压到 30MB 以内;不加这两个参数,体积几乎不变,因为被删页面的资源还挂在文件里。
dry_run这个开关是我强烈建议加上的。第一次跑任何一批文档,都先用预演模式把要删的页号和相关指标打印出来,翻一遍确认无误,再关掉开关真正执行。这个习惯救过我至少两次。
5.2 删页之后的重映射:书签、链接与页码
删页最容易被忽略的副作用是目录书签全部错位。原来的第 120 页变成第 95 页,如果书签还指向 120,点进去就是空白或者别的章节,整份文档的导航直接废掉。
PyMuPDF 的get_toc()返回的是一个三层结构[层级, 标题, 页码]的列表,页码是 1 起始的。删页之后需要按"被删除的页数"重新计算:
def remap_toc(doc, deleted_pages): """deleted_pages 是 1 起始的页号列表""" toc = doc.get_toc(simple=True) if not toc: return [] deleted = set(deleted_pages) def new_page_no(old_no): if old_no <= 0: return old_no # 0 表示指向文档外,保持原样 # 统计在它之前被删掉的页数 shift = sum(1 for p in deleted if p < old_no) target = old_no - shift if old_no in deleted: return -1 # 标记:书签原本指向被删的页 return max(1, target) remapped = [] for level, title, page_no in toc: new_no = new_page_no(page_no) if new_no == -1: # 书签指向被删页,退回到后一个存在的页 new_no = min(doc.page_count, max(1, page_no)) remapped.append([level, title, min(new_no, doc.page_count)]) return remapped拿到重映射后的列表,用doc.set_toc(remapped)写回去。这里有个细节:delete_pages必须在重映射之前执行,否则doc.page_count还是旧值,边界钳制会出错。顺序是"先删页、再算新页码、最后写回 TOC"。
内部链接(文档内跳转)的处理更麻烦,光靠高层 API 改不了,需要遍历每一页的get_links(),拿到目标页引用后判断被删页,再逐个处理。实际项目里我的策略是:如果链接指向被删页,直接移除这个链接,不要试图修复。因为指向空白页的链接本来就没有意义,留着反而是坏体验。
5.3 日志、预演模式与人工复核清单
批量任务一定要有日志。我的做法是每个文件处理完就往 JSONL 里追加一行,字段包括文件名、总页数、删除页号列表、复核页号列表、是否图片型、耗时。这份日志有三个用途:出问题时回溯、统计不同来源文档的阈值分布、以及生成人工复核清单。
复核清单是这套流程的安全阀。所有"文本层说没内容、像素层说有内容但墨迹很少"的页面,全部进这个清单,附上页号、墨迹占比、暗像素数,输出成一个文本文件。人工只需要看这些页——通常只占全部页面的百分之几,几分钟就能过完。
注意:所有输出文件都写到新目录,绝对不要原地覆盖源文件。这不是过度谨慎。我见过一次因为路径拼接写错,输出路径和输入路径相同,几十份原始档案被覆盖成删减版,没法恢复。加上
dst_path != src_path的显式断言,能挡住这类事故。
6. 踩坑实录与问题速查
这套方案上线后跑过各式各样的文档,下面这些问题都是真真切切遇到的,整理成速查表,方便你调试时对照。
6.1 典型问题与排查路径
| 现象 | 大概率原因 | 排查动作 | 处理办法 |
|---|---|---|---|
| 整份文档被删光 | 扫描件,文本层全空 | 打印前 20 页的字符数 | 开is_image_based检测,强制走像素层 |
| 空白页一个没删 | 灰底扫描,固定门限失效 | 打印paper值 | 用分位数自适应,或下调ink_ratio |
| 正文单行页被误删 | 阈值太激进 | 看被删页的ratio | 调低ink_ratio到 0.001,加文本层兜底 |
| 背面透印页删不掉 | 残影被计为墨迹 | 看dark_count是否几百量级 | 开 3×3 模糊,容差收到 8 |
| 边缘黑边导致删不掉 | 扫描盖板阴影 | 看暗像素是否集中在四周 | 加大margin到 0.04 |
| 输出文件体积没变 | 没清孤儿对象 | 对比删除前后大小 | save加garbage=3, deflate=True |
| 目录书签点开是空白 | 删页后没重映射 | 查 TOC 页码 | 按删页数量重算,指向被删页的书签后移 |
| 脚本跑一半崩了 | 加密文档或损坏文件 | 看异常堆栈 | 单文件 try/except,记录后跳过继续 |
| 处理速度太慢 | DPI 太高 | 计时单页渲染 | 降到 72 到 100 DPI,确认灰度和alpha=False |
| 中文字符判空失败 | 全角空格未剥离 | 打印repr(text[:20]) | 正则补上\u3000等字符 |
6.2 实操心得
第一条心得是先看再定参数。拿到一批新来源的文档,不要急着套用旧参数。抽五六页,分别打印字符数、paper值、墨迹占比、暗像素数,心里就有数了。不同来源的文档差异能有多大?我遇到过一份扫描件,paper只有 198,同一批另一份是 251,两个值用同一套固定门限,必然有一个全军覆没。
第二条是阈值宁松勿紧,删除宁少勿多。这条原则意味着你的默认配置会漏删一些空白页,没关系,第二次人工过一遍复核清单就能补上。反过来,默认配置激进,误删了正文,那是找不回来的。
第三条关于性能:一份 300 页的混合文档(一半有文本、一半是扫描图),单进程跑下来的时间分布大概是——文本抽取 0.3 秒、150 页渲染检测 1.5 秒、写盘 0.5 秒。渲染占了绝大部分。所以优化性能的第一优先级永远是减少不必要的渲染,而不是优化渲染本身。文本层预检的价值就在这里:它把大半的页面挡在了渲染之外。
第四条是个小技巧,关于页面旋转。有些扫描件的页面对象带着 90 度或 270 度的旋转标记,get_pixmap默认会应用这个旋转,渲染出来是正的,不影响像素统计。但如果你用的是别的渲染路径,忘了应用旋转,横竖方向一错,边缘裁剪的比例就全乱了,A4 页面按横向裁 2% 会切掉不少内容。检查一下page.rotation,不确定的话显式设成 0 再渲染。
最后一条,保存一份删除清单。我习惯在输出目录里放一个同名的.txt,内容是"删除了第 3、7、8、15 页,判定依据分别是……"。这份清单的价值在于,等半年后有人问"这份文档怎么少了几页",你不用重新跑一遍脚本去猜。工程上的可追溯性,往往比算法本身更重要。