PDF 檔案大小的經濟學
Spec: ISO 32000-2, §7.5.7ISO 32000-2 §7.5.7
兩份 PDF 在螢幕上能逐像素地一模一樣,在磁碟上卻相差十倍。差別幾乎從不在於你所看見的內容;而在於檔案底層是如何被組裝起來的。本頁是一趟大小經濟學的巡覽:一份 PDF 的位元組究竟跑到哪裡去了,以及作者所花用之位元組預算的四道槓桿。
它是串流與篩選器的為何檔案會大姊妹篇,那一篇談的是一個篩選器如何解碼。這一篇則緊扣著預算。
為何這很重要
標題為「為何這很重要」的區段檔案大小鮮少只是個用來炫耀的指標。它是每一次下載的頻寬、每一次封存的儲存空間,以及每一次預覽的延遲。一份本該是 400 KB 卻變成 12 MB 的發票,當你一個月產生一百萬份時,就不是一個外觀上的問題了——它是一筆三十倍的帳單。
令人挫折的部分在於,臃腫通常是隱形的。文件繪製正確、開啟正常、列印正常。沒有任何東西告訴你,同一份產物本可以只有它一小部分的大小,因為那些被浪費的位元組是結構性的,而非視覺性的。要找出它們,你得去看預算,而非看頁面。
簡短版本
標題為「簡短版本」的區段把一份 PDF 想成一份你花在四個項目上的預算。
- 逐物件的額外開銷。 每一個間接物件都攜帶一個
N G obj/endobj的包封,並由一個交叉參照項目所追蹤。一份頁面繁多的文件擁有成千上萬個微小的物件,而那些包封會累積起來。物件串流為一整群物件卸下那個包封;每一個被打包的物件仍保有它自己的交叉參照項目,但以一個精簡的 type-2 項目形式存在。 - 影像位元組。 對於任何帶有照片或掃描件的文件,影像會主導一切,而單一最大的槓桿就是篩選器選擇——一個無損編解碼器相對於一個有損影像編解碼器,就是百萬位元組與千位元組之間的差別。
- 字型位元組。 一個完整內嵌的字型,是數百千位元組你從未用到的字符。子集化只保留文件實際繪出的那些字符。
- 索引。 那張讓讀取器能找到每一個物件的交叉參照表,自身可以是一個壓縮串流,而非純文字。
把這四個都弄對,檔案就小。漏掉一個,它就主導了你其餘所有做對的事情。
NextPDF 如何處理
標題為「NextPDF 如何處理」的區段NextPDF 的寫入器是一個單趟串流序列化器:它在每個物件的位元組產生出來時就附加它們,並為每一個記錄一個經典的使用中交叉參照項目。那個預設值快速、可預測,且產出一個位元組穩定的檔案——但它不是最小可能的版面,而 NextPDF 對此是誠實的。
槓桿一——物件串流(ObjStm)
標題為「槓桿一——物件串流(ObjStm)」的區段一份 PDF 是一張由間接物件構成的圖。它們之中多數是小型字典:頁面節點、註解字典、結構樹元素、大綱項目。每一個都付出一筆固定的稅——obj / endobj 關鍵字、物件編號與世代編號,以及一個定位它的交叉參照項目。在一份帶有成千上萬個小物件的文件上,那筆稅是檔案中有意義的一塊。
一個物件串流把許多那種小型非串流物件蒐集進一個串流,並把它們一起壓縮(Spec: ISO 32000-2, §7.5.7ISO 32000-2 §7.5.7)。obj / endobj 包封為整群物件卸下;裡頭的值首尾相接地儲存、沒有逐物件的關鍵字,再以單一一個區塊去壓縮——這樣也壓得更好,因為那個去重複的壓縮器此時一次就看見了所有那些相似的字典。交叉參照項目並未消失——每一個被打包的物件仍需要一個——但它縮成交叉參照串流中一個精簡的二進位 type-2 項目(這點在槓桿二有更多說明)。
在 NextPDF 中,這是由 ObjectStreamPacker 來交付的,那是一個自成一體的後處理器,它取一份已完成的交叉參照串流 PDF,並把合格的物件改寫進單一一個 /Type /ObjStm。它所遵循的規則直接來自標準:一個合格的物件是一個世代為零、非串流的物件,並在排除那些必須維持可直接定址的特殊物件之後。§7.5.7 禁止把一個串流物件儲存在一個物件串流之內,因此串流物件——內容、字型、影像——保有它們自己的項目;而 ObjectStreamPacker 額外推卻文件自己的交叉參照串流物件(它會被改寫)以及 /Encrypt 字典,這兩者都必須維持可直接定址。其餘一切世代為零且非串流的物件都會被打包。
| Edition | Availability |
|---|---|
| Core | Full 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. |
| Pro | Not in this edition |
| Enterprise | Not in this edition |
選擇性啟用是一種刻意的立場,而非藏起來的一個限制。預設輸出是位元組穩定的,並與既有的 golden 基線相符;開啟打包,便選擇進入一個不同、更小、同樣確定性的版面。你選擇這項取捨,而引擎絕不會背著你替你做。
槓桿二——索引也可以是一個串流
標題為「槓桿二——索引也可以是一個串流」的區段一旦物件住進了一個物件串流,那張指向它們的索引就改變了形狀。讀取器是透過一個壓縮的交叉參照項目找到一個被打包的物件——一個 type-2 項目,它命名物件串流以及其中的索引(Spec: ISO 32000-2, §7.5.8.3ISO 32000-2 §7.5.8.3)。因為整張交叉參照本身就是一個 /Type /XRef 串流,那張為成千上萬個物件而設的索引是被二進位打包並壓縮的,而非寫成純文字的列。地圖與它所描繪的疆域一同縮小。
ObjectStreamPacker 重建的正是這個:它發出那單一一個物件串流,接著發出一個改寫後的交叉參照串流,為每一個保留的物件攜帶一個精簡的 type-1 項目、為每一個被打包的物件攜帶一個 type-2 項目,並保留每一個物件編號,好讓既有的參照維持有效。
槓桿三——影像篩選器選擇
標題為「槓桿三——影像篩選器選擇」的區段對於任何帶有真實影像的文件,這道槓桿讓其餘的相形見絀。位元組是同一張圖;編解碼器才是預算。篩選器的名稱住在標準篩選器集合中(Spec: ISO 32000-2, §7.4ISO 32000-2 §7.4),而它們之間的選擇是一項大小決定:
- FlateDecode 是無損的。對線條圖、螢幕截圖以及任何具有平塗色彩的東西都很完美——而對一張照片則是災難性的,因為無損意味著原始檔案的每一個位元組。
- DCTDecode 是 JPEG:有損,而對照片而言是大幅勝出的正確選擇,往往以一次沒人會察覺的品質下降換來十倍的縮減。
- JPXDecode 是 JPEG 2000:小波壓縮,具有不同的品質/大小曲線,但支援程度參差不齊,且被某些封存設定檔所禁止。
這正是一個篩選器如何解碼變得重要之處,而那是串流與篩選器那篇文章的工作。經濟學的重點較為狹窄:一張以無損方式儲存的照片,是一份不必要地龐大的 PDF 最常見的單一肇因,而再多的物件串流打包,都救不了一個其真正重量是一張未經 JPEG 處理之掃描件的檔案。
槓桿四——字型子集化
標題為「槓桿四——字型子集化」的區段一個內嵌的字型是一個程式。一個完整的字型可以是數百千位元組,因為它攜帶字型設計師曾畫過的每一個字符——橫跨你在這份文件中永遠用不到之文字系統的成千上萬個字元。子集化只內嵌文件實際繪出的那些字符,把那個程式變成它自身的一小部分。一封一頁的信件不需要一個支援 CJK 之字型的全部;它需要的是它所排版的那幾十個字符。字符如何被選取與重新編索引的機制是它們自己的主題——參見字型:困難的部分。
- 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.
- 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.
- 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.
- The indexA compressed cross-reference stream with type-2 entries binary-packs and deflates the map of every object, replacing plaintext index rows.
實際範例
標題為「實際範例」的區段這裡沒有捏造出來的 API 可供展示,因為最重要的那些大小決定,是在位元組抵達寫入器之前就做下的——而 NextPDF 所公開的那一道結構性槓桿,是單一一個選擇性啟用項。在概念上,這份預算讀起來像這樣:
- 把照片以 JPEG 餵入,好讓它們落在
DCTDecode之下,而非被無損地重新編碼。最大的勝利是一個關於來源資料的選擇,而非一個寫入器旗標。 - 讓寫入器把內嵌字型子集化,好讓只有繪出的字符被送出。
- 對於一份帶有許多小物件的文件,選擇進入物件串流打包,它會把已完成的檔案導經
ObjectStreamPacker,以將小型非串流物件分組並改寫交叉參照串流。
預設路徑——經典的使用中項目、無 ObjStm——是正確的基線:確定性、位元組穩定,且易於驗證。當把檔案撐大的是物件數量、而非影像重量時,打包則是那個經過考量的升級。
常見的誤解
標題為「常見的誤解」的區段陷阱在於去按一個「壓縮這份 PDF」的按鈕,並指望它修好一切。壓縮不是一道槓桿;它是四道,而它們彼此並不替代。物件串流打包無法縮小一張照片——那是影像篩選器的工作。一個完美的 JPEG 無法抵銷一個你忘了子集化的字型。而如果真正的臃腫是一張因為沒人替它選 DCTDecode 而被無損儲存的 10 MB 掃描件,那麼這一切都幫不上忙。
第二個誤解是,更小總是嚴格地更好。並非如此。物件串流與線性化不相容——那個釘住絕對物件放置位置、好讓第一頁能盡早串流進來的 fast-web-view 版面。而某些封存設定檔限制了甚至有哪些影像篩選器是被允許的。大小是一條軸。它與串流、封存標準符合性以及可重現性彼此權衡,而曲線上的正確位置取決於這份文件的用途是什麼。
限制與邊界
標題為「限制與邊界」的區段這四道槓桿是檔案大小的結構性經濟學。它們並不是一項通用的「把它變小」保證,而 NextPDF 也不假裝自己是任意傳入 PDF 的一個重新最佳化器。
ObjectStreamPacker 是一項從不冒著正確性風險的盡力而為最佳化。它會推卻——原封不動地回傳輸入——當檔案不是一份交叉參照串流 PDF、當它被加密、當它攜帶一個數位簽章(重新排放物件會位移簽章所保護的位元組範圍)、當它已經含有物件串流,或當沒有合格的物件可供打包時。它為整份文件恰好發出一個物件串流;它不會分割成一個 /Extends 集合,對於 NextPDF 所產生的文件大小而言,那超出範圍且符合標準。
影像與字型槓桿大致上是關於輸入的決定。NextPDF 的寫入器不會隱含地把一張無損點陣圖轉碼成一個有損影像串流——把一張照片重新編碼為 DCTDecode 或 JPXDecode 是一個明確的上游影像編碼決定,而非序列化器背著你做的某件事——它也不會從一個呼叫端要求完整內嵌的字型中回收字符。最大的大小勝利是在位元組序列化器的上游做下的;引擎的工作,是不浪費你帶給它的那份預算。
相關文件
標題為「相關文件」的區段- 串流與篩選器 — 那篇一個篩選器如何解碼的姊妹篇;本頁刻意與它互補,而非重複它。
- PDF 究竟是什麼 — 那個其逐物件額外開銷被物件串流槓桿所減少的間接物件模型。
- PDF 檔案剖析 — 那個成為一個壓縮串流的交叉參照結構。
- 字型:困難的部分 — 子集化如何選取並重新編索引一份文件所繪出的字符。
詞彙表
標題為「詞彙表」的區段- 物件串流(ObjStm) — 一個保有許多小型非串流間接物件、把它們一起壓縮的單一串流,因此
obj/endobj包封為整群物件卸下。每一個被打包的物件仍保有它自己的交叉參照項目,作為一個精簡的 type-2 項目。在 NextPDF 中為選擇性啟用。 - 間接物件 — PDF 圖中一個有編號的物件,包裹在一個
obj/endobj包封中,並由交叉參照索引所追蹤。那個包封就是逐物件的額外開銷。 - 交叉參照串流 — 那個為每一個物件編索引的
/Type /XRef串流,被二進位打包並壓縮,而非寫成純文字的列。 - 壓縮(type-2)項目 — 一個指向住在某個物件串流內部之物件的交叉參照項目,命名那個串流以及其中的索引。
- 字型子集化 — 只內嵌一份文件實際繪出的那些字符,而非完整的字型,以縮小內嵌的字型程式。
- 無損對有損篩選器 — 一個無損編解碼器(FlateDecode)重現每一個位元組;一個有損影像編解碼器(DCTDecode,或在有損模式下的 JPXDecode)為了一個遠遠更小的結果而捨棄難以察覺的細節。(JPEG 2000 / JPX 也能被設定為無損。)這個選擇是一個媒體繁多檔案上的主導槓桿。