PDF 压缩不是把文件后缀改成 zip。PDF 是一个容器,内部对象包括内容流、位图图像、字体子集、图形状态、注释、书签和元数据,真正让文件膨胀的往往不是页面本身,而是图片编码、字体嵌入方式和没有清理的重复资源。BentoPDF 这类工具想要做到“超压缩”,通常会先解析 PDF 内部对象,再决定哪些可以重编码、哪些只能无损压缩;如果处理策略错误,压缩后甚至会比原文件更大。
这里把项目里的三个名称分开理解:BentoPDF 是工具名,Hyper Compress 是对外提供的激进压缩模式,Kura 是内部负责 PDF 解析和重写流水线的引擎模块。实际项目里用什么名字都可以,关键是压缩链路的几个环节不能少:打开输入、识别体积来源、重编码图片、清理对象、校验输出。下面会基于 Python 生态实现一个最小可复现版本,让你能把它移植到自己的工具或服务里。
1. 先理解 PDF 体积膨胀的根源
1.1 PDF 不是单个文件,而是一组对象的集合
PDF 虽然以.pdf后缀呈现,内部却不是简单的一段数据流,而是一个由对象组成的结构。每一页会引用页面资源,页面资源又可能引用内容流、字体、图片 XObject 等。也就是说,文件体积取决于这些对象各占多少空间,而不取决于页数本身。
一个 20 页的纯文字 PDF 可能只有几百 KB,但一个 5 页的扫描 PDF 可能超过 100MB,原因就是页面内容流很轻,图片对象却非常重。把 PDF 比喻成一个压缩包并不完全准确,因为 PDF 内部对象本身就是独立存储的,有些流已经用 Flate 压缩过,有些图片则是原始位图或质量极高的 JPEG/PNG,这部分才是压缩重点。
主要影响 PDF 体积的对象类型如下:
| 对象类型 | 体积来源 | 压缩难度 |
|---|---|---|
| 位图图片 | 扫描图、截图、照片,未压缩或高质量编码 | 有损重编码收益大 |
| 页面内容流 | 文本绘制指令、图形指令 | Flate 压缩后收益有限 |
| 字体对象 | 完整字体文件被嵌入 | 子集化可大幅缩小 |
| 元数据与书签 | 作者、标题、评论、隐藏附件 | 通常可无损清理 |
| 重复资源 | 同一图片在多页重复出现 | 合并对象可明显减小 |
| 注释和表单 | 批注、AcroForm 字段 | 视内容而定,不能随意删除 |
“Hyper Compress”这个名字听起来只是一个形容词,但本质上它应该对应一套可执行的压缩策略。压缩前先回答一个问题:这个 PDF 里最占空间的对象是什么?如果答案是图片,就重编码图片;如果是字体,就做字体子集化;如果是重复对象,就做对象合并。
1.2 无损压缩和有损压缩的边界
PDF 压缩工具通常同时使用两种思路:
- 无损:清理元数据、压缩内容流、抽取字体子集、合并重复图片对象。文件不会出现视觉失真。
- 有损:把高分辨率图片降低 DPI,把 PNG 转成 JPEG,降低 JPEG 质量。文件会变小,但图像细节会损失。
很多项目会把“压缩级别”设计成三档,例如normal、strong、hyper。正常级别尽量做无损或轻度有损,强压缩降低图片质量,超压缩则面向在线预览场景,可以接受较明显的细节损失。这就是 Hyper Compress 存在的意义:用户知道这个模式会压缩得厉害,但使用时必须清楚它不适合打印归档。
1.3 三种压缩等级的基本取舍
在落地时,压缩等级不能只改一个参数,否则很难控制结果。建议至少组合四组参数:图像目标分辨率、JPEG 质量、是否强制重编码已有 JPEG、是否允许去除字体嵌入。
| 压缩等级 | 适合场景 | 颜色图像分辨率 | JPEG 质量 | 已有 JPEG 是否重编码 | 字体处理 |
|---|---|---|---|---|---|
| normal | 打印、归档、需要放大查看 | 150 DPI 以上 | 85 | 否 | 子集化并嵌入 |
| strong | 日常分享、邮件附件 | 120 DPI | 75 | 是 | 子集化并嵌入 |
| hyper | 网页预览、即时通讯发送 | 60-72 DPI | 50-60 | 是 | 子集化并嵌入 |
如果只设置一个数值,很容易出现两种尴尬结果:压缩后体积没有变化,或者页面模糊得无法阅读。压缩级别的本质其实就是一组参数模板,用户不需要理解 DPI 和 JPEG 质量,只需要告诉工具“我需要多小”。
2. 环境准备:技术选型与依赖安装
2.1 为什么选 Ghostscript + pikepdf
自己实现一个完整的 PDF 解析引擎并不现实,因为 PDF 规范非常复杂,包含对象流、交叉引用表、加密、字体映射和多种图像滤镜。主流做法是借助成熟工具,常见组合有三个:
| 方案 | 优点 | 缺点 |
|---|---|---|
| 纯 Python 解析 PDF 对象 | 可控性强 | 处理图片、字体和加密逻辑工作量太大 |
| pikepdf 直接修改对象 | 能精确删除元数据和重复资源 | 对图片重编码需要配合 Pillow,链路较长 |
| Ghostscript 重写整个 PDF | 成熟、稳定,能重编码图片和字体 | 参数多,默认值需要调优 |
这个项目选择 Ghostscript 作为核心压缩器,pikepdf 作为预处理器。Ghostscript 的pdfwrite设备会重新解析页面内容,它会把图片重采样、把字体子集化、把内容流重新压缩,一次调用就能完成大量优化。pikepdf 则负责打开 PDF、删除不需要的元数据、保存中间产物,补足 Ghostscript 对元数据管理不够灵活的问题。
2.2 安装顺序与版本检查
先安装 Python 依赖:
python -m pip install --upgrade pikepdf pillow reportlabpikepdf用于 PDF 读写,pillow用于生成测试图片,reportlab用于构造测试 PDF。如果你的项目只需要压缩,不需要生成测试样本,pillow和reportlab可以不加。
然后安装系统工具。ghostscript是压缩主引擎,qpdf和poppler-utils用于验证输出 PDF 结构、提取文本和渲染页面。
# Ubuntu / Debian sudo apt-get update sudo apt-get install -y ghostscript poppler-utils qpdf # macOS brew install ghostscript poppler qpdf # Windows 可以使用 choco,也可以从 Ghostscript 官方安装包安装 choco install ghostscript poppler qpdf安装完成后,检查版本:
gs --version qpdf --version pdftotext -v如果gs命令找不到,说明 Ghostscript 没有加入 PATH,后面所有压缩流程都会失败。建议在任何操作前先跑这个命令,很多“压缩没有反应”的问题根本不是代码问题,而是系统里压根没有压缩引擎。
2.3 项目目录规划
为了保持流程清晰,把项目拆成两个入口文件:一个负责压缩逻辑,一个负责命令行调用。
bentopdf-demo/ ├── kura.py # 压缩引擎:预处理、调 Ghostscript、回退策略 ├── cli.py # 命令行入口 ├── requirements.txt # Python 依赖 ├── scripts/ │ └── gen_test_pdf.py # 生成测试 PDF └── output/ # 输出目录这里把引擎命名为kura.py,只是复用项目材料里的名称。实际项目完全可以叫compressor.py或pdf_engine.py。
3. 用 Kura 模块实现压缩链路
3.1 定义压缩等级参数
压缩等级不应该散落在代码里,建议用配置文件或常量表统一管理。下面用 Python 数据类保存每档参数:
# kura.py from dataclasses import dataclass from enum import Enum class Level(str, Enum): NORMAL = "normal" STRONG = "strong" HYPER = "hyper" @dataclass(frozen=True) class LevelConfig: pdfsettings: str color_dpi: int gray_dpi: int mono_dpi: int jpeg_quality: int pass_through_jpeg: bool LEVEL_CONFIGS = { Level.NORMAL: LevelConfig( pdfsettings="/ebook", color_dpi=150, gray_dpi=150, mono_dpi=300, jpeg_quality=85, pass_through_jpeg=True, ), Level.STRONG: LevelConfig( pdfsettings="/ebook", color_dpi=120, gray_dpi=120, mono_dpi=300, jpeg_quality=75, pass_through_jpeg=False, ), Level.HYPER: LevelConfig( pdfsettings="/screen", color_dpi=72, gray_dpi=72, mono_dpi=150, jpeg_quality=60, pass_through_jpeg=False, ), }关键点在于pass_through_jpeg。如果原始 PDF 里已经是一张质量很高的 JPEG,正常模式可以原样保留,节省一次有损重编码;hyper 模式则会把所有 JPEG 重新压一遍,否则体积很可能压不下来。
pdfsettings是 Ghostscript 的预设,/ebook对应 150 DPI 左右的电子书质量,/screen对应屏幕阅读质量。要注意的是,/printer在打印场景下更安全,但如果文件里有超大扫描图,压缩率会低很多。
3.2 封装 Ghostscript 调用
Ghostscript 的命令行参数非常多,但核心流程可以封装成一个函数。下面这段代码把LevelConfig转成完整的gs命令:
import subprocess from pathlib import Path def build_gs_command(input_path: Path, output_path: Path, config: LevelConfig) -> list[str]: cmd = [ "gs", "-dSAFER", "-dBATCH", "-dNOPAUSE", "-sDEVICE=pdfwrite", "-dCompatibilityLevel=1.4", f"-dPDFSETTINGS={config.pdfsettings}", f"-dColorImageResolution={config.color_dpi}", f"-dGrayImageResolution={config.gray_dpi}", f"-dMonoImageResolution={config.mono_dpi}", f"-dJPEGQ={config.jpeg_quality}", "-dDetectDuplicateImages=true", "-dSubsetFonts=true", "-dCompressFonts=true", "-dCompressStreams=true", "-dEmbedAllFonts=true", "-dAutoRotatePages=/None", "-dAutoFilterColorImages=false", "-dColorImageFilter=/DCTEncode", "-dAutoFilterGrayImages=false", "-dGrayImageFilter=/DCTEncode", "-sOutputFile=" + str(output_path), str(input_path), ] if config.pass_through_jpeg: cmd.append("-dPassThroughJPEGImages=true") else: cmd.append("-dPassThroughJPEGImages=false") return cmd def run_ghostscript(input_path: Path, output_path: Path, config: LevelConfig) -> None: cmd = build_gs_command(input_path, output_path, config) result = subprocess.run(cmd, capture_output=True, text=True, timeout=120) if result.returncode != 0: raise RuntimeError( "Ghostscript 执行失败,退出码 {}\n最后 2000 字符日志:\n{}".format( result.returncode, result.stderr[-2000:] ) )参数解释:
-dSAFER:限制 PostScript 的文件写入能力,避免处理恶意输入时产生额外文件。-dDetectDuplicateImages=true:检测多页中重复的图片对象,同一张图只保留一次。-dSubsetFonts=true:字体只保留页面用到的字形,这是压缩字体体积的关键。-dCompressStreams=true:内容流使用 Flate 压缩。-dAutoRotatePages=/None:不自动旋转页面,避免输出页面方向变化。-dColorImageFilter=/DCTEncode:把颜色图像统一编码为 JPEG。这个参数在可能产生大文件时才适用,如果 PDF 有大量带透明通道的图片,需要改成自动判断,否则会出现渲染问题。
3.3 预处理:清理元数据和中间产物
Ghostscript 已经能清理一部分无用对象,但元数据删除并不直观。用 pikepdf 先做一次预处理,可以让后续压缩更干净:
import pikepdf def prepare_pdf(source: Path, prepared: Path, keep_metadata: bool = False) -> None: with pikepdf.open(source) as pdf: if not keep_metadata: pdf.docinfo = {} pdf.save(prepared, compress_streams=True)强制清空docinfo会移除作者、标题、创建软件等信息。注意:如果 PDF 有签名或印章,清空docinfo可能破坏验证链,生产环境需要保留选项。
接下来是完整压缩入口:
def compress_pdf( input_path: str | Path, output_path: str | Path, level: Level = Level.STRONG, keep_metadata: bool = False, ) -> dict: src = Path(input_path) out = Path(output_path) if not src.exists(): raise FileNotFoundError(f"输入文件不存在: {src}") if out.exists() and out.resolve() == src.resolve(): raise ValueError("输出路径不能和输入路径相同") out.parent.mkdir(parents=True, exist_ok=True) config = LEVEL_CONFIGS[level] prepared = src.with_suffix(".prepared.pdf") gs_output = src.with_suffix(".gs.pdf") try: prepare_pdf(src, prepared, keep_metadata) run_ghostscript(prepared, gs_output, config) src_size = src.stat().st_size out_size = gs_output.stat().st_size saved = (1 - out_size / src_size) * 100 if src_size else 0 if out_size < src_size: gs_output.replace(out) result = "ok" else: # 压缩后没有变小,保留原文件,避免用户得到更大的 PDF src.replace(out) result = "skipped" return { "result": result, "input_size": src_size, "output_size": out_size, "saved_percent": round(saved, 2), "level": level.value, } finally: if prepared.exists(): prepared.unlink() if gs_output.exists(): gs_output.unlink()这里有一个非常重要的回退策略:压缩后如果比原文件大,就直接保留原文件。很多新手会忽略这一步,导致用户看到“压缩后的 PDF 竟然变大了”。在批量处理场景中,这种回退能避免把高质量的原始文件替换掉。
3.4 命令行入口
为了便于测试,添加一个简单的 CLI:
# cli.py import argparse import json from kura import Level, compress_pdf def main() -> None: parser = argparse.ArgumentParser(description="BentoPDF Kura PDF Compressor") parser.add_argument("input", type=str, help="输入 PDF 路径") parser.add_argument("output", type=str, help="输出 PDF 路径") parser.add_argument( "--level", type=Level, choices=list(Level), default=Level.STRONG, help="压缩等级:normal, strong, hyper", ) parser.add_argument( "--keep-metadata", action="store_true", help="保留 PDF 元数据", ) args = parser.parse_args() result = compress_pdf( input_path=args.input, output_path=args.output, level=args.level, keep_metadata=args.keep_metadata, ) print(json.dumps(result, ensure_ascii=False, indent=2)) if __name__ == "__main__": main()这样可以通过参数控制压缩等级,也方便后续接入 Web 后台。
4. 生成测试 PDF 并验证压缩效果
4.1 构造一个大体积测试 PDF
先用脚本生成一个带大图和一页文字的测试 PDF,模拟扫描件场景:
# scripts/gen_test_pdf.py from pathlib import Path from PIL import Image, ImageDraw from reportlab.pdfgen import canvas def build_image(path: Path) -> None: width, height = 2480, 3508 # A4 300 DPI 约等于 2480x3508 img = Image.new("RGB", (width, height), "white") draw = ImageDraw.Draw(img) for y in range(0, height, 20): draw.line([(0, y), (width, y)], fill=(30, 30, 30)) for x in range(0, width, 6): draw.line([(x, 0), (x, height)], fill=(230, 230, 230)) img.save(path, dpi=(300, 300)) def build_pdf(image_path: Path, output_path: Path) -> None: c = canvas.Canvas(str(output_path), pagesize=(595.0, 842.0)) c.setTitle("BentoPDF Test Document") c.drawImage(str(image_path), 0, 0, width=595, height=842) c.showPage() c.setFont("Helvetica", 12) c.drawString(40, 780, "Hello, BentoPDF Compression") c.drawString(40, 760, "Check text extraction after compression.") c.showPage() c.save() if __name__ == "__main__": build_image(Path("scan_like.png")) build_pdf(Path("scan_like.png"), Path("sample_large.pdf"))执行:
python scripts/gen_test_pdf.py生成出的sample_large.pdf在几 MB 到几十 MB 之间,取决于图像编码方式。它足够用于测试。
4.2 执行压缩
python cli.py sample_large.pdf output/sample_hyper.pdf --level hyper正常运行时,输出类似:
{ "result": "ok", "input_size": 5242880, "output_size": 1048576, "saved_percent": 80.0, "level": "hyper" }如果第一次执行报gs: command not found,不要继续调压缩参数,先回到环境检查,确认 Ghostscript 已安装并加入 PATH。这是这类项目最常见的启动故障。
4.3 用命令验证输出是否可用
文件变小不等于压缩成功,还需要检查结构、文本和渲染结果。
qpdf --check output/sample_hyper.pdf pdftotext output/sample_hyper.pdf - | head -20 pdftoppm -png -r 72 output/sample_hyper.pdf output/hyper_page建议建立一张验证表:
| 验证项 | 命令 | 预期结果 |
|---|---|---|
| 文件结构 | qpdf --check | 没有 error 输出 |
| 文本可提取 | pdftotext output.pdf - | 第二页文字仍可读取 |
| 页面渲染 | pdftoppm -png -r 72 | 图片没有被彻底破坏 |
| 文件大小 | ls -lh | 比原文件明显减小 |
如果pdftotext输出为空,说明压缩过程中文本层被破坏或字体字形丢失,应该检查 Ghostscript 日志和字体嵌入参数。只在视觉上能看还不够,PDF 压缩工具必须兼顾“人可以看”和“机器能解析”。
5. 常见问题排查
5.1 压缩后文件反而更大
这是最多人遇到的问题。常见原因是输入 PDF 已经被优化过,内部图片已经是低分辨率 JPEG,或者页面内容以矢量为主。如果强行用 hyper 重编码,Ghostscript 仍会创建新对象,体积可能回升。
处理方式:
- 压缩前先分析文件内部对象,确认图片是否已压缩。
- 检查对比结果,如果输出不小于输入,保留原文件。
- 不要用 hyper 压缩所有 PDF,先根据页数、图片数量做判断。
5.2 图片变模糊
把图片分辨率降到 72 DPI 后,扫描件上的小字会非常模糊。此时需要区分场景:hyper 适合网页预览和即时通讯,不适合打印归档。如果用户需要打印,应该使用 normal 或 strong。
如果 strong 下依然模糊,可以检查原始 PDF 是否是“扫描件”,以及页面是否有多张图片被合并。有些 PDF 一页包含一张背景图和多张前景小图,统一重编码会把所有图都降分辨率,导致前景文字模糊。
5.3 中文乱码和字体缺失
PDF 压缩后中文乱码,通常不是“压缩”造成的,而是字体嵌入策略出错。Ghostscript 默认会尝试嵌入字体,但如果原始 PDF 没有嵌入中文字体,或者输出时使用了不支持的过滤设置,就会出现字体缺失。
排查时先执行:
pdffonts output/sample_hyper.pdf如果字体后标记为noEmbed,说明输出 PDF 没有嵌入该字体。修复方法是保留-dEmbedAllFonts=true,并且只使用-dSubsetFonts=true,不要为了减小体积而主动关闭字体嵌入。对于中文字体,子集化已经能节省大量空间,完全去掉字体会让不同设备显示不一致。
5.4 加密 PDF 报错
如果输入 PDF 有密码保护,pikepdf 打开时会直接报错,Ghostscript 也会拒绝处理。处理流程是:先确认密码,再在打开时传入password参数,输出一份解密后的中间文件,压缩后再考虑是否加密回写。生产环境还需要注意,解密后的中间文件要放在受控目录,并在压缩结束后立即删除。
常见现象和排查路径如下:
| 现象 | 常见原因 | 检查方式 | 处理建议 |
|---|---|---|---|
gs: command not found | Ghostscript 未安装或未加入 PATH | gs --version | 安装并确认 PATH |
| 压缩后体积变大 | 输入 PDF 已被优化,或图片是低质量 JPEG | qpdf --show-object查看图片编码 | 保留原文件,关闭强制重编码 |
| 图片模糊 | DPI 和目标分辨率设置太低 | 渲染页面放大查看 | 使用 strong 或 normal |
| 中文乱码 | 字体未嵌入或子集化失败 | pdffonts output.pdf | 开启 `Embed |