如果需要以 600 DPI 将 PDF 转换为 JPG 用于打印、归档或高分辨率提取,简短的答案是:可以实现,但必须对输出进行钳位。大型 PDF 页面以 600 DPI 转换可能会生成每页数 GB 的 JPG,这会在没有内存预算的情况下使任何服务器崩溃。我在 Convertify 的生产环境中遇到了这个问题,因此必须构建一个既能满足用户请求又不会导致进程爆炸的修复方案。

本文将介绍我在 Rust + libvips 中编写的钳位函数、背后的数学原理(剧透:sqrt)、生产环境中的真实数据,以及权衡之处。如果你只想使用工具,这里是 600 DPI PDF 转 JPG 转换器。免费、无需注册,下文所述的钳位功能会对每个请求生效。

为什么 600 DPI 很重要

DPI 在“PDF 转图像”中并不是 PDF 的物理属性,而是一个缩放因子。当你告诉 libvips 以 600 DPI 加载 PDF 页面时,你是在说:按原始文档尺寸的每英寸 600 像素来栅格化此页面。因此,输出尺寸完全取决于 PDF 认为自己有多大。

在大多数情况下,300 DPI 就足够了。打印、大多数杂志、高质量照片输出,300 DPI 是标准。600 DPI 出现在以下场景:

  • 归档扫描。 你希望保留原始文档的每一个细节。
  • 大幅面高质量打印:海报、技术图纸、工程图纸。
  • 密集排版的 OCR。 小字体和不常见的字形受益于更高的栅格分辨率。
  • 大幅面文档。 地图绘制、蓝图、宽幅原理图。这是我首次遇到问题的地方。这些 PDF 通常具有巨大的原生尺寸(24 英寸 × 36 英寸或更大),在 600 DPI 下会爆炸式增长。

如果有人在搜索引擎中输入 pdf to jpg 600 dpi,通常就有上述需求之一。他们不希望工具在不告知用户的情况下静默降级到 300 DPI。

朴素方法(以及它为什么失败)

在 libvips 中以给定 DPI 加载 PDF 页面只需一行代码:

let img = VipsImage::new_from_file(&format!("{file_path}[dpi=600,page={page}]"))?;

进入全屏模式 退出全屏模式

就是这样。libvips-poppler 处理渲染。在典型的 A4 页面上,你会得到大约 3500 万像素的图像,JPG 大小约为 8-15 MB,没问题。

现在粘贴一个大幅面 PDF,例如 24 英寸 × 36 英寸的技术图纸。相同的代码会生成 3 亿多像素的图像。作为内存中的原始像素数据,这超过 1 GB。以 90 质量编码为 JPG,每页仍可能达到 200-400 MB。多页 PDF 在 600 DPI 下:你会看到数 GB 的输出,通常在用户意识到之前就已经发生了。

我在生产中看到的真实症状:

  • 服务器 OOM 在转换中途终止。
  • 下载开始但从未完成,因为文件太大,浏览器无法处理。
  • 用户抱怨“小型”PDF 产生了巨大的输出。

懒惰的修复方法是全局将 DPI 上限设为 300。但这会惩罚诚实的用例:真正想要在正常尺寸 PDF 上使用 600 DPI 的用户,会因为一个异常情况而被迫降级。所以我需要一种自适应的方法。

百万像素预算

真正的限制不是 DPI,而是输出像素数。无论是由什么 DPI 生成,3500 万像素的 JPG 和 3.5 亿像素的 JPG 是完全不同的两种事物。

因此,我没有直接限制 DPI,而是设置了一个百万像素预算:“输出图像不得超过 N 百万像素。”然后在每次转换前,以低 DPI 探测页面尺寸,计算请求的 DPI 产生多少像素,如果超出预算,就将 DPI 降低到刚好能容纳的程度。

在 Convertify 上,我的预算目前是 100 MP。这对于任何普通文档的 600 DPI 来说都绰绰有余(标准信纸大小的页面在 600 DPI 下只有约 3400 万像素)。它只会在病态情况下触发:巨大的 PDF,或输入超出服务器实际处理能力的边缘几何。

钳位函数

以下是实际在生产环境中运行的函数:

pub fn clamp_dpi_to_megapixels(
    file_path: &str,
    page: i32,
    requested_dpi: i32,
    max_megapixels: f64,
) -> i32 {
    let probe = VipsImage::new_from_file(&format!("{file_path}[dpi=72,page={page}]"));

    let (w, h) = match probe {
        Ok(img) => (img.get_width() as f64, img.get_height() as f64),
        Err(_) => return requested_dpi,
    };

    let scale = requested_dpi as f64 / 72.0;
    let mp = (w * scale) * (h * scale) / 1_000_000.0;

    if mp <= max_megapixels {
        requested_dpi
    } else {
        let factor = (max_megapixels / mp).sqrt();
        ((requested_dpi as f64 * factor).floor() as i32).max(72)
    }
}

进入全屏模式 退出全屏模式

让我逐一解释。

以 72 DPI 探测。 libvips-poppler 将 72 DPI 视为 PDF 的原生“点”网格,因此以 72 DPI 加载成本低廉,并能给出以点为单位的真实页面尺寸。8.5 英寸 × 11 英寸的页面会返回 612 × 792 像素。

如果探测失败(页码超出范围、PDF 格式错误或其他任何情况),我会原样返回请求的 DPI。后续转换会以适当的错误失败。这里不是吞掉错误的地方。

计算请求的 DPI 会产生多少像素。 将基础尺寸乘以 requested_dpi / 72,然后转换为百万像素。同一个 8.5 英寸 × 11 英寸的页面在 600 DPI 下:(612 × 8.33) × (792 × 8.33) / 1_000_000 ≈ 33.6 MP。远低于预算。

在预算内,原样返回。 如果朴素的 DPI 产生的像素 ≤ 最大 MP,我们就完成了。这是快乐路径,可能是 95% 以上的用户请求。

超出预算,按比例回退。 这里数学很重要。要将 350 MP 降到 100 MP,我们需要按 100/350 的因子缩小。但这个比率是像素。DPI 是像素的平方根。DPI 减半,像素变为四分之一。因此 DPI 因子是 sqrt(100/350) ≈ 0.53。应用于 600 DPI:600 × 0.53 ≈ 318 DPI。仍远高于 300 的安全回退值,但低到足以容纳。

.floor().max(72) 是安全护栏。Floor 防止四舍五入超出预算;72 是有效的最小值,因为低于 PDF 原生网格进行栅格化会产生垃圾。

为什么用 sqrt(简短的数学题外话)

这里没什么特别的,但值得清楚地说明,因为这是整个洞见所在。

像素数随 DPI 的平方缩放,而不是 DPI 本身。DPI 加倍,宽度和高度都加倍,因此像素数变为四倍。DPI 变为三倍,像素数增长 9 倍。关系是:

pixels = base_pixels × (dpi / 72)²

进入全屏模式 退出全屏模式

当你知道目标像素数时,求解 DPI:

dpi = 72 × sqrt(target_pixels / base_pixels)

进入全屏模式 退出全屏模式

这正是钳位函数所做的,只是通过 requested_dpi 进行了因式分解。如果你跳过 sqrt 并直接用像素比率乘以 DPI,你会过度修正。最终得到的 DPI 会远低于预算实际允许的值,用户会得到不必要低分辨率的输出。

我曾在网上看到几处写着“按比例缩放来钳位 DPI”。它们的输出结果远低于应有的水平。原因就在于此。

生产环境中的真实数据

以下是 100 MP 预算下钳位函数的实际表现:

输入文档 72 DPI 基础尺寸 请求的 DPI 请求 DPI 下的 MP 钳位后的 DPI 输出 MP
信纸 (8.5" × 11") 612 × 792 (0.48 MP) 600 33.6 MP 600 33.6
A3 (11.7" × 16.5") 842 × 1191 (1.0 MP) 600 69.6 MP 600 69.6
大型蓝图 (24" × 36") 1728 × 2592 (4.5 MP) 600 311 MP 340 100
宽幅原理图 (36" × 60") 2592 × 4320 (11.2 MP) 600 778 MP 215 100

规律是:对于普通文档,600 DPI 原样通过。对于朴素方法会产生数亿像素的边缘情况,DPI 会被缩放回预算允许的范围,用户仍然能得到比全局 300 DPI 回退更接近其意图的输出。

何时使用 TIFF

值得注意的是:如果你在以 600 DPI 进行归档工作,JPG 通常不是好的选择。JPG 是有损的,在高 DPI 下,你通常是为了保留精细细节而需要无损:文本边缘、细线、小注释。

对于这些情况,TIFF 是正确的格式。LZW 压缩的 TIFF 在合理的文件大小下是无损的,地球上每一个归档系统都能读取它。libvips 原生支持它。

我在 PDF 转 TIFF 转换器后面也使用了相同的钳位,只是使用了不同的编码器。如果你正在构建类似的东西,不要在 600 DPI 归档工作中强制使用 JPG。也支持 TIFF 吧。你的用户会感谢你。

如果重做我会改变什么

两件事。

每页反馈。 目前钳位是静默运行的。一份 200 页的文档,其中一张地图被从 600 DPI 缩放到 340 DPI:用户看不到。静默对大多数情况是友好的,但对于归档工作流,人们希望确切知道哪些页面触发了预算。我会在响应中显示出来。

自适应预算。 100 MP 是一个硬编码的常量。在更大的服务器上可能是 250。对于纯移动端会话,40 更合理。从请求上下文中推导预算(或允许用户选择更高的层级)将是一个影响重大的小配置更改。

这两件事都不紧急,但都在待办列表中。

试一试

如果你现在需要 600 DPI PDF 转 JPG 转换:Convertify 可以做到。免费、无需注册、批量最多 10 个文件,上文所述的钳位函数会对每个请求运行。上传一个巨大的文件,你会得到符合合理内存预算的最高 DPI。不会崩溃,也不会静默全局降级。

如果你正在构建自己的 PDF 转图像工具,也遇到了类似问题,上面的函数已经在生产中经过测试,代码很短,可以直接复制使用。调整 max_megapixels 以适应你的基础设施,然后继续。

如果你想了解更多 Convertify 的工程实践,我每周都会在这个账号的 Building Convertify in Public 上撰文。libvips 技巧、Rust 模式、真实的生产 bug。没有填充内容。