Zipic实录 6 -PDF压缩:利用macOS原生能力
这是「Zipic 实录」系列第六篇。上一篇聊了文件夹监控自动压缩的实现。这一篇聊另一个核心功能——PDF 压缩,和图片压缩完全不同的技术路径。
PDF 压缩:利用 macOS 原生能力
PDF 作为日常文档中最常见的格式,很多时候里面就是塞满了图片。支持 PDF 压缩是用户呼声很高的需求。
PDF 压缩和普通图片压缩完全是两码事。图片压缩相对简单——读取像素数据,用算法重新编码,写入新文件。但 PDF 是复杂的容器格式,里面可能包含文本、矢量图形、嵌入字体、书签、表单……当然还有图片。关键问题是:如何在不破坏 PDF 结构的前提下,只对其中的图片进行压缩?
最初考虑过几种方案:Ghostscript 功能强大但体积也大,需要处理外部依赖的分发问题;ImageMagick 对 PDF 结构的保留不够好;自己解析 PDF 格式手动提取和重压缩图片——这条路想想就知道是个无底洞。
最终选择了 macOS 原生的 Quartz Filter 技术,说实话有点相见恨晚。它就藏在系统里专门干这个事,系统自带的「预览」应用导出 PDF 时的「减小文件大小」选项用的就是这个。工作原理很优雅:在渲染 PDF 页面时自动对其中的位图图像进行 JPEG 重压缩,而文本、矢量图形、字体这些保持原样不动。
这是 macOS 平台独有的优势——Core Graphics 框架有硬件加速支持,处理速度很快,而且不需要打包任何外部依赖,应用体积不会膨胀。
核心思路是动态生成自定义的 .qfilter 配置文件定义压缩参数,然后通过 Core Graphics 框架应用这个滤镜来渲染 PDF。整个流程:根据用户选择的压缩等级生成对应的 Quartz Filter 配置文件 → 读取源 PDF → 创建新的 PDF 上下文并应用 Filter → 逐页渲染(这一步 Filter 会自动处理图像压缩)→ 输出压缩后的 PDF。
关键代码(Quartz Filter 文件生成):
private static func generateQFilter(at url: URL, compressionQuality: Double, level: Int) {
let xmlContent = """
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "...">
<plist version="1.0">
<dict>
<key>FilterData</key>
<dict>
<key>ColorSettings</key>
<dict>
<key>ImageSettings</key>
<dict>
<key>Compression Quality</key>
<real>\(compressionQuality)</real> <!-- 0.0-1.0 -->
<key>ImageCompression</key>
<string>ImageJPEGCompress</string> <!-- JPEG 压缩 -->
<key>ImageScaleSettings</key>
<dict>
<key>ImageScaleFactor</key>
<real>1.0</real> <!-- 保持原尺寸 -->
</dict>
</dict>
</dict>
</dict>
<key>FilterType</key>
<integer>1</integer>
<key>Name</key>
<string>Compress PDF</string>
</dict>
</plist>
"""
try xmlContent.write(to: url, atomically: true, encoding: .utf8)
}
关键代码(PDF 压缩核心逻辑):
static func pdf(at sourceURL: URL, to destinationURL: URL, compressionLevel: Double) -> CommandResult {
// 1. 加载 Quartz Filter
guard let filter = QuartzFilterManager.filterForCompressionLevel(compressionLevel) else {
return CommandResult(output: "Failed to load filter", error: .exit, status: -1)
}
// 2. 读取源 PDF
guard let sourcePDF = PDFDocument(url: sourceURL) else {
return CommandResult(output: "Failed to load PDF", error: .exit, status: -1)
}
// 3. 创建数据容器和 PDF 上下文
let mutableData = NSMutableData()
guard let consumer = CGDataConsumer(data: mutableData),
let firstPage = sourcePDF.page(at: 0) else { return /* error */ }
var mediaBox = firstPage.bounds(for: .mediaBox)
guard let pdfContext = CGContext(consumer: consumer, mediaBox: &mediaBox, nil) else {
return /* error */
}
// 4. 应用 Filter 到上下文(关键步骤)
guard filter.apply(to: pdfContext) else {
return CommandResult(output: "Failed to apply filter", error: .exit, status: -1)
}
// 5. 逐页渲染(Filter 自动处理图像压缩)
for pageIndex in 0..<sourcePDF.pageCount {
guard let page = sourcePDF.page(at: pageIndex) else { continue }
var pageRect = page.bounds(for: .mediaBox)
pdfContext.beginPage(mediaBox: &pageRect)
page.draw(with: .mediaBox, to: pdfContext)
pdfContext.endPage()
}
pdfContext.closePDF()
try mutableData.write(to: destinationURL, options: .atomic)
return CommandResult(output: "Success", error: .exit, status: 0)
}
压缩等级的设计上,我定义了 6 个级别,对应不同的 JPEG 质量参数(0.9 到 0.2)。值得注意的是,JPEG 压缩到 0.2 这个级别图片会出现明显的压缩痕迹,对于需要打印或展示的文档建议用较高的质量级别,如果只是用于网络传输或归档可以激进一些。
踩坑经验:动态生成的 .qfilter 文件需要存放在合适的位置(我选择了 ~/Library/zipic/filters/),要处理好文件不存在或损坏的情况,必要时重新生成。
另外要注意的是,Quartz Filter 只压缩 PDF 中的位图图像,如果一个 PDF 主要是文字和矢量图形,压缩后体积可能变化不大——这一点需要在产品层面向用户说明,避免「为什么压了半天没变小」的困惑。好消息是 PDF 中的元数据(书签、链接、表单等)在这个方案下都能完整保留,这是很多第三方方案做不到的。
下一篇聊聊缩略图生成的内存优化和设备指纹稳定性——一个是性能问题,一个是用户体验问题,都是实际运营中暴露出来的。
夜雨聆风
