PDF 閱讀器不從檔案的開頭開始讀取;它從末尾開始;最後的幾個位元組包含其他所有內容的位址,不理解這種順序的剖析器會從第一行就誤讀該格式;因此,在磁碟上學習 PDF 最有用的方法是像閱讀器那樣去學習它:先看尾部,然後向後跳轉到地圖,接著解析地圖所指向的物件
當沒有任何內容被壓縮時,位元組本身在文字編輯器中非常清晰易讀;一個繪製出「Hello, World!」的最小單頁文件可以容納在不到 500 位元組的空間內,且該格式的每個結構元素在其中都清晰可見;以下是整個檔案,並標記了四個部分:
%PDF-1.0 % Header
%âãÏÓ
1 0 obj % Body: the object sequence
<<
/Kids [2 0 R]
/Count 1
/Type /Pages
>>
endobj
2 0 obj
<<
/Rotate 0
/Parent 1 0 R
/Resources 3 0 R
/MediaBox [0 0 612 792]
/Contents [4 0 R]
/Type /Page
>>
endobj
3 0 obj
<< /Font << /F0 << /BaseFont /Times-Italic /Subtype /Type1 /Type /Font >> >> >>
endobj
4 0 obj
<< /Length 65 >>
stream
1. 0. 0. 1. 50. 700. cm BT
/F0 36. Tf
(Hello, World!) Tj
ET
endstream
endobj
5 0 obj
<< /Pages 1 0 R /Type /Catalog >>
endobj
xref % Cross-reference table
0 6
0000000000 65535 f
0000000015 00000 n
0000000074 00000 n
0000000192 00000 n
0000000291 00000 n
0000000409 00000 n
trailer % Trailer
<<
/Root 5 0 R
/Size 6
>>
startxref
459
%%EOF
這四個部分在檔案中總是按以下順序排列:標頭、物件主體、交叉引用表和 trailer;但關鍵是,您幾乎是以相反的順序讀取它們;ISO 32000-2 §7.5.1 佈局了相同的四部分解剖結構,而這種由後向前的存取方式完全是出氣實用目的:直接跳轉到所需物件的閱讀器比從頂端掃描每個位元組的閱讀器要快得多,而這種隨機存取正是 trailer 和交叉引用表存在的目的
標頭只有兩行,且第二行至關重要
第一行是 %PDF-1.0;就語法而言,百分號使其成為註解,但閱讀器會將其視為檔案特徵標記並從中提取版本號;在實踐中,版本的處理很寬鬆;專為 PDF 2.0 構建的閱讀器會很樂意開啟聲稱為 1.0 的檔案,且大多數閱讀器會嘗試開啟宣告版本錯誤或版本行稍微埋在檔案中而不是在位元組零處的檔案;該數字是有關期望哪些功能的提示,而不是限制
第二行是人們不小心刪除然後花一下午時間偵錯的行;它也是一個註解,但它的負載是四個高於 ASCII 127 的位元組;它們的存在是為了讓任何以「文字模式」移動檔案的工具都能識別其為二進位格式並停止重寫行尾;PDF 攜帶壓縮串流,其位元組可能巧合地與歸位或換行相匹配,如果傳輸工具重寫了這些位元組,字典中記錄的串流長度將不再與磁碟上的位元組相符,檔案就會損壞;高位元組註解是針對 ASCII 模式下 FTP 的有著四十年歷史的防禦措施,它仍然存在於每個嚴謹的工具所寫入的檔案中,因為它所防止的失敗是無聲且嚴重的
主體包含物件,每個物件都有編號
構成文件的所有內容都以間接物件的扁平序列形式存在於主體中;每個物件都以兩個整數和 obj 關鍵字開頭,包含其內容,並以 endobj 結尾;上面範例中的物件 1 是頁面樹節點:1 0 obj,然後是字典,最後是 endobj;第一個整數是物件編號,第二個是世代號;在全新寫入的檔案中,世代號幾乎總是零,只有當物件編號在不同編輯中被重複使用時它才會增加,這非常罕見,以至於您可以將非零世代視為檔案經歷過增量更新的跡象;此處關鍵字之間的內容是一個字典,寫在 << 和 >> 之間,但它也可以是數字、字串、陣列或串流
使這成為圖(Graph)而不是列表(List)的是引用標記 2 0 R;這意味著「物件 2,世代 0,不論它剛好存在於檔案中的哪個位置」;上面的頁面樹節點並不包含其頁面,它指向物件 2,而物件 2 則透過相同的機制指向其資源和內容串流;主體以寫入器認為方便的任何順序配置,且引用將其縫合到以目錄為根的樹中;檔案中的位置沒有任何意義;識別身分來自物件編號,而位置則來自交叉引用表
交叉引用表是位元組偏移量的索引
xref 表是將物件編號轉換為檔案位置的關鍵;這就是為什麼閱讀器可以開啟包含一千頁的文件並算繪第 850 頁,而不需要解析其前的 849 頁的原因;每個分量都精確記錄了其物件從檔案開頭算起的位置(以位元組為單位):
xref
0 6 % 6 entries, starting at object 0
0000000000 65535 f % entry 0: head of the free list
0000000015 00000 n % object 1 begins at byte 15
0000000074 00000 n % object 2 begins at byte 74
0000000192 00000 n % object 3 begins at byte 192
0000000291 00000 n % object 4 begins at byte 291
0000000409 00000 n % object 5 begins at byte 409
固定寬度是特意設計的;每個項目都剛好是二十個位元組:十位數的偏移量、空格、五位數的世代號、空格、一字元的類型,以及兩位元組的行尾字元;因為各行是統一的,所以閱讀器可以透過算術計算直接索引到物件 n 的項目,而不用進行掃描,因此為本體提供隨機存取的表格本身也是可以隨機存取的;0 6 這行是子區段標頭:它表示接下來的項目描述了從編號 0 開始的六個物件
物件 0 很特殊且總是存在;它的類型是 f(代表 free 釋放),其世代號是 65535,並且它引導了空閒物件編號的連結清單;在從未編輯過的檔案中,空閒列表只有這一個分量,只是一個形式;它在增量更新過程中發揮了作用,此時刪除一個物件會將其編號新增到該清單中,以便稍後的編輯可以重新取用它;其他項目是 n 類型(代表 in-use 使用中),它們的十位數數字是您為了讀取該物件定義而需要尋找的偏移量
Trailer 是入口點,且它位於末尾
即使 trailer 是最後寫入的,它卻是閱讀器最先讀取的內容;剖析器開啟檔案,尋找到末尾,然後向後尋找 %%EOF;在其上方是 startxref,緊接著一個數字,該數字是 xref 關鍵字的位元組偏移量;有了它,閱讀器可以直接跳轉到交叉引用表,而不用掃描單個物件:
trailer
<<
/Root 5 0 R % the document catalog
/Size 6 % one more than the highest object number
>>
startxref
459 % byte offset of the xref table
%%EOF
Trailer 字典攜帶了閱讀器在執行任何其他操作之前所需的兩個值;/Root 指向文件目錄,在此處為物件 5,這是物件圖的頂端,也是通往頁面樹的路由;/Size 是交叉引用表應包含的項目計數,由於零槽處的空閒項目,該值比最高物件編號多一;從 %%EOF 開始,整個讀取順序展開:尋找標記、讀取 startxref 定位表、載入表以了解每個物件的位置、讀取 /Root 找到目錄,並從該處按需解析物件;位於頂部的標頭直到很晚才被諮詢;底部的心智圖是閱讀器首先需要的東西
增量更新會附加第二張地圖,而不是重寫
當檔案發生變更時,這種尾端優先的設計就發揮了作用;PDF 可以在不重寫已存在於磁碟上的任何位元組的情況下進行編輯;新物件和修改後的物件會被附加到末尾,隨後是新的交叉引用區段和新的 trailer,而底層的原始檔案則保持不變;唯一的新增記錄是新 trailer 中的 /Prev 項目,它保存了前一個交叉引用表的位元組偏移量:
% ... original file, unchanged, ends here ...
6 0 obj % an object added by this edit
<< /Type /Annot /Subtype /Text /Rect [100 700 120 720] >>
endobj
xref % a second xref section, for the new object only
6 1
0000000612 00000 n
trailer
<<
/Root 5 0 R
/Size 7
/Prev 459 % byte offset of the earlier xref table
>>
startxref
680 % offset of this new xref section
%%EOF
閱讀器仍然從最後的 %%EOF 開始,仍然遵循 startxref 找到最新的表,本現在會沿著 /Prev 鏈向後追蹤到較舊的表,並將它們合併,使任何物件編號的最新項目勝出;交叉引用區段在檔案中形成一個向下的連結清單,每個區段都會覆寫它所接觸的物件之前的前一個區段;編輯所取代的物件仍然實體存在於其舊的偏移量處,它只是不再可達,因為後來的 xref 項目指向了更新的地方
這是使已簽署的 PDF 可驗證的機制;數位簽章覆蓋了檔案的位元組範圍,並且因為增量更新只會附加內容,所以簽署的位元組永遠不會移動;簽章仍然針對原始範圍進行驗證,而較晚的修訂版本則位於其後,且各有其 xref 和 trailer;這也是為什麼 PDF 可以攜帶可還原之歷程的原因:每個被取代的物件仍然存在於較早的交叉引用區段下的磁碟中,這對於版本追蹤是一項功能,但對於任何認為「刪除」意味著位元組已消失的人來說則是一項隱患
代價是體積增長;每次編輯都會附加內容,沒有任何內容是在原地回收的,因此修改多次的檔案會累積無用的物件和長長的 xref 區段鏈;補救方法是完整重寫:載入文件並重新儲存,這會對存活的物件進行重新編號、丟棄不可達的物件,並輸出一個乾淨的交叉引用表;這兩種策略直接進行折衷;附加內容速度快且能保留簽章和歷程,而重寫速度較慢且丟棄兩者,以換取緊湊的檔案
實踐中讀取這四個部分
了解版面配置就足以手動偵錯大多數「此檔案無法開啟」的問題;如果閱讀器拒絕 PDF,通常問題出在兩端,而不是中間;截斷的下載會遺失 trailer,導致 startxref 或 %%EOF 缺失,閱讀器沒有入口點,寬容的閱讀器會退而掃描整個檔案以重建 xref,這正是該表旨在避免的慢速路徑;糟糕的文字模式傳輸會損壞串流位元組,或者偏移量不再與實際情況相符,導致物件從錯誤的位置載入;當表中的偏移量不再指向實際的 obj 關鍵字時,即使每個物件單獨來看都很好,檔案在結構上也已損壞
對於新的程式碼,版面配置的教訓是讓函式庫來管理位元組的記錄;交叉引用表中的偏移量必須與每個物件的實際位置逐位元組保持一致,trailer 必須指向正確的表,且增量更新必須透過 /Prev 正確鏈接;像適用於 Delphi 和 C++Builder 的 HotPDF 元件 這樣的原生元件,在寫入檔案時會處理所有這些問題,包括在附加增量修訂版本和重寫緊湊檔案之間進行選擇;如果您想看到相同的結構從無到有建置起來,而不是被解剖,關於 從頭開始建置 PDF 文件 的配套文章將引導您依序輸出標頭、物件、xref 和 trailer