跳到內容
getnextpdf.com

PDF 檔案的解剖構造

用純文字編輯器打開任何一個 PDF,你看到的第一件事很令人安心:一行 %PDF-1.x%PDF-2.0 的 header。你看到的最後一件事則是 %%EOF。介於這兩行之間的一切,是一台用來依編號尋找物件的精巧小機器。本頁是一場解剖。我們打開檔案,為每個器官命名,並說明它們如何彼此相連。

它是兩個鄰頁在結構面上的伴讀。PDF 到底是什麼 把檔案當成一個物件圖;增量更新 則說明它如何隨時間成長。本頁緊貼著位元組——剖析器逐一走過的那些實體區域,依照它們在磁碟上排列的順序。

你幾乎從不需要這些知識來使用一個 PDF。你需要它的那一天,是某個 PDF 出了問題的那一天。一個檔案在某個檢視器能開、在另一個卻不行;一個驗證器回報「交叉參照表損毀」;一份已簽署的文件突然驗證失敗。一旦你能讀懂它的解剖構造,這些都不再是謎團。它們只不過是一個不再對應到某個位置的數字、一個放錯地方的區域,或一段指向空無一物的結尾。

了解版面配置,能把「這個 PDF 損壞了」變成一個你可以著手處理的診斷。這正是對著黑箱聳肩,與指出那個說謊的確切位元組之間的差別。

按照這個檔案順序,一個符合規範的 PDF 有四個實體部分(Spec: ISO 32000-2, §7.5.1):

  1. 一個 header——一行,%PDF-2.0,標示版本。
  2. 一個 body——檔案的主體:一串已編號的間接物件
  3. 一個 交叉參照區段——一個從物件編號到該物件所在位元組偏移量的索引。傳統 PDF 使用文字表格;PDF 2.0 使用壓縮的 xref 串流
  4. 一個 trailer——一個標出進入點的小型字典,後面接著 startxref、一個偏移量,以及 %%EOF

其中的巧妙之處:讀取器不會從頭開始。它從結尾開始,讀取 startxref 找到索引,並用那個索引直接取得任何物件。檔案是由前往後寫入的,卻是由後往前讀取的。

讓我們依序走過這四個區域,把位元組擺在眼前。

header 只有一行。NextPDF 寫入 %PDF-2.0,並依慣例再加一行由高位元位元組組成的註解,好讓笨拙的傳輸工具把檔案當成二進位而非文字。那第二行就是為什麼把 PDF 當純文字打開時,會在版本號之後立刻看到一小段亂碼。

body 是文件實際存放的地方。每個間接物件都是一個編號、一個產生編號、關鍵字 obj、一個值,以及 endobjSpec: ISO 32000-2, §7.3.10)。這個值是少數幾種形狀之一——但其中兩種幾乎扛起了所有的份量:

  • 一個字典<< /Key value … >>,是名稱到值的對照。頁面樹、catalog、字型描述子:全都是字典。
  • 一個串流是一個字典,後面接著 stream、一段任意位元組的區塊,以及 endstream。頁面內容、內嵌字型與影像都是串流,而且幾乎總是經過壓縮。(它們的篩選器自成一段故事,講在串流與篩選器中。)

物件之間透過間接參照互相指向——2 0 R 的意思是「物件 2,產生編號 0」。正是這套接線,把一份扁平的物件清單變成一張圖。

交叉參照區段是多數人從未正確想像過的部分。在傳統形式下,它是純文字:關鍵字 xref,接著是由定寬 20 位元組行組成的子區段(Spec: ISO 32000-2, §7.5.4)。每一行是一個十位數的位元組偏移量、一個五位數的產生編號,以及單一個旗標——n 代表使用中、f 代表自由——並補足到恰好二十個位元組,好讓讀取器僅憑算術就能尋至任何項目。PDF 2.0 以一個交叉參照串流取代它:相同的索引,但是二進位且壓縮在一個標示為 /Type /XRef 的串流物件之內(Spec: ISO 32000-2, §7.5.8)。它更小,並且能夠描述封裝在物件串流之中的物件。

trailer 是檔案的目錄(Spec: ISO 32000-2, §7.5.5)。它標出 /Root——文件 catalog,也就是其他一切所懸掛的那個單一物件——以及 /Size,物件數量。接著是那套讓反向讀取成為可能的交握:startxref、自成一行的位元組偏移量,以及 %%EOF。讀取器尋至結尾,讀取那個偏移量,直接跳到交叉參照區段,然後就此出發。

  1. HeaderOne line, %PDF-2.0, naming the version. A binary-marker comment usually follows.
  2. BodyNumbered indirect objects — dictionaries and streams — referenced by N G R.
  3. Cross-reference sectionA text table of 20-byte entries, or a compressed /Type /XRef stream in PDF 2.0.
  4. TrailerNames /Root and /Size, then startxref + offset + %%EOF.
  5. Read orderA reader starts at %%EOF, follows startxref to the index, then reaches each object directly.
The four physical regions of a PDF in file order, and the path a reader actually takes through them — starting at the trailer and working inward via the cross-reference section.

還有第五個區域,是長壽的檔案會長出來的:一個增量更新。一項變更並不會就地寫入。被變更的物件、一個全新的交叉參照區段,以及一個新的 trailer,會附加在第一個 %%EOF 之後,而那個新 trailer 會攜帶 /Prev——前一個交叉參照區段的偏移量(Spec: ISO 32000-2, §7.5.6)。這些區段形成一條向後的鏈;對任何一個物件編號而言,最新的項目勝出。因為原始位元組永遠不會移動,所以簽章實際所涵蓋的那段位元組範圍,其密碼學檢查在更新之後仍然成立。後續的增量更新仍可能改變驗證器對整份文件的整體回報——簽署後的變更是否獲准、以及該簽章被認定保證了什麼——但它無法更動那些已簽署的位元組本身。這項性質正是增量更新整篇的主題。

以下是整套解剖構造,濃縮在一個最小檔案裡。xref 之下的那些數字是位元組偏移量,而它們必須完全正確——只要指到物件起始位置後面一個字元,嚴格的讀取器就會放棄。

%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
0000000115 00000 n
trailer
<< /Size 4 /Root 1 0 R >>
startxref
186
%%EOF

照剖析器的方式來讀它。最後一行:%%EOF。它的上方:startxref 186,於是尋至位元組 186,也就是 xref 開始的地方。表格說物件 1 位於位元組 9。trailer 的 /Root 1 0 R 指向那裡——catalog——並從 catalog 沿著 /Pages 走到頁面樹,找到那唯一的一頁。物件 0 永遠是自由列表的開頭,其產生編號為 65535,這是承襲自此格式最早期設計的一具化石,而每個讀取器至今仍預期會見到它。

陷阱是把一個 PDF 當成故事來讀——由上到下、依序而讀。它不是故事;它是一個帶有反向指標的索引。物件編號在檔案中不必連續,物件可以用任何實體順序出現,而讀取器從不依賴它們的位置。唯一具權威性的索引就是交叉參照區段,而找到那個索引的唯一方法,就是位於最末尾的 startxref 偏移量。

其後果常令人意外。一個 body 完美無瑕、卻在 startxref 中有一個錯誤數字的 PDF,是無法讀取的——讀取器找不到索引。一個物件順序被打亂、但交叉參照區段正確的 PDF,則完全沒有問題。實體位置不帶任何意義。被記錄下來的位置才承載了全部的意義。

本頁說明的是實體結構,而不是頁面內容。標記如何被放上頁面——內容串流運算子、文字顯示、繪圖狀態——是另一個獨立的主題。它也只說明格式良好的檔案。現實世界的 PDF 經常有點壞掉,之所以還能存活,全靠寬容的檢視器透過掃描 obj 關鍵字來重建交叉參照表。那種搶救是一種檢視器行為,並非格式所保證的事。

Reading and repairing arbitrary third-party PDFs — edition availability
EditionAvailability
CoreNextPDF 是一個寫入器。它在每個物件被發出的那一刻,就從輸出緩衝區記下每一個偏移量,因此它所產生的檔案,其交叉參照區段在建構上即與 body 相符。
Pro剖析、重建或修復一個並非由 NextPDF 寫入、且交叉參照表已損毀的檔案,在每一個方案中都不在範圍內。
Enterprise若要檢視某個既有檔案的結構,請使用專用的剖析器或驗證器;NextPDF 只保證它所寫出之物的正確性,不保證它所讀取之物。

為什麼 PDF 在 header 之後會有一行二進位標記? 某些較舊的傳輸工具,會把一個它們誤以為是純文字的檔案弄壞。那行高位元的註解讓檔案看起來明確就是二進位,於是它能毫髮無傷地走完旅程。

xref 串流只是一個更小的表格嗎? 大致是,但多了一項額外能力。除了被壓縮之外,xref 串流還能描述儲存在物件串流之內的物件——這些項目是傳統的 20 位元組文字表格無從表達的。

我能在一個檔案裡同時有表格和串流嗎? 單一個修訂版本只會用其中一種。但一個混合檔案可以把給舊讀取器用的傳統表格,與給新讀取器用的交叉參照串流配對在一起,好讓每一種讀取器都找到一個它看得懂的索引。

  • Header——第一行,%PDF-2.0,標示版本;通常後面接著一行二進位標記註解。
  • 間接物件——body 中具有編號的物件,寫作 N G obj … endobj,其中 N 是物件編號,G 是產生編號。
  • 字典——一個 << /Key value … >> 名稱到值的對照;最常見的物件形狀。
  • 串流——一個字典,加上一段介於 streamendstream 之間的位元組區塊,用於內容、字型與影像。
  • 交叉參照表(xref)——從物件編號到位元組偏移量的索引;在傳統上是每筆項目佔 20 位元組的文字表格,在 PDF 2.0 中是一個 /Type /XRef 串流。
  • Trailer——標出 /Root/Size 的字典,透過位於檔案結尾的 startxref 偏移量被找到。
  • 增量更新——被變更的物件、一個新的交叉參照區段,以及一個新的 trailer,附加在 %%EOF 之後,並以 /Prev 向後串接到前一個區段。