在实际工作流里,“合并 PDF”和“压缩 PDF”很少是孤立需求:提交材料要把多份扫描件拼成一个文件,交付报告要控制 PDF 体积,脚本批量处理后还要被 CI 或其他程序调用。Presse 是一个用 Rust 编写的命令行工具,从项目标题可以看出,它的核心能力就是两个维护场景:压缩 PDF 和合并 PDF。相比常见的图形化 PDF 编辑器,CLI 工具的差别在于可组合、可重复、可纳入自动化管道。这篇文章会从使用场景和原理讲起,分析 PDF 合并与压缩背后的对象结构逻辑,再给出一个用 Rust 从零还原 Presse 最小功能的完整实现,最后补充运行验证、常见报错排查和生产化建议。
1. 先搞清楚 Presse 解决什么问题,为什么选 Rust
1.1 合并和压缩是文档流水线里的高频操作
PDF 在办公和研发场景中几乎无处不在,但很多人的处理方式还停留在“打开在线网站 -> 上传文件 -> 等待处理 -> 下载结果”。这种流程有三个明显问题:
- 不可复现:同样的合并操作,下一次还要重新点击一遍。
- 有隐私风险:合同、简历、内部报告上传到第三方服务器,等于把文件交给别人保管。
- 无法批量:一次处理几十个 PDF 时,手工操作的时间和错误率都会上升。
Presse 这类工具把操作收敛到终端命令里,最大的价值不是“省一次点击”,而是让操作变成一条命令。合并一批文件可以写成脚本,压缩逻辑可以嵌进发布流程,输出文件名和目录可以由程序动态决定。对于经常处理文档的开发者、运维人员和内容运营来说,这个能力比任何图形界面都更可靠。
从 Show HN 的发布形式也能看出,这是一个希望通过社区反馈逐步打磨的开源工具。对读者来说,即使不直接使用 Presse,它的设计思路也值得参考:一个只做两件事的小工具,比一个什么都能做但每个功能都不顺手的大软件更适合命令行生态。
1.2 为什么 Rust 适合写这类 CLI
命令行工具有很多实现语言选择,Python、Go、Shell 都常被使用。Rust 在这里的优势可以从几个维度看:
| 维度 | Rust | Python | Go | Shell 脚本 |
|---|---|---|---|---|
| 启动速度 | 极快,毫秒级 | 有解释器加载开销 | 快 | 快 |
| 分发方式 | 单二进制,无需运行时 | 需要解释器和依赖 | 单二进制 | 依赖目标机器的 shell 工具 |
| 内存与文件处理 | 安全且可控 | 内存占用偏高 | 可控 | 依赖外部命令 |
| PDF 生态 | 有 lopdf 等纯 Rust 库 | pypdf、PyMuPDF 很成熟 | 有第三方库 | 基本只能调 qpdf/gs |
| 错误处理 | 编译期强制处理 | 容易遗漏异常分支 | 需要显式处理 | 容易静默失败 |
Rust 还有一个容易被忽略的好处:在没有 Python 环境的 CI 镜像里,一个静态编译的presse二进制可以直接拷进去运行,不需要维护 Python 依赖、虚拟环境和解释器版本。对需要长期维护的自动化工具来说,这个特性非常实用。
1.3 从标题看 Presse 的能力边界
项目标题只给出了两个关键词:compress 和 merge。合理的功能假设是:
- 合并:输入多个 PDF,按顺序输出一个 PDF。
- 压缩:输入一个 PDF,输出体积更小的 PDF。
这两个功能组合起来,可以覆盖一类典型场景:先合并多章 PDF 成一本完整文档,再压缩到适合邮件发送或网盘上传的大小。文章后续的代码实现会围绕这两个功能展开。
2. 理解合并和压缩的原理,才能正确设计命令
2.1 PDF 不是“图片长文件”,而是一堆对象的集合
很多人以为 PDF 就是一页页图片拼起来的文件,合并时直接把文件拼在一起就行。这个理解会导致实现失败。
PDF 的实际结构是一个对象集合:每一页是一个 Page 对象,页面里引用的字体、图片、内容流是其他对象,文件尾部有交叉引用表(xref table)记录每个对象的偏移位置,最后用 trailer 指向根对象。打开 PDF 时,阅读器先读 trailer,找到交叉引用表,再按表里的偏移量定位每个对象。
这解释了为什么“直接把两个 PDF 文件拼接成一个文件”是不可行的:拼接后的文件只有一个 trailer,第二个文件的交叉引用表与第一个文件的对象编号会冲突,阅读器无法正确解析。
合并 PDF 的正确做法是:
- 读取多个 PDF 文件,把每个文件的对象载入内存。
- 重新分配对象编号,避免冲突。
- 把每个源文件的 Page 对象追加到目标文档的页面树里。
- 更新资源引用关系。
- 重新生成交叉引用表和 trailer。
用程序写这段逻辑并不轻松,所以工程上更推荐直接使用现成 PDF 库。Rust 生态里lopdf提供了Document::merge之类的接口,把上述复杂性封装起来。
2.2 压缩 PDF 有两个层次,不要混为一谈
压缩 PDF 常见有两种完全不同的思路:
第一种是“结构压缩”。PDF 里存在大量重复或未被引用的对象,文件尾部还可能残留修改历史产生的旧版本对象。把可以合并的对象写进对象流、删除孤儿对象、重新生成交叉引用表,能让文件变小一些。这种压缩效率有限,但不会影响页面视觉质量,适合已经经过内部优化的 PDF。
第二种是“内容重编码”。PDF 页面的体积主要来自图片和字体。要把图片从无损格式转成 JPEG、降低采样分辨率,或者对嵌入字体做子集化,才能获得几倍甚至几十倍的压缩率。这类操作涉及编码器和渲染引擎,纯读写 PDF 结构的库无法完成。
实际工程中,压缩质量参数一般从高到低分几档:屏幕预览(screen)、电子书(ebook)、打印(printer)。档位越低,图片被压缩得越狠,文件越小,但清晰度也会下降。
2.3 Rust 生态里有什么可用的能力
写一个真实的 PDF 工具,需要先确认技术路线:
lopdf:纯 Rust 实现的 PDF 读写库,支持加载、保存、修改对象、合并文档,适合做结构层面的操作。pdf-writer:偏底层的 PDF 写入库,适合从零生成符合规范的 PDF。pdfium-render:Google PDFium 的 Rust 绑定,能力完整但依赖原生库,部署复杂度高。- Ghostscript 或 qpdf:外部命令行工具,用子进程调用即可拿到成熟的压缩能力。
这里要说清楚一件事:目前纯 Rust 的 PDF 工具链虽然能用,但“高效重编码图片和字体”这件事仍然不如 Ghostscript 等老牌引擎成熟。所以合理的架构选择是:合并和结构瘦身用 Rust 库做,内容压缩通过调用外部命令完成。本文下面的实现就采用这种组合。
3. 环境准备:把 Rust 工具链和依赖先对齐
3.1 安装 Rust 工具链
如果本机还没有 Rust,建议使用rustup安装,它负责管理工具链版本,方便以后升级。
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh安装完成后,重启终端或执行环境变量加载命令,验证版本:
rustc --version cargo --version国内网络环境下,crates.io下载依赖可能很慢。可以在~/.cargo/config.toml里配置镜像源,使用稀疏注册表和国内镜像:
[source.crates-io] replace-with = 'rsproxy' [source.rsproxy] registry = "sparse+https://rsproxy.cn/index/" [net] git-fetch-with-cli = true配置完成后,新建项目再添加依赖时,下载速度会有明显提升。如果已经在使用其他镜像源,只需保持配置一致即可,不需要额外操作。
3.2 创建项目并添加依赖
用 cargo 初始化二进制项目:
cargo new presse cd presse本文需要四个依赖:clap用于命令行参数解析,lopdf用于 PDF 读写和合并,anyhow用于简化错误处理,tempfile用于生成临时文件(压缩时先写临时文件再重命名,避免输出过程被中断导致文件损坏)。
# Cargo.toml [dependencies] clap = { version = "4", features = ["derive"] } lopdf = "0.33" anyhow = "1" tempfile = "3"不同版本的lopdf接口可能略有差异,添加依赖后执行cargo doc --open查看本地文档,确认当前版本的 API 名称。下面的代码用于说明思路,实际项目要结合自己的依赖版本调整。
3.3 确认是否有外部命令可用
合并功能只需要 Rust 库,但压缩功能依赖 Ghostscript。检查方式:
gs --version如果没有安装,可以根据系统选择安装方式:
# Ubuntu / Debian sudo apt install ghostscript # macOS brew install ghostscript # Windows 可以使用 winget 或下载官方安装包 winget install Ghostscriptqpdf也可以作为替代方案,但本文以 Ghostscript 为例。无论用哪个,程序启动时都要检测命令是否存在,避免运行时才报“找不到命令”。
3.4 学习环境和生产环境的差异
- 学习环境:直接
cargo run -- merge a.pdf b.pdf就能验证逻辑。 - 生产环境:需要
cargo build --release编译出优化后的二进制,再安装到/usr/local/bin或通过 CI 发布。 - 生产环境还需要考虑:退出码、日志、输出文件是否会被覆盖、磁盘空间是否足够、临时目录是否有写权限。
- 跨平台使用时要注意 Windows 下 Ghostscript 的路径和 PATH 配置,不能假设
gs一定在 PATH 中。
4. 实现合并功能:最小闭环可以跑通
4.1 设计命令参数
命令行工具最好提供子命令,这样后续扩展加密、加水印等功能时不需要破坏现有命令。presse merge负责合并,presse compress负责压缩。
// src/cli.rs use clap::{Parser, Subcommand}; use std::path::PathBuf; #[derive(Parser)] #[command(name = "presse", about = "Compress and merge PDF files")] pub struct Cli { #[command(subcommand)] pub command: Command, } #[derive(Subcommand)] pub enum Command { /// 按顺序合并多个 PDF 文件 Merge { /// 输入文件,按传入顺序合并 #[arg(required = true)] inputs: Vec<PathBuf>, /// 输出文件路径 #[arg(short, long, default_value = "merged.pdf")] output: PathBuf, }, /// 压缩单个 PDF 文件 Compress { /// 输入文件 input: PathBuf, /// 输出文件路径 #[arg(short, long, default_value = "compressed.pdf")] output: PathBuf, /// 质量档位:screen / ebook / printer #[arg(short, long, default_value = "ebook")] quality: String, }, }核心设计点是:
inputs用Vec<PathBuf>而不是Vec<String>,因为文件路径不一定是合法的 UTF-8 字符串,尤其是从脚本或 Windows 系统传入路径时要特别注意。- 输出文件提供默认值,但建议用户显式指定,避免误覆盖当前目录下的同名文件。
- 质量参数限制为
screen、ebook、printer三个档位,在代码里做白名单校验。
4.2 用 lopdf 实现合并逻辑
合并逻辑的核心是:加载每个输入文件,调用Document::merge把对象并入目标文档,最后统一保存。
// src/merge.rs use anyhow::{Context, Result}; use lopdf::Document; use std::path::Path; pub fn merge_pdfs(inputs: &[PathBuf], output: &Path) -> Result<()> { if inputs.len() < 2 { anyhow::bail!("merge requires at least two input files"); } let mut merged = Document::default(); for path in inputs { let doc = Document::load(path) .with_context(|| format!("failed to load {}", path.display()))?; merged = merged.merge(doc) .with_context(|| format!("failed to merge {}", path.display()))?; } merged.save(output) .with_context(|| format!("failed to save {}", output.display()))?; println!("merged {} files into {}", inputs.len(), output.display()); Ok(()) }这里有几个容易踩的坑:
- 不要直接用源文档调用
save,合并结果必须写到新文档对象里。 merged.merge(doc)的返回值才是合并后的文档,不能忽略返回值。- 如果输入文件里有加密 PDF,
Document::load可能会报错。合并前可以先检测加密标记,明确提示用户。
main.rs里把参数解析和业务逻辑串起来:
// src/main.rs mod cli; mod compress; mod merge; use anyhow::Result; use clap::Parser; fn main() -> Result<()> { let cli = cli::Cli::parse(); match cli.command { cli::Command::Merge { inputs, output } => { merge::merge_pdfs(&inputs, &output)?; } cli::Command::Compress { input, output, quality } => { compress::compress_pdf(&input, &output, &quality)?; } } Ok(()) }注意main返回Result时,程序会把错误信息打印到标准错误输出,同时以非零退出码结束。这是 CLI 工具的基本要求,脚本可以据此判断成功或失败。
4.3 异常分支要单独处理
合并操作有几个边界情况:
- 只传一个文件时,没有合并的意义,直接报错。
- 输入文件不存在时,
Document::load会失败,需要把文件名拼进错误信息,方便用户定位。 - 输出文件路径与某个输入文件路径相同时,先读取后保存并不会立即损坏源文件,但逻辑上容易混乱,建议检查并拒绝。
- 输入文件不是 PDF 时,不要用扩展名判断,而是让加载器去解析文件头。PDF 文件以
%PDF开头,可以自己读取前几个字节做快速校验。
fn is_pdf(path: &Path) -> bool { use std::io::Read; let mut file = match std::fs::File::open(path) { Ok(f) => f, Err(_) => return false, }; let mut buf = [0u8; 4]; if file.read_exact(&mut buf).is_err() { return false; } &buf == b"%PDF" }这种校验成本很低,能避免把 Word 文件或文本文件错误地当成 PDF 加载。
4.4 运行验证
用两个简单 PDF 文件测试:
cargo run -- merge a.pdf b.pdf -o out.pdf预期的正常输出类似:
merged 2 files into out.pdf验证方式有三个层次:
- 文件是否存在:
ls -l out.pdf - 页数是否正确:使用
pdfinfo out.pdf | grep Pages查看页数。 - 内容是否正常:打开 PDF 阅读器翻页,确认第二份文件的内容跟在第一份后面。
如果页数正确但部分页面空白,说明对象引用关系没有完全处理好,这是合并逻辑最常见的隐性错误。
5. 实现压缩功能:结构瘦身和内容重编码
5.1 两种压缩策略的取舍
| 策略 | 实现难度 | 压缩效果 | 副作用 |
|---|---|---|---|
| 只用 lopdf 做结构瘦身 | 简单 | 通常 5% 到 20% | 基本无副作用 |
| 调用 Ghostscript 重编码 | 简单 | 通常 50% 到 90% | 图片清晰度下降,视档位而定 |
| 手动重编码图片再重写 PDF | 复杂 | 最好,但工作量最大 | 需要处理编码器细节 |
对大多数场景,先做结构瘦身,再用 Ghostscript 重编码,两步合起来最划算。如果输入 PDF 的图片已经很低清,只做结构瘦身即可,避免二次压缩造成画质损失。
5.2 先用 lopdf 做结构层面的瘦身
结构瘦身的目标是删除冗余对象、压缩对象流、重建交叉引用表。lopdf提供了compress方法:
// src/compress.rs use anyhow::{Context, Result}; use lopdf::Document; use std::path::Path; pub fn compress_structurally(input: &Path, output: &Path) -> Result<()> { let mut doc = Document::load(input) .with_context(|| format!("failed to load {}", input.display()))?; doc.compress(); doc.save(output) .with_context(|| format!("failed to save {}", output.display()))?; Ok(()) }compress内部会把对象压缩进对象流,并重建交叉引用表。这一步不改变页面的视觉内容,只改变文件内部存储结构。压缩后的文件更小,也更接近 PDF 1.5 的优化格式。
如果实际使用的lopdf版本里方法名不同,以cargo doc显示的 API 为准。
5.3 用 Ghostscript 做内容重编码
内容压缩的核心命令如下:
gs -sDEVICE=pdfwrite \ -dCompatibilityLevel=1.5 \ -dPDFSETTINGS=/ebook \ -dNOPAUSE -dQUIET -dBATCH \ -sOutputFile=out.pdf in.pdf参数含义:
| 参数 | 作用 |
|---|---|
-sDEVICE=pdfwrite | 输出设备设为 PDF 写入器 |
-dCompatibilityLevel=1.5 | 输出 PDF 1.5 格式,允许对象流 |
-dPDFSETTINGS=/ebook | 压缩档位,/screen最激进,/ebook均衡,/printer最保真 |
-dNOPAUSE | 不需要交互确认 |
-dBATCH | 处理完成后自动退出 |
-sOutputFile | 指定输出路径 |
-dQUIET | 减少冗余日志 |
在 Rust 里用子进程调用,代码更加可控:
// src/compress.rs use anyhow::{Context, Result}; use std::path::Path; use std::process::Command; pub fn compress_with_gs(input: &Path, output: &Path, quality: &str) -> Result<()> { let settings = match quality { "screen" => "/screen", "ebook" => "/ebook", "printer" => "/printer", _ => anyhow::bail!("unsupported quality: {quality}, use screen/ebook/printer"), }; let status = Command::new("gs") .args(["-sDEVICE=pdfwrite"]) .arg(format!("-dCompatibilityLevel=1.5")) .arg(format!("-dPDFSETTINGS={settings}")) .args(["-dNOPAUSE", "-dQUIET", "-dBATCH"]) .arg(format!("-sOutputFile={}", output.display())) .arg(input) .status() .context("failed to execute gs, is Ghostscript installed?")?; if !status.success() { anyhow::bail!("ghostscript exited with status: {status:?}"); } Ok(()) }注意:Command::new("gs").status()会继承当前终端的标准输出和标准错误,所以 Ghostscript 自身的警告能直接显示出来,方便排查。
5.4 组合压缩入口并验证结果
完整的compress_pdf函数按“先结构瘦身,再内容重编码”的顺序执行,并加上临时文件保护:
// src/compress.rs use std::fs; use tempfile::NamedTempFile; pub fn compress_pdf(input: &Path, output: &Path, quality: &str) -> Result<()> { // 先做结构瘦身 let tmp_structural = NamedTempFile::new() .context("failed to create temp file")?; compress_structurally(input, tmp_structural.path())?; // 再做内容重编码 let tmp_final = NamedTempFile::new() .context("failed to create temp file")?; compress_with_gs(tmp_structural.path(), tmp_final.path(), quality)?; // 原子替换输出文件 fs::rename(tmp_final.path(), output) .with_context(|| format!("failed to move result to {}", output.display()))?; println!("compressed {} -> {}", input.display(), output.display()); Ok(()) }使用临时文件是为了避免压缩中途失败时,输出文件残留半个文件。最终用fs::rename完成原子替换。
运行:
cargo run -- compress big.pdf -o small.pdf --quality ebook验证方式:
ls -lh big.pdf small.pdf pdfinfo small.pdf | grep Pages如果文件变小但页数一致,说明压缩成功。接着随机抽几页检查图片清晰度和文字是否正常,尤其注意扫描件的 OCR 文字区域有没有被过度压缩。
6. 常见问题与排查链路
6.1 合并后 PDF 打不开或页数不对
| 现象 | 可能原因 | 检查方式 | 处理建议 |
|---|---|---|---|
| PDF 阅读器报文件损坏 | 交叉引用表重建失败 | qpdf --check out.pdf | 检查 lopdf 版本,换用Document::save的其他写入选项 |
| 页数比预期少 | 某些页面被覆盖或引用丢失 | 打印每个输入文件的页数 | 合并前记录每个文件页数,合并后校验总数 |
| 页面空白但页数正确 | 页面资源对象没有复制完整 | 查看错误日志,单文件逐测 | 对异常文件单独加载和保存,确认能否独立打开 |
排查顺序建议:先确认输入文件本身能正常打开,再检查合并结果,最后才怀疑库的 bug。很多“库有问题”的结论,最后都发现是输入 PDF 本身已经损坏。
6.2 压缩后文件反而变大
Ghostscript 重编码不是万能药。如果输入 PDF 里的图片本来就是高压缩率 JPEG,或者页面中存在大量矢量图形,重编码器可能生成更大的文件。
处理建议:
- 先用
ls -lh对比压缩前后大小,观察趋势。 - 用
/screen档位测试,如果仍然变大,说明内容本身没有多少压缩空间。 - 对扫描件,优先考虑降低图片 DPI,而不是反复跑
gs。 - 不要对同一个文件反复压缩,每次重编码都会增加质量损失,且体积不一定下降。
6.3 中文文件名或中文路径异常
有的版本在 Windows 环境下,命令行传中文路径时会出现乱码。这可能是因为参数被 shell 按错误编码解析,也可能是因为代码使用了字符串拼接而不是PathBuf。
正确做法:
- CLI 参数类型用
PathBuf,不要用String存储路径。 - 输出路径拼接时使用
Path::join,不要手动拼/或\。 - 测试时同时覆盖英文路径和中文路径。
6.4 找不到 gs 命令
如果compress命令运行后提示failed to execute gs,先手动执行:
gs --version检查顺序:
- Ghostscript 是否安装。
- 安装后终端是否重启,PATH 是否刷新。
- Windows 下是否使用了安装包提供的完整路径。
- 程序里是否应该允许通过环境变量
PRESSE_GS_PATH指定 gs 路径,方便特殊环境配置。
生产环境建议在程序启动时做一次依赖检测,把所有外部命令的可用性集中报告:
presse doctor这个命令会检查gs、qpdf是否存在于 PATH 中,并报告版本号。
6.5 Rust 依赖下载慢或编译慢
如果配置了国内源仍然慢,检查:
- 是否仍然使用旧版
crates-io协议,建议切换到sparse+协议。 - 是否在代理环境下,
net.git-fetch-with-cli配置是否生效。 - 编译时用
cargo build --release,开发时用cargo build即可,不要在每次调试时都清空target目录。
7. 工程化建议与扩展方向
7.1 CLI 工具的发布前检查清单
一个可以交给其他人使用的 CLI 工具,至少需要满足以下条件:
- 支持
--help和--version,clap默认提供。 - 成功时返回退出码 0,失败时返回非 0 退出码。
- 错误信息输出到标准错误(stderr),不要混进标准输出(stdout),否则脚本里解析 stdout 会困难。
- 输出文件写入采用临时文件加
rename的方式,避免中断残留。 - 默认不覆盖已存在的文件,除非用户显式传入
--force。 - 对输入文件做基本校验,包括是否存在、是否为 PDF、是否加密。
- 提供测试样例,至少包含两份带有图片和中文文字的 PDF 文件。
- 在 Windows、macOS、Linux 三个平台各跑一次合并和压缩流程。
- 发布时使用 release profile,并考虑用 GitHub Actions 构建平台二进制。
7.2 生产环境额外考虑
- 日志:可以为
presse增加-v或--verbose参数,输出每个文件的处理耗时和页数。 - 并发:如果需要批量处理大量 PDF,不要一次性并行启动多个 Ghostscript 进程,内存和 CPU 可能被占满。建议用固定线程池控制并发数。
- 磁盘空间:压缩过程会有临时文件,批量处理前检查磁盘剩余空间。
- 权限:输出目录要有写权限,临时目录不能放在只读挂载点。
- 回滚:输出文件名和输入文件名严格区分,不要提供“就地覆盖”的默认行为,除非用户明确要求。
7.3 扩展方向
在已经实现合并和压缩的基础上,可以继续增加:
- PDF 加密保护:用
lopdf或调用qpdf设置打开密码和权限密码。 - 水印和页眉页码:合并后往每页内容流里写入文本对象。
- YAML 配置文件:把“合并哪几个文件、输出到哪里、用什么压缩质量”写进配置,一条命令执行整个批次。
- 目录提取:输出每个输入文件的页码范围,方便验收。
- 纯 Rust 压缩引擎:长期来看,可以把依赖 Ghostscript 的压缩逻辑逐步替换成纯 Rust 图片重编码实现,减少外部依赖,但这需要大量投入。
7.4 对新手最有价值的练习路径
如果想把这篇文章的代码真正跑通并理解透彻,建议按这个顺序练习:
- 先只实现
merge,用两个小 PDF 验证页数和内容。 - 再给
merge加文件类型校验和加密检测。 - 然后接 Ghostscript,实现
compress,对比不同quality参数的体积差异。 - 最后加上临时文件保护、错误退出码和
doctor命令。
每完成一步就运行一次验证,不要等所有功能写完再调试。掌握这个过程后,你会发现“写一个能用的 PDF 命令行工具”和“写一个能稳定交付的 PDF 命令行工具”之间的差距,主要不在 PDF 原理,而在边界情况、错误处理和跨平台兼容性。