跳到內容

PDF 到底是什麼

Evidence: Standard-backed

PDF 並不是碰巧放在檔案裡的一組頁面描述。它更像是一個附帶印表機的小型圖狀資料庫。本頁說明每個 PDF 都具備的四個部分——header、body、交叉參照表、trailer——以及 NextPDF 如何寫入它們,讓讀取器不必猜測就能找到每個物件。

多數 PDF 的錯誤並不是算繪錯誤,而是結構錯誤:一個比應指向的物件多出一個字元的位元組偏移量、一個指向錯誤 root 的 trailer、一筆與物件實際位置不一致的交叉參照項目。這些問題不會改變頁面的外觀,直到讀取器改走檔案中的另一條路徑,甚至越過檔案結尾。

如果你把 PDF 當成不透明黑箱,這些失敗看起來就像隨機發生。如果你了解物件模型,它們就會回到本質:一個與位置不符的數字。能否讀懂這個格式,正是「這個 PDF 損壞了」與「物件 14 的偏移量已過時,因為寫入器在串流長度定案之前就量測了它」之間的差別。

按照檔案順序,一個 PDF 有四個部分:

  1. 一個 header——標示版本的一行(%PDF-2.0)。
  2. 一個 body——一串已編號的間接物件:字典、串流、陣列、數字、字串、名稱。
  3. 一個 交叉參照表(或者,在 PDF 2.0 中是交叉參照串流)——一個從物件編號到位元組偏移量的對照,讓任何物件無需掃描整個檔案就能被取得。
  4. 一個 trailer——一個小型字典,標出文件的 root 物件,並指向交叉參照區段的起始位置。

讀取器不會從頭到尾讀完整份 PDF。它會先讀最後一行,找到 startxref,跳至交叉參照區段,並以它作為進入 body 的索引。這個格式天生就是為反向讀取而設計。光是這一點,就能解釋它大部分的設計。

NextPDF 依照這個格式實際被讀取的方式建構 PDF:先寫入物件,接著記錄偏移量,最後寫入表格。

每個間接物件都由單一登錄表(src/Core/ObjectRegistry.php)分配一個編號。這個登錄表透過 allocate() 發出連續編號,並在物件的位元組寫入輸出緩衝區之後,透過 register() 記錄位元組偏移量。偏移量絕不會被預先猜測。它們是在物件 header 寫出的那一刻,從 BinaryBuffer::getOffset() 觀測得到的。這正是為什麼 NextPDF 的交叉參照項目不會偏離它所描述的物件:偏移量就是緩衝區當時實際所在的位置。

body 完成後,版本專屬的序列化策略src/Writer/PdfSerializationStrategy.php)會寫入交叉參照區段與 trailer:

  • Pdf20StreamStrategy 會發出一個壓縮的交叉參照串流/Type /XRef)——這是 PDF 2.0 的預設。
  • Pdf17TableStrategyPdf14TableStrategy 會發出一個傳統的 20 位元組交叉參照表格,外加一個獨立的 trailer 字典——這是要求採用較舊檔案結構的 PDF/A 規範所必需的。

策略由輸出規範選定,而不是推斷得來。不論選哪一種,最終位元組的形狀都相同:交叉參照區段,接著是 startxref,接著是位元組偏移量,最後是 %%EOF。那段結尾正是讀取器最先找到的部分。

  1. Step 1 of 4: ISO 32000-2 §7.5.5 — %%EOF and startxref at the file end
  2. Step 2 of 4: ISO 32000-2 §7.5.4 / §7.5.8 — the cross-reference section maps object number to offset
  3. Step 3 of 4: ISO 32000-2 §7.5.5 — the trailer names /Root, the document catalog
  4. Step 4 of 4: ISO 32000-2 §7.3.10 — each indirect object is reached at its recorded offset
讀取器如何在 NextPDF 檔案中解析一個物件,以及定義每個步驟的 ISO 32000-2 條款:它從檔案結尾開始,向內推進。

這個四部分結構不是 NextPDF 自訂的慣例;它就是 Spec: ISO 32000-2, §7.5 的檔案結構條款。這份標準將 PDF 定義為一個 header、一個物件 body、一個交叉參照表,以及一個 trailer,並指出讀取器應從檔案結尾開始剖析。最後一行是 %%EOF,而它之前的兩行則是 startxref 關鍵字以及指向交叉參照區段的位元組偏移量。

Evidence: Standard-backed

所謂 間接物件,是由一個物件編號與一個產生編號(generation number)定義,兩者以空白分隔,後接以關鍵字 objendobj 框住的物件值。物件編號與產生編號的組合可唯一識別該物件;指向它的間接參照,寫法為物件編號、產生編號,以及關鍵字 R。NextPDF 的 ObjectRegistry 精確對映這個模型:連續編號、新寫入物件的產生編號 0,以及記錄下來的偏移量。

自 PDF 1.5 起,物件也可以存在於一個 物件串流 之中;在那裡,它們儲存時不帶 obj/endobj 關鍵字,且產生編號必須為零。交叉參照串流/Type /XRef Spec: ISO 32000-2, §7.5.8 )是 PDF 2.0 同時索引一般物件與這些壓縮物件的機制。 NextPDF 的 CrossReferenceStream 以一個 /W 欄位寬度陣列與 FlateDecode 壓縮來建構它。

以下是一個最小 PDF body 及其 trailer 的形狀。交叉參照區段中的那些數字是位元組偏移量。它們必須完全正確,這就是為什麼 NextPDF 是從緩衝區記錄它們,而不是推算它們。

%PDF-2.0
1 0 obj
<< /Type /Catalog /Pages 2 0 R >>
endobj
2 0 obj
<< /Type /Pages /Kids [3 0 R] /Count 1 >>
endobj
3 0 obj
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] >>
endobj
xref
0 4
0000000000 65535 f
0000000009 00000 n
0000000058 00000 n
0000000122 00000 n
trailer
<< /Size 4 /Root 1 0 R >>
startxref
196
%%EOF

讀取器會從底部開始開啟這個檔案:%%EOF,接著是 startxref 196,再定位到 xref 起始處的位元組 196,讀到物件 1 位於位元組 9,沿著 /Root 1 0 R 找到 catalog,並從那裡走訪頁面樹。物件 0 永遠是自由列表(free-list)的開頭,其產生編號為 65535——這是承襲自此格式最早期設計的一個怪癖;因為讀取器預期它存在,所以會被忠實重現。

常見陷阱是以為 PDF 會像原始碼一樣從上到下被讀取。事實並非如此。body 中的物件可以任意排序。物件編號在檔案中不必連續,讀取器也從不依賴它們連續。唯一具權威性的索引就是交叉參照區段,而找到它的唯一方法就是檔案結尾的 trailer。只要 startxref 中有一個錯誤數字,即使 body 完全有效,PDF 也無法讀取。物件寫入順序很亂、但交叉參照表正確的 PDF,則完全沒有問題。位置本身毫無意義;被記錄下來的位置才是一切。

本頁說明的是檔案結構,而不是頁面內容。標記如何被放上頁面——內容串流、繪圖運算子、文字顯示——是另一個獨立的主題。它也不涵蓋當檔案在寫入後被更動時會發生什麼事。那屬於增量更新(incremental updates)的工作,其中會附加第二個交叉參照區段,且 trailer 會向後串接。

NextPDF 是一個寫入器。此處描述的是它如何序列化一份自己建構的文件。它並不是通用的 PDF 剖析器或修復工具。它並不承諾讀取、重建或搶救任何交叉參照表受損的任意第三方檔案。這項保證刻意限縮範圍。NextPDF 寫出的檔案,其偏移量都吻合,因為它們是量測得來,而不是預測得來。

既然新檔案永遠使用 0,為什麼還需要產生編號? 產生編號是為了讓物件能在多次更新之間重複使用。剛寫好的檔案中,每個物件的產生編號都是 0。非零的產生編號,只有在檔案被增量更新且某個物件編號被回收再利用時才會出現。

兩個物件可以擁有相同的編號嗎? 在單一交叉參照區段內,不行。在跨越多次增量更新時,一個檔案在實體上可以包含同一物件編號的多份副本。最新的交叉參照項目會勝出。那是下一頁的主題。

檔案中的物件順序對輸出有影響嗎? 沒有。NextPDF 為了讓建置可重現,會以確定性順序寫入物件,但讀取器是透過交叉參照區段來解析一切,因此實體順序在語意上並無意義。

  • 間接物件——body 中具有編號的物件,寫作 N G obj … endobj,其中 N 是物件編號,G 是產生編號。
  • 間接參照——指向間接物件的指標,寫作 N G R
  • 交叉參照表(xref)——從物件編號到位元組偏移量的索引。在 PDF 2.0 中,這通常是一個交叉參照串流/Type /XRef),而非每筆項目佔 20 位元組的經典文字表格。
  • Trailer——位於交叉參照區段結尾的字典,標明 /Root(文件 catalog)與 /Size,並透過 startxref 偏移量被找到。
  • 物件串流——本身包含其他間接物件(一起壓縮)的串流物件;其成員不帶 obj/endobj,且產生編號為零。
  • 文件 catalog——由 /Root 所指名的物件;通往頁面樹及文件中其他所有內容的進入點。