技術文章

Delphi 中的 PDF 物件串流與交互參照串流

PDF 1.5 的物件串流會把許多小型間接物件打包進一個以 Flate 壓縮的容器中,losLab PDF Library 透過 PackObjectStreams 旗標,在完整儲存時產生這些容器。這樣做的收益是實實在在的:數百個原本各自要花上幾十個未壓縮位元組的頁面、字型與註解字典,會被壓縮成寥寥幾個壓縮區塊。代價則是每個被打包的物件,現在都需要一個交互參照串流來描述它

後半段正是寫入器容易出錯的地方。建構一個 /ObjStm 容器只是算術問題;讓交互參照機制能正確指向它,則是一次重新設計。一個能產生完全合法容器、卻用一般 type-1 偏移量來描述其成員的寫入器,寫出來的檔案 Acrobat 只會打開夠久的時間,好宣告它已損毀。這兩項功能其實是同一項功能,本文涵蓋這兩者在 ISO 32000-1 §7.5.7 與 §7.5.8 中定義的寫入端做法

ObjStm 容器實際上包含什麼

物件串流是一種串流,其解碼後的位元組由兩個串接的區域組成,ISO 32000-1 §7.5.7 為構建它所定義的字典鍵恰好只有三個。/Type /ObjStm 用來識別它,/N 給出成員數量,/First 則給出標頭區域的位元組長度──也就是本文區域開始的偏移量。標頭是以空白字元分隔的物件編號與偏移量配對;本文則是各成員依序序列化後首尾相接的結果,每個偏移量都是從本文開頭起算,而不是從解碼後酬載的開頭起算。讀取一個完整解碼後的容器就能看得很清楚:下方範例中,/First 是 14,因為三行標頭占了十四個位元組;物件 7 位於本文的第 55 個位元組處,因為物件 4 序列化後是 54 個字元加上一個分隔符

// Decoded payload of: 12 0 obj << /Type /ObjStm /N 3 /First 14
//                        /Filter /FlateDecode /Length 118 >> stream
4 0
7 55
9 90
<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>
<< /Type /ExtGState /CA 1 /ca 1 >>
[ 0 0 595 842 ]

有兩條成員資格規則是絕對的,而且都直接源自 §7.5.7。串流物件絕不能成為成員,因為串流帶有原始位元組,那將必須巢狀嵌入另一個串流裡。而且成員必須是完整的物件值,絕不能只是一個裸間接參照──一個內容僅為 5 0 R 的壓縮物件,會產生一層讀取端在事先不知道其指向目標的情況下無法解析的間接關係。losLab PDF Library 會在候選收集階段把這兩種情況連同加密字典與物件 0 一併過濾掉,然後把倖存者以每容器 200 個為一組進行打包。這個上限是隨機存取層面的考量,而非規範限制:想要讀取單一成員的讀取端,必須把整個容器解壓縮,所以過大的容器會讓小型查詢變得昂貴

為何 ObjStm 成員必須使用 type-2 交互參照條目?

因為一個被打包的物件並沒有檔案偏移量可供記錄。ISO 32000-1 §7.5.8 以二進位交互參照串流中的三種條目類型回答了這個問題:type 0 代表空閒物件,type 1 代表存放於某個位元組偏移量的一般使用中物件,type 2 則代表壓縮物件,其兩個資料欄位分別存放容器物件編號與該物件在容器內的成員索引。傳統純文字的 xref 表根本無法表達一個被打包的物件,這正是為何 PDF 1.5 要把這兩項功能一併引入的原因

隨之而來的順序問題,幾乎讓每一個第一次實作的人都栽了跟頭,我們自己也不例外。一般物件會得到 type-1 條目。/ObjStm 容器本身也會得到 type-1 條目,因為容器本身就是一個寫在真實偏移量位置的普通間接串流物件。只有成員才會得到 type-2 條目。而且交互參照串流本身在檔案裡也是一個間接物件,因此它也需要自己的 type-1 條目,指向它剛剛被寫入的偏移量位置──也就是 startxref 所記錄的那個偏移量。我們寫入器的早期版本,在寫入迴圈中排除的是容器物件編號,而不是排除成員,結果寫出一份有交互參照串流、卻完全沒有任何物件串流的檔案:結構上前後一致,語意上卻空無一物,下游一律拒收。/Size 值也藏著相對應的差一錯誤(off-by-one),因為它等於最大物件編號加一,而交互參照串流本身也被分配了一個最大物件編號,因此也必須被計入

/W 陣列的大小設定:為何四個位元組並不夠用

/W 陣列宣告了這三個欄位各自的位元組寬度,losLab PDF Library 將其寫為 /W [1 Field2 Field3],其中欄位 1 固定為一個位元組,用來存放類型代碼;欄位 3 固定為兩個位元組,足以涵蓋最高到 65535 的世代編號,也同樣適用於成員索引。欄位 2 則無法固定為常數,因為它承載兩種互不相關的數量:在 type-1 條目中它是一個僅受檔案大小限制的位元組偏移量,在 type-2 條目中它是容器物件編號,在 type-0 條目中它則是鏈結中下一個空閒物件的編號。固定四個位元組的欄位 2 在檔案超過 4 GB 之前都運作良好,一旦超過這個界線,超出邊界的每個偏移量都會被悄悄截斷,整張表也隨之變成垃圾資料。因此,寫入器會掃描已組裝完成的表,找出欄位 2 各槽位可能出現的最大值(包括交互參照串流本身的偏移量),並將該欄位加寬至最多八個位元組

// Field 2 must hold the largest byte offset AND the largest
// ObjStm container number AND the largest free-chain target.
MaxField2Value := XRefStart;
for X := 0 to MaxObj do
begin
  if XRefTable[X].InUse and (XRefTable[X].ObjStrNum > 0) then
    Field2Value := XRefTable[X].ObjStrNum   // type-2: container number
  else
    Field2Value := XRefTable[X].ObjPos;     // type-1 offset / type-0 next-free
  if Field2Value > MaxField2Value then
    MaxField2Value := Field2Value;
end;

Field2 := 4;
while (Field2 < 8) and
      (MaxField2Value > ((Int64(1) shl (Field2 * 8)) - 1)) do
  Inc(Field2);
Field3 := 2;   // generation numbers and member indices both fit

一旦寬度確定,酬載大小也就能精確得知,因此寫入器會預先配置整個緩衝區,再依索引逐一填入;若是一個位元組接一個位元組地把條目附加到 AnsiString 上,會讓表的建構變成平方複雜度,這在一份十頁的發票上沒人會注意到,但在一份含有二十萬個物件的文件上,人人都會注意到。還有兩個細節能讓嚴謹的讀取端滿意。/Index 宣告了表所涵蓋的物件編號範圍,對於一次完整重寫而言,那就單純是 [0 N]、沒有任何缺口。此外,寫入器實際上沒有輸出的每個槽位,都必須預設為空閒而非使用中:物件 0 是空閒鏈的鏈首,每個空閒槽位都連結到下一個,而曾經存放已刪除物件的槽位則要把其世代編號加一。姊妹篇 解析不受信任 PDF 時的記憶體安全 從讀取端的角度提出了同樣的邊界論證

為何交互參照串流絕不能被加密?

因為讀取端必須先解析它,才能知道該如何解密其他任何東西。交互參照串流正是告訴讀取端 /Encrypt 字典位於何處的依據;如果它自己的位元組也被加密了,讀取端就得先有檔案金鑰,才能找到描述檔案金鑰的那個物件。losLab PDF Library 用一個單一的判斷式來強制執行這條規則:只要串流字典帶有 /Type /XRefShouldCryptStreamData 就會回傳 False,因此無論走哪一條路徑抵達序列化器,這項豁免都會成立

/ObjStm 容器則得到相反的待遇,這種不對稱是刻意設計的。容器會整體被加密,以自身的物件編號作為金鑰,做法與其他任何串流完全相同。其成員並不會個別加密──它們是以解密後的明文形式打包,之後對整個組裝完成的容器所做的一次加密處理,就會涵蓋所有成員,字串也包含在內。若對成員做雙重加密,會產生一份解密後仍是密文的檔案,而且由於外層加密看似成功,這個失敗會以物件圖深處的解析錯誤形式浮現,而不是以驗證失敗的形式出現。有一個物件因此完全被排除在此機制之外:在已加密的文件中,Catalog 會保持為直接的 type-1 物件、絕不打包,因為打包它會迫使載入器必須先解壓縮並解密一個物件串流才能抵達文件根節點,而此時根節點所協助建立的解密情境卻還沒完全就緒

從 Delphi 開啟打包功能

公開的開關是 PackObjectStreams,它以 TPDFlibSaveOptions 上的欄位、獨立的設定函式 SetPackObjectStreams,以及文件物件上的屬性等三種形式呈現。它預設為啟用,並依版本自動把關:寫入器只有在文件本身已是 PDF 1.5 或更新版本時才會打包,並會呼叫內部的最低版本檢查機制,確保被打包的文件會被升級標示為 1.5,而不是標示錯誤。儲存完成後,GetLastSaveUsedObjectStreams 會回報這道門檻是否真的開啟,這才是你在回歸測試中該用的斷言依據,而不是比較位元組大小

var
  Doc: TPDFlib;
  Options: TPDFlibSaveOptions;
begin
  Doc := TPDFlib.Create;
  try
    if Doc.LoadFromFile('report.pdf', '') <= 0 then
      Exit;

    Doc.SetInformation(0, '1.5');        // packing is gated on PDF 1.5+

    FillChar(Options, SizeOf(Options), 0);
    Options.CompressContent    := True;
    Options.GarbageCollect     := True;  // drop orphans before packing
    Options.PackObjectStreams  := True;

    if Doc.SaveToFileOptions('report-packed.pdf', Options) = 1 then
      if Doc.GetLastSaveUsedObjectStreams = 1 then
        Writeln('Saved with ObjStm containers and an xref stream');
  finally
    Doc.Free;
  end;
end;

打包與垃圾回收之間的先後順序很重要。可達性分析必須先執行,因為一個能存活進容器的成員,會把容器一併帶著存活──如果一個存活物件被打包,其容器編號依定義即為可達,把容器清掃掉會讓該成員無處可尋。先執行收集器,也意味著死物件根本不會進入容器,這正是複合式檔案縮小效益的來源。打包功能是對其他縮檔手段的補充,而非取代;PDF 檔案大小最佳化與字型子集化 一文所涵蓋的是作用於串流酬載的手段,而物件串流則是作用於結構層面

啟用前值得了解的邊界限制

增量儲存絕不打包。增量更新會附加新物件與新的交互參照區段,同時讓較早的修訂版本在實體上保持原封不動,所以把既有物件重新打包進全新容器,會讓前一個修訂版本仍在參照的 type-1 條目變成孤兒;只要處於附加模式,losLab PDF Library 就會停用打包功能,增量更新與附加模式串流 一文完整涵蓋了這條路徑。低於 PDF 1.5 的文件則無條件保留純文字交互參照表:一個 1.4 版的消費端根本不知道 /ObjStm 是什麼,僅僅因為寫入器偏好較小的檔案就悄悄把文件升級版本,等於代替呼叫端做了一個錯誤的取捨。我們刻意不輸出的一個選用鍵是 /Extends,ISO 32000-1 §7.5.7 定義這個鍵是為了讓一個容器能指名其前驅容器,讓讀取端可以把一串容器視為一個邏輯群組。它確實是選用的,我們寫出的每個容器都是自成一體、可獨立解碼的,省略它也讓寫入器免去了一整類循環與懸空參照錯誤──當然,讀取端在其他產生器所產出的檔案中遇到 /Extends 時,仍必須遵循它

物件串流打包與交互參照串流輸出功能,隨附於 losLab PDF Library(適用於 Delphi 與 C++Builder)之中,並與它們所搭配的垃圾回收器及內容串流最佳化工具一同出貨;產品頁面提供完整的儲存選項參考文件