扫描版 PDF 压缩
> 一句话:把「扫描版 PDF 太大」从手工试参数变成一条命令——自动校准出能满足
> 目标体积的最高质量档位,压完还能用 PSNR 验证质量。
何时使用
- 用户说「这个 PDF 太大了,帮我压到 200M 以下」
- 网盘/邮件/考试报名系统提示 PDF 超限
- 一堆整页扫描的书、讲义、题册需要批量瘦身
不适用:
- 文本型 PDF(可选中文字、体积通常本来就不大)——本工具针对图像流,文本型
压不动会直接报错退出。文本型请用 pymupdf 重新生成或 Ghostscript。
- 带透明图层(smask)的设计稿 PDF——为避免破坏透明通道,此类图像会被跳过。
- 需要保持原始 DPI 用于印刷的场景——本工具做有损重编码,印刷件别压。
前置条件
| 依赖 | 必需 | 说明 |
|---|---|---|
| Python 3.9+ | 是 | |
| pymupdf | 是 | pip install pymupdf(图像读写与 PDF 结构操作) |
| pillow | 是 | pip install pillow(LANCZOS 降采样与 JPEG 重编码) |
无需 Ghostscript / qpdf / poppler——纯 Python 栈,Windows / macOS / Linux 均可跑。
快速开始
# 自动校准 + 压缩到 190MB 以下 + 压缩后自动 PSNR 验证
python scripts/pdf_scan_compressor.py 大书.pdf 大书_压缩版.pdf --verify
真实运行样例(433.9 MB 的 503 页扫描讲义,目标 190 MB):
[info] 503 pages | JPEG images 503 | skip: smask 0, non-JPEG 0 | src 433.9 MB
[calib] target_dpi q predicted_MB
[pick] dpi=220 quality=70 predicted ~180.6 MB
[enc] 500/503 230s
[done] out.pdf: 433.9 -> 173.7 MB (40%), 232s
[verify] page 2: PSNR 47.2 dB
...
[verify] median PSNR 42.1 dB —— 几乎不可分辨
想先看看会压成什么样、不动文件:
python scripts/pdf_scan_compressor.py 大书.pdf --calibrate
接口
| 参数 / 子命令 | 作用 | 默认值 |
|---|---|---|
input output |
输入、输出路径(源文件只读,绝不原地覆盖) | 必填 |
--target-mb |
目标体积上限(MB,十进制) | 190 |
--calibrate |
只跑抽样校准并打印建议参数,不写文件 | 关 |
--dpi |
手动指定目标 DPI(跳过校准) | 自动 |
--quality |
手动指定 JPEG 质量(跳过校准) | 自动 |
--verify |
压缩后抽样 8 页做 PSNR 渲染对比 | 关 |
退出码:0 成功;2 结果超目标体积;3 没有可重编码的 JPEG(文本型/Fax);5 PSNR 不达标。
关键参数与阈值(每个都要给理由)
| 参数 | 取值 | 为什么是这个值 |
|---|---|---|
| 校准网格 | DPI 180–260 × 质量 60–80 | 覆盖「屏幕阅读够用」的常用档;实测 220/70 对扫描讲义约压到 40% 体积 |
| 校准抽样量 | 24 页 | 预测体积与全量实际偏差实测 <5%,24 页足以覆盖版式差异,耗时约 1–2 分钟 |
| PSNR 合格线 | 中位数 ≥30 dB | ≥35 dB 肉眼几乎不可分辨;30–35 dB 有轻微差异但阅读无损;<30 dB 建议调参重跑 |
| 降采样上限 | scale = min(1.0, target_dpi/src_dpi) |
源图低于目标 DPI 时绝不放大,避免越压越大 |
| subsampling=2 | 4:2:0 色度抽样 | 扫描文字为灰黑内容,色度减半不可感知,体积再省约 15% |
| 验证渲染 DPI | 150 | 模拟普通屏幕阅读观感;用更高 DPI 比对会放大差异、误导判断 |
工作原理
- 体检:遍历 xref 表,统计全部唯一 JPEG(DCTDecode)图像;带 smask 的跳过、
非 JPEG 的跳过并计数。 - 换算 DPI:每张图按「像素宽 ÷ 放置宽度(pt)」算出实际 DPI;同一图多处放置取
最小 DPI,保证任何位置都不低于目标。 - 抽样校准:随机抽 24 页,在 DPI×质量网格上实测编码体积 → 线性外推全书总量
→ 选满足目标前提下 DPI×质量最高的档位。 - 全量重编码:LANCZOS 降采样 + JPEG 重编码(optimize + 4:2:0),
update_stream
原位替换图像流;书签/链接/页面对象一概不碰。 - 保存:
garbage=4, deflate=True, clean=True——清理未引用对象、压缩交叉引用流。 - 验证(可选):抽样渲染原图与压缩版至 150 DPI,逐页算 PSNR 给出质量结论。
坑(按踩到的顺序写)
doc.xref_stream(xref)对 DCT 图返回的是解码后位图(几百 MB 的裸 RGB),
PIL 读不了直接UnidentifiedImageError。→ 必须用doc.xref_stream_raw(xref)
拿原始 JPEG 字节。实测 pymupdf 1.28.2。- OneDrive「按需文件」是隐形杀手:云占位文件(属性含 0x400000)一读就触发
整个文件回灌下载,414 MB 的书会把前台命令拖到超时且无任何报错输出。
→ 先Copy-Item到本地盘(后台跑、给足时间),再对本地副本操作。 - 503 页全量重编码约 4 分钟,前台跑容易撞 2 分钟超时。→ 长任务放后台,
脚本每 50 页打印一次进度([enc] 350/503 191s),卡没卡一眼可见。 pymupdf.Document没有xref_stream_raw_length(网上旧教程有)。测流长度
只能len(doc.xref_stream_raw(xref))。- 整页扫描图常超 PIL 默认 DecompressionBomb 阈值(8900 万像素报警)。
脚本已设Image.MAX_IMAGE_PIXELS = None,别删这行。
排错
| 现象 | 处理 |
|---|---|
error: 未发现可重编码的 JPEG 图像 |
是文本型 PDF 或 Fax(CCITT)/JBIG2 编码,本工具不适用 |
| 结果仍超目标 | 用 --calibrate 看表,手动指定更低 --dpi/--quality 重跑(如 200/65) |
| PSNR < 30 dB | 调高 --quality(如 75/80)或提高 --dpi,代价是体积变大 |
pip install pymupdf 后仍 import 失败 |
确认用的是同一个解释器;中文用户名机器优先用 venv 而非全局 pip |
| 读写极慢且无报错 | 检查文件是否 OneDrive 云占位(见坑 2),先落本地盘 |
相关文档
- 原理与网格设计的实测数据(433.9 MB→173.7 MB 完整校准表)见本文件「关键参数」与「工作原理」两节;本 skill 单脚本即全部实现,无外部 references。