技術文章

手動建置極簡 PDF:您需要的五個物件

PDF 在本質上是一個純文字容器;在十六進位編輯器中開啟大多數檔案,頂部是可讀的:版本註解,然後是一系列編號的物件,接著是一個小索引,而最底部則是一個指示檢視器從何處開始的指標;剝離壓縮後,該格式就變得足夠平易近人,您可以在文字編輯器中輸入一個可運作的文件並讓檢視器開啟它;這樣做一次,比閱讀任何規範都能讓您更了解 PDF 是如何結合在一起的,因為您必須手動將物件彼此連接,而且在您連接正確之前,檔案會拒絕開啟

此逐步指南建置了實際轉譯某些內容的最小 PDF:一頁,在 US Letter 紙張上使用內建字型顯示「Hello, World!」;完成的檔案正好需要五個物件以及圍繞它們的幾行簿記;我們將先撰寫物件,然後組合標頭、交叉引用表和尾標,以將它們綁定到檢視器可以接受的檔案中

檢視器要求的五個物件

檢視器不會從頭到尾掃描 PDF 來尋找內容;它從尾標開始,沿著對文件型錄的引用,並從那裡遍歷物件鏈;該鏈上的每個物件都必須存在,否則開啟將會失敗;對於一頁的文件,鏈條很短,且每個連結都有單一工作:

  • Catalog(型錄)是根;它是尾標指向的物件,在此處其唯一需要的項目是對頁面樹的引用
  • Pages(頁面樹)是頁面樹節點;它列出文件中的頁面並報告頁面數量
  • Page(頁面)描述一個實體頁面:其大小、繪製所用的資源,以及繪製該頁面的內容資料流
  • Content stream(內容資料流)容納繪圖運算子,即在該頁面上放置文字和圖形的後置命令
  • Font(字型)宣告內容資料流參考的字體;使用 14 種標準字型之一,您就無需內嵌任何內容

每個物件都是有編號且可定址的;間接物件寫為 N 0 obj ... endobj,其中 N 是物件編號,而 0 是其世代編號(在您重新撰寫的檔案中始終為 0);在檔案的任何其他地方,您都使用引用指向該物件: 5 0 R 表示「物件 5」;這些引用就是連接線;型錄在我們的編號中持有 2 0 R 以到達頁面樹,頁面樹持有一個向下的頁面引用,依此類推;如果編號出錯,檢視器就會順著懸空指標指向虛無

名稱、字典與資料流

三種語法結構幾乎承載了所有內容;一個名稱以斜線開始:/Type/Page/F0;名稱是區分大小寫的識別碼而非字串,PDF 使用它們作為字典鍵值並標記物件是什麼;一個字典是包裹在雙角括號中的鍵值對集合,其中每個鍵都是一個名稱:<< /Type /Page /MediaBox [0 0 612 792] >>;值可以是數字、名稱、方括號中的陣列、引用或巢狀字典;大多數 PDF 物件都是字典

一個資料流是一個字典,後跟關鍵字 streamendstream 之間的位元組區塊;這就是頁面繪圖運算子所在的位置,也是實際檔案中壓縮影像和內嵌字型所在的位置;資料流字典描述了這些位元組,在實際生產檔案中,它必須包含一個給出確切位元組計數的 /Length 項目,當資料壓縮時,通常還會有一個像是 /Filter(例如 /FlateDecode)的項目;我們將仰賴工具來填入 /Length,因為手動計算位元組是此練習中沒有教育意義且極易出錯(差一個位元組即損壞檔案)的部分

撰寫物件

以下是按順序排列的五個物件;在閱讀內容資料流之前,要記住的座標細節是:PDF 從頁面的左下角開始測量(以點為單位,其中 1 點為 1/72 英吋),且 Y 軸向上增長;US Letter 頁面為 612 x 792 點,因此 50 700 位於左上角附近,而不是底部

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]
   /Resources << /Font << /F0 4 0 R >> >>
   /Contents 5 0 R
>>
endobj

4 0 obj
<< /Type /Font
   /Subtype /Type1
   /BaseFont /Helvetica
>>
endobj

5 0 obj
<< /Length 44 >>
stream
BT
/F0 36 Tf
50 700 Td
(Hello, World!) Tj
ET
endstream
endobj

讀取引用即可看出結構;物件 1(型錄)將其 /Pages 項目指向物件 2;物件 2(頁面樹)在 /Kids 中列出物件 3,並宣告 /Count 1;物件 3(頁面)將 /Parent 指回物件 2(樹和頁面必須互相參考),使用 /MediaBox 調整自身大小,在其 /Resources 中以區域名稱 /F0 公開字型,並指定物件 5 為其內容;物件 4 是字型:/BaseFont /Helvetica 選擇了每個符合標準的檢視器都已具備的 14 種標準字體之一,因此無需內嵌任何內容;物件 5 是內容資料流

內容資料流實際代表的意義

資料流主體是 PDF 頁面描述語言中的一個微型程式,該語言是後置的:運算元在前,接著是消耗它們的運算子;有五行執行了這項工作;BTET 開啟和關閉文字物件,所有定位或顯示文字的內容都必須位於它們之間;/F0 36 Tf 將目前字型設定為名為 /F0、大小為 36 點的資源(Tf 是「設定文字字型和大小」);50 700 Td 將文字位置移動到頁面座標中的 (50, 700);(Hello, World!) Tj 顯示字串,PDF 將其寫入括號中作為字面文字,使用 Tj 將其繪製在目前位置;遺漏 BT/ET,嚴格的檢視器會拒絕文字運算子,忘記在 Tj 之前設定字型,則沒有目前字型可供繪製

資料流字典中的 /Length 44streamendstream 之間的位元組計數,它必須是準確的;此值值得交給工具處理,而不是手動計算換行符號,特別是因您的編輯器將行尾寫入為 LF 還是 CRLF 會改變總數

標頭、xref 與尾標

物件是內容;三個結構片段將它們轉換成檔案;第一個是標頭(第一行),命名了格式和版本:

%PDF-1.7

在 PDF 語法中 % 開始一個註解,但檢視器會將此特定註解視為格式簽章並從中讀取版本;實際的寫入器會緊隨其後加上第二行高位元組的註解,以提示檔案傳輸工具該檔案是二進位檔案且絕不能被當作文字處理

在檔案的末尾是交叉引用表,它是使隨機存取成為可能的索引;它記錄了每個物件自檔案開頭起算的位元組偏移量,因此檢視器可以直接尋找物件 3,而無需先解析物件 1 和 2;該表是固定的:每個項目都是固定寬度的 20 位元組(包括行尾),格式化為 10 位數的偏移量、5 位數的世代、關鍵字(使用中為 n,空閒為 f)和兩位元組的結束符號;我們六個項目(物件 0 始終是空閒清單標頭)的正確表如下所示:

xref
0 6
0000000000 65535 f
0000000009 00000 n
0000000058 00000 n
0000000115 00000 n
0000000235 00000 n
0000000308 00000 n
trailer
<< /Size 6
   /Root 1 0 R
>>
startxref
408
%%EOF

這些偏移量是手動撰寫 PDF 時最脆弱的部分;每一個偏移量都是對應的 N 0 obj 開始的精確位元組位置,一旦您在上面的任何位置新增一個字元,每個偏移量都會發生偏移;尾標是檢視器最先和最後使用的進入點:/Root 1 0 R 命名了型錄,/Size 6 聲明了物件數量,而 startxref 408 則給出了 xref 單字本身的位元組偏移量;檢視器開啟檔案、跳轉到末尾、讀取 startxref、尋找到交叉引用表,並從那裡到達型錄及其下方的所有內容;%%EOF 標記最後一個位元組

讓工具來修正位元組計數

上述偏移量僅為示意,在實務中,當您輸入完畢時它們就已經是錯的了,因為它們取決於您檔案的精確位元組版面配置;與其重新計算,不如使用預留位置值撰寫結構,並讓公用程式重建交叉引用表和資料流長度;免費、跨平台的 pdftk 可以一次完成:

pdftk hello-draft.pdf output hello.pdf

它解析您的物件、重新計算每個位元組偏移量、填入正確的 /Length 值、寫入有效的 xref 表和尾標,並輸出 hello.pdf;在任何檢視器中開啟它,您將在頂部附近獲得一個包含 36 點 Helvetica 字型「Hello, World!」的頁面;qpdf 執行相同的任務,且許多檢視器也會即時修復稍微損壞的檔案;在此處依賴工具並非因為懶惰,而是因為偏移量運算是該格式中概念內容最少且出錯率最高的部分,因此將其自動化可以讓結構保持為您正在學習的內容

為什麼這可以擴充到實際文件

一份百頁的報告在結構上與您剛剛建置的並無二致;型錄仍然位於根部,頁面樹仍然收集頁面,且每個頁面仍然指向其資源和內容資料流;增長的是寬度而非主幹:頁面樹分支以便檢視器可以跳過整個子樹,內容資料流包含數百個運算子而非五個,字型作為其自身的資料流物件(帶有寬度表和編碼)被內嵌,而影像則作為具有特定影像篩選器的資料流到達;現代檔案也傾向於將許多物件打包到壓縮的物件資料流中,並使用交叉引用資料流取代純文字 xref 表,這就是為什麼在文字編輯器中開啟實際的 PDF 通常會顯示一堵二進位字元牆;底層模型與您手動製作的檔案完全相同;有關更廣泛的物件圖以及在更大文件中型錄、頁面樹和資源字典如何相互關聯,PDF 文件結構深入之旅從這裡接續,而檔案結構概述則涵蓋了增量更新以及尾標如何在修訂版本之間進行鏈結

從手寫到函式庫

手動輸入物件是學習練習,而不是生產技術;一旦您需要真正的字型、換行文字、影像或多於一個普通的頁面,pdftk 為您修補的位元組簿記就會變成全部的工作,此時您會需要一個負責處理這些的函式庫;相同的五個物件仍然會被寫入,但函式庫會計算每個偏移量、管理字型和資源字典,並壓縮內容資料流,而無需您追蹤任何一個位元組;在 Delphi 和 C++Builder 中,HotPDF Component 將這整個檔案簡化為少數幾個呼叫:設定文件、呼叫 BeginDocSetFontTextOut 來放置相同的問候語,然後呼叫 EndDoc 來寫入正確的型錄、頁面樹、xref 和尾標;了解底層的物件,是當文件未以您預期的方式轉譯時,讓您能推斷出輸出原因的關鍵