跳转到内容
getnextpdf.com

PDF 文件大小的经济学

Spec: ISO 32000-2, §7.5.7

两份 PDF 可以在屏幕上逐像素地相同,在磁盘上却相差十倍。那个差别几乎从来不是你看见的内容;而是文件在底下是如何被组装的。本页是一趟大小经济学之旅:一份 PDF 的字节究竟去了哪里,以及作者据以花费一份字节预算的那四道杠杆。

它是流与过滤器为何文件很大姊妹篇,后者讲的是一个过滤器如何解码。这一篇则始终聚焦于预算。

文件大小很少是一个虚荣指标。它是每一次下载上的带宽、每一次封存上的存储,以及每一次预览上的延迟。一份本该是 400 KB 的 12 MB 发票,当你每月生成一百万份时,便不是一个装饰性的问题——它是一笔三十倍的账单。

令人沮丧的部分是,臃肿通常是隐形的。文档渲染正确、打开正常、打印正常。没有任何东西告诉你,同一个产物本可以只是其大小的一个零头,因为被浪费的字节是结构性的,而非视觉性的。要找到它们,你必须去看预算,而非去看页面。

把一份 PDF 想成一份你花在四个项目上的预算。

  • 逐对象开销。 每一个间接对象都携带一个 N G obj / endobj 信封,并被一个交叉引用条目所追踪。一份页面繁多的文档有成千上万个微小对象,而那些信封会累加起来。对象流为一整组对象抛弃那个信封;每一个被打包的对象仍保留它自己的交叉引用条目,但作为一个紧凑的 type-2 条目。
  • 图像字节。 对任何带有照片或扫描件的文档而言,图像占主导,而最大的单一杠杆是过滤器选择——一个无损编解码器对比一个有损图像编解码器,是兆字节与千字节之间的差别。
  • 字体字节。 一个完整的内嵌字体是数百千字节你从不使用的字形。子集化只保留文档实际绘出的那些字形。
  • 那个索引。 那张让读取器找到每一个对象的交叉引用表,自身可以是一个压缩的流,而非纯文本。

把这四者都弄对,文件就小。漏掉一个,它便压倒你所做对的其余一切。

NextPDF 的写入器是一个单趟流式序列化器:它在每个对象的字节被产生出来时就追加它们,并为每一个记录一个经典的使用中交叉引用条目。那个默认行为快速、可预测,并产生一份字节稳定的文件——但它不是尽可能最小的版面,而 NextPDF 对此是诚实的。

一份 PDF 是一张间接对象的图。它们之中多数是小型字典:页面节点、注解字典、结构树元素、大纲条目。每一个都付一笔固定的税——obj / endobj 关键词、对象与世代编号,以及一个定位它的交叉引用条目。在一份带有成千上万个小对象的文档上,那笔税是文件中有意义的一片。

一个对象流把许多那样的小型非流对象收集进一个流并把它们一起压缩(Spec: ISO 32000-2, §7.5.7)。obj / endobj 信封为整组而被抛弃;里头的值被背靠背地储存、没有逐对象的关键词,然后作为单一块被 deflate——这也压缩得更好,因为那个去重的压缩器现在一次看见了所有那些相似的字典。交叉引用条目并没有消失——每一个被打包的对象仍需要一个——但它收缩成交叉引用流中一个紧凑的二进制 type-2 条目(关于这一点,杠杆 2 详述)。

在 NextPDF 中,这是由 ObjectStreamPacker 交付的,一个自包含的后处理器,它取一份已完成的交叉引用流 PDF,并把符合条件的对象重写进单一个 /Type /ObjStm。它所遵循的规则径直来自标准:一个符合条件的对象是一个世代为零的、非流对象,在排除那些必须保持可被直接寻址的特殊对象之后。§7.5.7 禁止把一个流对象储存在一个对象流内部,因此流对象——内容、字体、图像——保留它们自己的条目;而 ObjectStreamPacker 额外谢绝文档自己的交叉引用流对象(它会被重写)与 /Encrypt 字典,两者都必须保持可被直接寻址。其他每一个世代为零且非流的对象都会被打包。

Object stream packing (ObjStm) — edition availability
EditionAvailability
CoreFull support in the open-source core via ObjectStreamPacker. It is opt-in: the single-pass writer’s default emits classic in-use entries, so output stays byte-identical unless you enable packing. The packer is deterministic, with its own reproducible golden baseline.
ProNot in this edition
EnterpriseNot in this edition

选择性启用是一个刻意的立场,而非一项藏起来的限制。默认输出是字节稳定的、并与现有的 golden 基线相符;打开打包就是选择进入一个不同的、更小的、同样确定性的版面。你选择那笔交易,而引擎绝不会背着你做出它。

一旦对象住进一个对象流内部,那个指向它们的索引便改变了形状。读取器透过一个压缩的交叉引用条目找到一个被打包的对象——一个 type-2 条目,它命名那个对象流以及其中的索引(Spec: ISO 32000-2, §7.5.8.3)。因为整个交叉引用自身就是一个 /Type /XRef 流,那个针对成千上万个对象的索引便被二进制打包并 deflate,而非被写成纯文本的行。地图随领土一同收缩。

ObjectStreamPacker 重建的恰是这个:它发出那个单一的对象流,然后是一个被重写的交叉引用流,为每一个被保留的对象携带一个紧凑的 type-1 条目、为每一个被打包的对象携带一个 type-2 条目,并保留每一个对象编号,好让既有的引用保持有效。

对任何带有真实图像的文档而言,这道杠杆令其他几道相形见绌。字节是同一张图;编解码器才是预算。过滤器名称住在标准过滤器集中(Spec: ISO 32000-2, §7.4),而它们之间的选择是一个大小决定:

  • FlateDecode 是无损的。对线条画、屏幕截图,以及任何带有平涂颜色的东西而言完美无缺——而对一张照片则是灾难性的,在那里无损意味着原件的每一个字节。
  • DCTDecode 是 JPEG:有损,而对照片来说是远胜一筹的正确选择,往往是十倍的缩减,换来一个无人注意的质量下降。
  • JPXDecode 是 JPEG 2000:带有一条不同的质量/大小曲线的小波压缩,但支持参差不齐,并被某些封存设定档所禁止。

这恰恰就是一个过滤器如何解码所要紧之处,而那是流与过滤器文章的工作。经济学的要点更窄:一张被无损储存的照片,是一份不必要地巨大之 PDF 最常见的单一成因,而再多的对象流打包,也救不了一份其真实重量是一张未经 JPEG 化之扫描件的文件。

一个内嵌字体是一个程序。一个完整的可达数百千字节,因为它携带字体设计师曾画过的每一个字形——你在这份文档里永远不会用到的、跨各种文字系统的成千上万个字符。子集化只内嵌文档实际绘出的那些字形,把那个程序变成它自身的一个小零头。一封一页的信不需要一个 CJK 兼容字体的全部;它需要它所排的那几十个字形。字形如何被选取并重新索引的机制自成一个主题——参见字体:困难的部分

  1. Image bytesFor media-heavy files, the largest line item. The filter choice — lossless Flate vs a lossy image codec like DCT (or JPX in a lossy mode) — is the dominant size lever.
  2. Font bytesA full embedded font is mostly glyphs you never draw. Subsetting keeps only the used glyphs, often shrinking the program by an order of magnitude.
  3. Per-object overheadThousands of small dictionaries each pay an obj/endobj envelope plus a cross-reference entry. Object streams (ObjStm) drop the envelope for the whole group; each object keeps a compact type-2 cross-reference entry.
  4. The indexA compressed cross-reference stream with type-2 entries binary-packs and deflates the map of every object, replacing plaintext index rows.
Where the bytes go: the four levers an author spends a PDF's size budget on, in the order they typically dominate a real file.

这里没有一个捏造的 API 可供展示,因为最重要的那些大小决定是在字节抵达写入器之前就做出的——而 NextPDF 暴露的那一道结构性杠杆是单一个选择性启用项。在概念上,这份预算读起来像这样:

  • 把照片作为 JPEG 馈入,好让它们落在 DCTDecode 之下,而非被无损地重新编码。最大的胜利是一个关于源数据的选择,而非一个写入器标志。
  • 让写入器子集化内嵌字体,好让只有绘出的字形被发出。
  • 对一份带有许多小对象的文档,选择进入对象流打包,那会把已完成的文件路由穿过 ObjectStreamPacker,以把那些小型非流对象分组并重写交叉引用流。

那条默认路径——经典的使用中条目、无 ObjStm——是正确的基线:确定性、字节稳定,且易于验证。打包是当对象数、而非图像重量才是膨胀文件之物时,那个经过考量的升级。

陷阱在于去够一个“压缩这份 PDF”的按钮,并指望它修好一切。压缩不是一道杠杆;它是四道,而它们彼此并不替代。对象流打包无法缩小一张照片——那是图像过滤器的工作。一个完美的 JPEG 无法抵销一个你忘了子集化的字体。而如果真正的臃肿是一张因为没人替它选 DCTDecode 而被无损储存的 10 MB 扫描件,那么这一切都帮不上忙。

第二个误解是,更小总是严格地更好。并非如此。对象流与线性化不兼容——那个钉住绝对对象放置位置、好让第一页能尽早流式进来的 fast-web-view 版面。而某些封存设定档限制了甚至有哪些图像过滤器是被允许的。大小是一条轴。它与流式处理、封存标准符合性以及可复现性彼此权衡,而曲线上的正确位置取决于这份文档的用途是什么。

那四道杠杆是文件大小的结构性经济学。它们并不是一项通用的“把它变小”保证,而 NextPDF 也不假装自己是任意传入 PDF 的一个重新优化器。

ObjectStreamPacker 是一项绝不冒正确性之险的尽力而为优化。它会谢绝——原样返回输入——当文件不是一份交叉引用流 PDF 时、当它被加密时、当它携带一个数字签名时(重新铺排对象会移动一个签名所保护的字节范围)、当它已经包含对象流时,或当没有符合条件的对象可供打包时。它为整份文档恰好发出一个对象流;它不会拆分成一个 /Extends 集合,那对 NextPDF 所产生的文档大小而言是被划出范畴的、且是符合标准的。

图像与字体那两道杠杆,在很大程度上是关于输入的决定。NextPDF 的写入器不会隐式地把一张无损位图转码成一个有损图像流——把一张照片重新编码为 DCTDecodeJPXDecode 是一个明确的上游图像编码决定,而非序列化器背着你做的事——它也不会从一个调用方请求完整内嵌的字体中回收字形。最大的那些大小胜利是在字节序列化器的上游做出的;引擎的工作是不要浪费你带给它的那份预算。

  • 流与过滤器 — 那篇一个过滤器如何解码的姊妹篇;本页刻意与它互补,而非重复它。
  • PDF 究竟是什么 — 那个间接对象模型,对象流杠杆所减少的逐对象开销正源于它。
  • PDF 文件剖析 — 那个会变成一个压缩流的交叉引用结构。
  • 字体:困难的部分 — 子集化如何选取并重新索引一份文档所绘出的字形。
  • 对象流(ObjStm) — 一个持有许多小型非流间接对象、把它们一起压缩的单一流,因此 obj / endobj 信封为整组而被抛弃。每一个被打包的对象仍保留它自己的交叉引用条目,作为一个紧凑的 type-2 条目。在 NextPDF 中为选择性启用。
  • 间接对象 — 一份 PDF 图中的一个带编号对象,包裹在一个 obj / endobj 信封中,并被交叉引用索引所追踪。那个信封就是逐对象的开销。
  • 交叉引用流 — 那个索引每一个对象的 /Type /XRef 流,被二进制打包并 deflate,而非被写成纯文本的行。
  • 压缩的(type-2)条目 — 一个指向某个住在对象流内部之对象的交叉引用条目,命名那个流以及其中的索引。
  • 字体子集化 — 只内嵌一份文档实际绘出的那些字形,而非完整的字体,以收缩那个内嵌的字体程序。
  • 无损对比有损过滤器 — 一个无损编解码器(FlateDecode)复现每一个字节;一个有损图像编解码器(DCTDecode,或处于有损模式的 JPXDecode)丢弃不可察觉的细节以换来一个小得多的结果。(JPEG 2000 / JPX 也可被配置为无损。)这个选择是媒体繁重文件上的主导杠杆。