ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

用Rust实现PDF合并与压缩的命令行工具

用Rust实现PDF合并与压缩的命令行工具 在实际工作流里“合并 PDF”和“压缩 PDF”很少是孤立需求提交材料要把多份扫描件拼成一个文件交付报告要控制 PDF 体积脚本批量处理后还要被 CI 或其他程序调用。Presse 是一个用 Rust 编写的命令行工具从项目标题可以看出它的核心能力就是两个维护场景压缩 PDF 和合并 PDF。相比常见的图形化 PDF 编辑器CLI 工具的差别在于可组合、可重复、可纳入自动化管道。这篇文章会从使用场景和原理讲起分析 PDF 合并与压缩背后的对象结构逻辑再给出一个用 Rust 从零还原 Presse 最小功能的完整实现最后补充运行验证、常见报错排查和生产化建议。1. 先搞清楚 Presse 解决什么问题为什么选 Rust1.1 合并和压缩是文档流水线里的高频操作PDF 在办公和研发场景中几乎无处不在但很多人的处理方式还停留在“打开在线网站 - 上传文件 - 等待处理 - 下载结果”。这种流程有三个明显问题不可复现同样的合并操作下一次还要重新点击一遍。有隐私风险合同、简历、内部报告上传到第三方服务器等于把文件交给别人保管。无法批量一次处理几十个 PDF 时手工操作的时间和错误率都会上升。Presse 这类工具把操作收敛到终端命令里最大的价值不是“省一次点击”而是让操作变成一条命令。合并一批文件可以写成脚本压缩逻辑可以嵌进发布流程输出文件名和目录可以由程序动态决定。对于经常处理文档的开发者、运维人员和内容运营来说这个能力比任何图形界面都更可靠。从 Show HN 的发布形式也能看出这是一个希望通过社区反馈逐步打磨的开源工具。对读者来说即使不直接使用 Presse它的设计思路也值得参考一个只做两件事的小工具比一个什么都能做但每个功能都不顺手的大软件更适合命令行生态。1.2 为什么 Rust 适合写这类 CLI命令行工具有很多实现语言选择Python、Go、Shell 都常被使用。Rust 在这里的优势可以从几个维度看维度RustPythonGoShell 脚本启动速度极快毫秒级有解释器加载开销快快分发方式单二进制无需运行时需要解释器和依赖单二进制依赖目标机器的 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-renderGoogle 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 sparsehttps://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: VecPathBuf, /// 输出文件路径 #[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用VecPathBuf而不是VecString因为文件路径不一定是合法的 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)的返回值才是合并后的文档不能忽略返回值。如果输入文件里有加密 PDFDocument::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 -sDEVICEpdfwrite \ -dCompatibilityLevel1.5 \ -dPDFSETTINGS/ebook \ -dNOPAUSE -dQUIET -dBATCH \ -sOutputFileout.pdf in.pdf参数含义参数作用-sDEVICEpdfwrite输出设备设为 PDF 写入器-dCompatibilityLevel1.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([-sDEVICEpdfwrite]) .arg(format!(-dCompatibilityLevel1.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和--versionclap默认提供。成功时返回退出码 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 原理而在边界情况、错误处理和跨平台兼容性。
返回列表