基于Ghostscript和pikepdf的Python PDF压缩实战 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 DPI75是子集化并嵌入hyper网页预览、即时通讯发送60-72 DPI50-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(frozenTrue) 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_dpi150, gray_dpi150, mono_dpi300, jpeg_quality85, pass_through_jpegTrue, ), Level.STRONG: LevelConfig( pdfsettings/ebook, color_dpi120, gray_dpi120, mono_dpi300, jpeg_quality75, pass_through_jpegFalse, ), Level.HYPER: LevelConfig( pdfsettings/screen, color_dpi72, gray_dpi72, mono_dpi150, jpeg_quality60, pass_through_jpegFalse, ), }关键点在于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, -sDEVICEpdfwrite, -dCompatibilityLevel1.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}, -dDetectDuplicateImagestrue, -dSubsetFontstrue, -dCompressFontstrue, -dCompressStreamstrue, -dEmbedAllFontstrue, -dAutoRotatePages/None, -dAutoFilterColorImagesfalse, -dColorImageFilter/DCTEncode, -dAutoFilterGrayImagesfalse, -dGrayImageFilter/DCTEncode, -sOutputFile str(output_path), str(input_path), ] if config.pass_through_jpeg: cmd.append(-dPassThroughJPEGImagestrue) else: cmd.append(-dPassThroughJPEGImagesfalse) 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_outputTrue, textTrue, timeout120) if result.returncode ! 0: raise RuntimeError( Ghostscript 执行失败退出码 {}\n最后 2000 字符日志\n{}.format( result.returncode, result.stderr[-2000:] ) )参数解释-dSAFER限制 PostScript 的文件写入能力避免处理恶意输入时产生额外文件。-dDetectDuplicateImagestrue检测多页中重复的图片对象同一张图只保留一次。-dSubsetFontstrue字体只保留页面用到的字形这是压缩字体体积的关键。-dCompressStreamstrue内容流使用 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_streamsTrue)强制清空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(parentsTrue, exist_okTrue) 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(descriptionBentoPDF Kura PDF Compressor) parser.add_argument(input, typestr, help输入 PDF 路径) parser.add_argument(output, typestr, help输出 PDF 路径) parser.add_argument( --level, typeLevel, choiceslist(Level), defaultLevel.STRONG, help压缩等级normal, strong, hyper, ) parser.add_argument( --keep-metadata, actionstore_true, help保留 PDF 元数据, ) args parser.parse_args() result compress_pdf( input_pathargs.input, output_pathargs.output, levelargs.level, keep_metadataargs.keep_metadata, ) print(json.dumps(result, ensure_asciiFalse, indent2)) 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, width595, height842) 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 没有嵌入该字体。修复方法是保留-dEmbedAllFontstrue并且只使用-dSubsetFontstrue不要为了减小体积而主动关闭字体嵌入。对于中文字体子集化已经能节省大量空间完全去掉字体会让不同设备显示不一致。5.4 加密 PDF 报错如果输入 PDF 有密码保护pikepdf 打开时会直接报错Ghostscript 也会拒绝处理。处理流程是先确认密码再在打开时传入password参数输出一份解密后的中间文件压缩后再考虑是否加密回写。生产环境还需要注意解密后的中间文件要放在受控目录并在压缩结束后立即删除。常见现象和排查路径如下现象常见原因检查方式处理建议gs: command not foundGhostscript 未安装或未加入 PATHgs --version安装并确认 PATH压缩后体积变大输入 PDF 已被优化或图片是低质量 JPEGqpdf --show-object查看图片编码保留原文件关闭强制重编码图片模糊DPI 和目标分辨率设置太低渲染页面放大查看使用 strong 或 normal中文乱码字体未嵌入或子集化失败pdffonts output.pdf开启 Embed