跳到內容
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 包封為整群物件卸下;裡頭的值首尾相接地儲存、沒有逐物件的關鍵字,再以單一一個區塊去壓縮——這樣也壓得更好,因為那個去重複的壓縮器此時一次就看見了所有那些相似的字典。交叉參照項目並未消失——每一個被打包的物件仍需要一個——但它縮成交叉參照串流中一個精簡的二進位 type-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 串流,那張為成千上萬個物件而設的索引是被二進位打包並壓縮的,而非寫成純文字的列。地圖與它所描繪的疆域一同縮小。

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 串流,被二進位打包並壓縮,而非寫成純文字的列。
  • 壓縮(type-2)項目 — 一個指向住在某個物件串流內部之物件的交叉參照項目,命名那個串流以及其中的索引。
  • 字型子集化 — 只內嵌一份文件實際繪出的那些字符,而非完整的字型,以縮小內嵌的字型程式。
  • 無損對有損篩選器 — 一個無損編解碼器(FlateDecode)重現每一個位元組;一個有損影像編解碼器(DCTDecode,或在有損模式下的 JPXDecode)為了一個遠遠更小的結果而捨棄難以察覺的細節。(JPEG 2000 / JPX 也能被設定為無損。)這個選擇是一個媒體繁多檔案上的主導槓桿。