技術文章

PDF 檔案結構:標頭、物件主體、交叉引用表與 Trailer

下載到一半被切斷的 PDF,即使幾乎每一頁都到了也可能拒絕開啟;2,000 頁的手冊卻能瞬間跳到第 1,500 頁。兩者都出自 PDF 檔案結構,HotPDF Delphi Component 寫出來的樣子是:標頭、編號物件構成的主體、記位元組偏移的交叉引用(xref)表,還有 trailer,最後兩個存在檔尾

ISO 32000-2 §7.5.1 為每份 PDF 排出同樣的四段解剖,從一頁收據到掃描典藏都一樣。各部分在檔案裡的順序永遠相同,並以固定的鏈條相連:trailer 指名 catalog、說出 xref 表從哪裡開始,xref 表說出每個物件從哪裡開始,物件之間用編號互相指向。下面的節選來自一份 610 位元組的 Hello World 檔案,偏移量逐位元組核對過;逐關鍵字標註的完整檔案見PDF 內部長什麼樣子,檢視器拿這些部分一路畫到像素的過程,見閱讀器開啟 PDF 時它如何運作

PDF 檔案格式圖:PDF 檔案在磁碟上的結構——標頭、物件主體、交叉引用表與 trailer
PDF 檔案結構圖:標頭、物件主體、交叉引用表與 trailer 在磁碟上按一個固定順序排列,檔尾的 trailer 把其他三者繫在一起

PDF 標頭是兩行,第二行才是重點

第一行,例如 %PDF-1.7,就語法而言是註解,但閱讀器把它當檔案簽名、從它取版本;實務上版本處理很寬鬆,把那個數字當成該期待哪些功能的提示就好。第二行是人們會不小心刪掉、然後花一個下午除錯的那一行。它的負載至少有四個位元組高於 ASCII 127(ISO 32000-1 §7.5.2),用意是讓任何以文字模式搬動檔案的東西認出它是二進位、停止改寫行尾。壓縮串流裡有碰巧等於回車或換行的位元組;改寫它們,字典裡記錄的串流長度就不再與磁碟上的位元組相符。每個正經的寫出器仍然發出這行註解,因為它擋下的失敗無聲而全面

主體:編號物件與八種 PDF 物件型別

主體是間接物件的扁平序列,每個包成 N G obj ... endobj,帶物件編號與世代編號。把這份扁平名單變成 PDF 內部結構的,是引用記號 N G R:2 0 R 意思是「物件 2、世代 0,不管它恰好住在檔案裡哪裡」。任何值也可以就地寫成直接物件。在節選裡,頁面的 /Resources 字典是直接的,它指名的字型卻是間接的,一百個頁面可以共用物件 4,而不必各帶一份複本:

1 0 obj
<< /Type /Catalog /Pages 2 0 R >>          % catalog 指向頁面樹
endobj
...
3 0 obj
<< /Type /Page
   /Parent 2 0 R
   /MediaBox [0 0 612 792]
   /Resources << /Font << /F1 4 0 R >> >>   % 直接字典、間接字型
   /Contents 5 0 R
>>
endobj
PDF 間接引用解剖:字型物件 12 只定義一次,頁面的 resources 字典透過 12 0 R 記法觸及它
物件靠物件編號與世代編號獲得身分,需要之處寫出對應的 R 引用即可觸及,不必放複本

ISO 32000-1 §7.3 把詞彙收得很短:PDF 裡每個值都是八種基本型別之一。boolean 與數字是純量,PDF 沒有指數記法,所以 1e6 不會出現在合規檔案裡。字串裝位元組,寫成 (Hello, World!) 或十六進位的 <48656C6C6F>。/Type 或 /Helvetica 這類名稱逐位元組比較,所以在 /BaseFont 期待名稱的地方寫 (Helvetica) 是型別錯誤,即使字元相符。陣列按順序裝任何東西,像 MediaBox。字典把名稱映射到值,扛著大部分結構。串流是字典加原始位元組,頁面內容、字型與影像都住在這裡。第八種型別是 null,對不存在物件的引用解析到它而不是丟錯誤,受損檔案因此得以降級,而不是整個失敗

PDF 物件八型別卡片格:boolean、數字、字串、名稱、陣列、字典、串流與 null 的示例語法
八種基本型別構成 PDF 值的完整詞彙;字典扛結構重活,串流裝笨重的壓縮負載

世代編號是為重用而存在的。編輯刪掉一個物件時,它的編號進入空閒名單;之後的編輯重用這個編號時,世代加一,過期的 12 0 R 因此無法悄悄解析到新的 12 1 obj。你遇到的幾乎每個物件都是世代 0。解析從 catalog 開始,即節選裡的物件 1,它的 /Pages 條目領進頁面樹;內部節點把頁面收進 /Kids、保有 /Count,還能把 /MediaBox 這類屬性傳給後代。為什麼決定頁面順序的是這棵樹、而不是物件的物理順序,見PDF 頁面樹與頁面順序

xref 表怎麼定位一個物件?

xref 表用算術把物件編號換成位元組偏移,2,000 頁的手冊因此能直接開在第 1,500 頁、不必解析它之前的頁面。ISO 32000-1 §7.5.4 把每個條目固定在恰好二十個位元組:從檔案開頭起算的十位數偏移、一個空格、五位數世代、一個空格、一個型別字母,再加兩位元組的行尾,LF 之下是空格加換行。因為每列等寬,閱讀器直接索引到物件 n 的那一列。0 6 這行是子段標頭,條目 0 永遠空閒、世代 65535,領銜空閒物件編號的鏈結串列。其餘每個偏移都必須落在對應 N G obj 檔頭的第一個數字上,610 位元組檔案裡就是這樣:

xref
0 6                  % 6 個條目,從物件 0 開始
0000000000 65535 f   % 條目 0:空閒串列的頭
0000000019 00000 n   % 物件 1(catalog)從位元組 19 開始
0000000068 00000 n   % 物件 2(頁面樹)從位元組 68 開始
0000000125 00000 n   % 物件 3(頁面)從位元組 125 開始
0000000263 00000 n   % 物件 4(字型)從位元組 263 開始
0000000333 00000 n   % 物件 5(content stream)從位元組 333 開始
二十位元組 xref 條目的解剖:十位數位元組偏移讓閱讀器不必掃描就能定位到任何物件
等寬的二十位元組列用算術把物件編號換成位元組偏移,物件 0 領銜空閒串列

被截斷的 PDF 為什麼開不了?

被截斷的 PDF 失敗,因為把一切繫在一起的兩個部分——xref 表與 trailer——是最後寫入的位元組。trailer 字典帶 /Root(catalog)與 /Size(xref 條目數,比最高物件編號多一,因為零號槽位放空閒條目)。其下,startxref 給出 xref 關鍵字的位元組偏移,%%EOF 收掉檔案:

trailer
<< /Size 6 /Root 1 0 R >>   % 條目數、文件 catalog
startxref
427                          % xref 關鍵字的位元組偏移
%%EOF

丟掉最後幾百位元組,檔案有物件卻沒有指向它們的地圖。嚴格的閱讀器到此為止;寬容的掃整個檔案找 N G obj 檔頭、重建表格——這正是 xref 存在所要避免的慢路徑——而且重建出的地圖仍可能漏掉物件、或挑中被編輯過物件錯的複本。文字模式傳輸的傷害更安靜,改寫行尾會移動每個物件。我們把 610 位元組檔案轉成 CRLF 行尾、跑了 qpdf --check(qpdf 12.3.2):它在偏移 427 回報「file is damaged」與「xref not found」,先掃描重建表格、然後才肯檢查任何東西

經典 xref 表,還是交叉引用串流?

從 PDF 1.5 起,xref 也可以是交叉引用串流(ISO 32000-1 §7.5.8):一個 /Type /XRef 的間接物件,二進位列取代文字表,串流字典吸收 trailer 條目,trailer 關鍵字於是消失,startxref 指向那個物件。列型別變成數字(0 空閒、1 在位元組偏移、2 在物件串流內),/W 給每欄的位元組寬度,HotPDF 用 Flate 加 PNG Up 預測器壓縮列。Version 是 pdf15 以上時 HotPDF 寫出串流形式,所以範例把它明確設好,UseObjectStreams 建在同一個開關上。必須在 PDF 1.4 年代工具裡開啟的檔案——它們完全解析不了 xref 串流——我們把 UseXRefStream 留在關;緊湊索引划得來的大型文件則打開。變的只有檔尾:

uses
  System.SysUtils, HPDFDoc;

procedure SaveWithXRef(const FileName: string; AsStream: Boolean);
var
  PDF: THotPDF;
begin
  PDF := THotPDF.Create(nil);
  try
    PDF.AutoLaunch := False;
    PDF.FileName := FileName;
    PDF.Version := pdf17;            // xref 串流需要 PDF 1.5 以上
    PDF.UseXRefStream := AsStream;   // 預設 False:文字表加 trailer
    PDF.BeginDoc;
    PDF.CurrentPage.TextOut(72, 72, 0, 'Same page, different index');
    PDF.EndDoc;
  finally
    PDF.Free;
  end;
end;

// SaveWithXRef('table.pdf', False);
//   預期檔尾:xref、0 N、二十位元組列、
//   trailer << /Size /Root /Info /ID >>、startxref、%%EOF
// SaveWithXRef('stream.pdf', True);
//   預期檔尾:N 0 obj << /Type /XRef /Size /Root /Info /ID /W ...
//   /Filter /FlateDecode >> stream ... endstream endobj、startxref、%%EOF

增量更新附加第二份 xref,而不是重寫

PDF 可以在完全不碰磁碟上既有位元組的情況下編輯。變更與新增的物件附加在後,接著一份只列它們的 xref 段,以及一個 /Prev 條目存著前一張表偏移的 trailer。閱讀器沿 startxref 到最新段,反向走訪 /Prev 鏈,讓每個物件編號最新的條目勝出。下面的編輯給 610 位元組檔案加一個備註附註:頁面物件 3 以 /Annots 陣列在同一編號下重寫,物件 6 是新的。拿掉註解、用 LF 行尾,偏移就是精確的,結果過 qpdf --check 無任何錯誤:

% ...原始 610 位元組,原樣不動 ...
3 0 obj                          % 取代頁 3,同一物件編號
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R /Annots [6 0 R] >>
endobj
6 0 obj                          % 新的附註
<< /Type /Annot /Subtype /Text /Rect [72 650 92 670] /Contents (Checked) >>
endobj
xref                             % 兩個單條目子段
3 1
0000000610 00000 n
6 1
0000000752 00000 n
trailer
<< /Size 7 /Root 1 0 R /Prev 427 >>
startxref
843
%%EOF
PDF 增量更新:附加第二份 xref 段與 trailer,經 /Prev 串鏈,原始位元組與簽章範圍原地不動
編輯附加新物件,加上經 /Prev 串鏈的新 xref 與 trailer;原始位元組範圍永不移動,簽章因此仍然有效

附加正是讓已簽章 PDF 仍可驗證的機制:簽章涵蓋一個位元組範圍,增量更新永不移動那些位元組。這也是 PDF 帶著可復原歷史的原因:舊的頁 3 還坐在位元組 125,觸及不到、但存在。反覆修訂的檔案長出長長的 /Prev 鏈;qpdf revised.pdf flat.pdf 把可觸及物件在單一 xref 下重新序列化、丟掉舊修訂,上面的檔案如此處理後剩一張 xref 表、沒有 /Prev,代價是原始位元組上的任何簽章。HotPDF 裡,增量修訂長這樣:

procedure ReviseInPlace(const Original, Revised: string);
var
  PDF: THotPDF;
begin
  PDF := THotPDF.Create(nil);
  try
    PDF.AutoLaunch := False;
    PDF.BeginIncrementalUpdate(Original);   // 原始位元組逐字保留
    PDF.SetLoadedTitle('Quarterly report, revised');
    PDF.SetLoadedAuthor('Finance');
    PDF.SaveIncrementalUpdate(Revised);      // 變更物件、新 xref、帶 /Prev 的 trailer
  finally
    PDF.Free;
  end;
end;

四段版面也給行為失準的 PDF 一個快速的檢傷順序:確認檔案以 %%EOF 收尾、startxref 數字講得通,抽查一兩個 xref 偏移是否落在 N G obj 檔頭上,然後沿 /Root 進 catalog 與頁面樹。供 Delphi 與 C++Builder 使用的 HotPDF Delphi Component 按要求寫經典 xref 表或交叉引用串流,附加的增量修訂讓更早的位元組、以及覆蓋其上的簽章原封不動