技術文章

Delphi PDF 檔案大小稽核:按分類拆解位元組

為了找出 PDF 檔案大小究竟花在哪裡,losLab PDF Library 提供 AuditDocumentSpace,它把每個間接物件分類進十二個類別——影像、字型程式、字型字典、內容串流、表單 XObject、物件串流、內嵌檔案、中繼資料、結構樹、註解、頁樹、其他——並回報每個類別的物件數量、儲存位元組數與百分比佔比

這個功能存在的情境並不陌生。一份 40 頁的報告從你的產生器輸出後是 80 MB,客戶問為什麼,你卻只能給個猜測。可能是影像。也許是字型。於是你打開降取樣,出貨,結果檔案落到 74 MB,因為真正的重量根本在別的地方。我們的字型子集化與影像降取樣姊妹篇談的是如何縮小 PDF;這一篇談的則是應該先做的那一步,也就是量測你即將要縮小的東西

為何要先量測再壓縮?

因為三種標準最佳化手法在任何給定檔案上的回報天差地遠,而檔案本身在你數過之前不會告訴你哪一種適用。對一份字型只佔 2% 位元組的文件做字型子集化,等於花一個下午移動一個四捨五入誤差。對一份主要重量是未壓縮內容串流的檔案做影像降取樣,也會得到同樣令人失望的結果。最佳化器不是難的部分——每套元件庫都有一個。知道該把哪個最佳化器對準哪份檔案才是難的部分,而那是一個計數問題,不是壓縮問題。稽核也能抓出「不需要任何最佳化器」的情況:一份結果是 60% 內嵌附件的檔案不需要更好的壓縮,需要的是討論這些附件是否該放進文件裡;一份 30% 是結構樹的檔案,付出的是無障礙標記的成本,這通常是刻意為之、不該悄悄剝除的成本。一旦位元組被歸屬清楚,你做的就是有數字支撐的產品決策,而不是伸手抓最順手的開關

十二分類報告裡有什麼

AuditDocumentSpace 回傳的是字串清單控制代碼而非一筆記錄,讓報告能原封不動地穿過扁平 DLL 與 COM 外觀。清單開頭是一行 Total,Objects,Bytes,100.0 摘要行,接著固定順序恰好十二行 Category,Objects,Bytes,Percent,這個順序是合約的一部分:Images、Font programs、Font dictionaries、Content streams、Form XObjects、Object streams、Embedded files、Metadata、Structure tree、Annotations、Page tree、Other。永遠十三行,即使某個類別是空的也一樣

var
  Lib: TPDFlib;
  ListID, I: Integer;
begin
  Lib := TPDFlib.Create;
  try
    if Lib.LoadFromFile('report.pdf', '') <> 1 then
      Exit;
    ListID := Lib.AuditDocumentSpace;   // 0 when no document is selected
    if ListID = 0 then
      Exit;
    try
      // GetStringListItem is 1-based: items run 1..GetStringListCount
      for I := 1 to Lib.GetStringListCount(ListID) do
        Memo1.Lines.Add(Lib.GetStringListItem(ListID, I));
    finally
      Lib.ReleaseStringList(ListID);
    end;
  finally
    Lib.Free;
  end;
end;

那個迴圈裡有個 Delphi 細節會咬你剛好一次。GetStringListItem 使用以 1 為起點的項目索引,與 GetStringListCount 一致,超出範圍的索引會回傳空字串而不是丟出例外。若習慣性寫成 for I := 0 to Count - 1,你會得到空白的第一行、悄悄消失的最後一行,而且不會有任何例外告訴你索引錯了。報告本身看起來會幾乎正確,這正是一個診斷工具最糟糕的失效方式

為何稽核使用儲存長度而非解碼後大小?

因為儲存長度既是你想要的數字,也是取得成本低廉的數字。每個間接物件都帶有 TPDFIndObj.FLength,也就是解析時該物件在檔案裡佔用的原始位元組長度。用它意味著一張 900 KB 的 DCTDecode 影像會被回報成 900 KB——那正是它在磁碟上的成本——而不是它解碼出來的 40 MB RGB 樣本。這也代表稽核完全不需要解碼任何東西:延遲載入的物件維持延遲,濾鏡維持未執行,稽核一份 500 MB 的檔案只是一次物件標頭掃描,而不是一整輪解壓縮

第二條規則是雙重計數的防禦。當一個物件位於壓縮的物件串流內部時(以非零的 FObjStrNum 表示),它的位元組數會記為零。它的儲存成本已經由容器串流付過一次,ISO 32000-1 §7.5.7 把容器串流定義為一個 /Type /ObjStm 串流,內含許多物件、以一個 Flate 壓縮的酬載打包。若對每個成員各記一份,再把容器再記一次,總數就會超出檔案的真實大小。這對你如何解讀輸出結果有直接影響,下文會提到,我們談物件串流與交叉參照串流的文章也有更深入的說明

為何字型程式無法自我分類?

因為內嵌在 PDF 裡的 TrueType 字型檔案沒有任何標記能說明自己是什麼。ISO 32000-1 §9.8.1 把內嵌字型程式定義為字型描述器裡 /FontFile/FontFile2/FontFile3 的值,而參照另一端的串流字典帶有 /Length1 與濾鏡鍵,卻沒有 /Type,也沒有能識別它是字型的 /Subtype。單獨看它就是個匿名二進位串流。只有指向它的描述器知道它是什麼。同樣的不對稱也出現在註解上:§12.5.2 讓 /Type /Annot 在註解字典裡成為選填,所以可靠的訊號是它是否屬於某頁的 /Annots 陣列成員,而不是字典本身

因此分類要跑兩輪。第一輪讀取每個物件自己的 /Type/Subtype,拿下容易分辨的:/ObjStm/Subtype /Image/Subtype /Form/Type /Font/Type /FontDescriptor/Metadata/EmbeddedFile/Filespec/StructTreeRoot/StructElem/Annot/Page/Pages。其餘的暫時歸進 Other。第二輪則走訪參照方並覆寫分類:每個頁面字典把自己的 /Contents 重新指定為內容串流,把 /Annots 項目重新指定為註解,把 /Thumb 重新指定為影像,而每個字型字典則走訪自己的描述器鏈

// Shape of the second pass: the referrer names the object
Descriptor := DictOf(FontDict.FindValueByKeyName('FontDescriptor'));
if Assigned(Descriptor) then
begin
  MarkRef(FontDict.FindValueByKeyName('FontDescriptor'), catFontDicts);
  MarkRef(Descriptor.FindValueByKeyName('FontFile'),  catFontPrograms);
  MarkRef(Descriptor.FindValueByKeyName('FontFile2'), catFontPrograms);
  MarkRef(Descriptor.FindValueByKeyName('FontFile3'), catFontPrograms);
end;
// Type0 fonts keep the descriptor one level down
Descendants := FontDict.FindValueByKeyName('DescendantFonts', True);
if (Descendants is TPDFArray) and (TPDFArray(Descendants).Count > 0) then
  MarkFontProgramRefs(DictOf(TPDFArray(Descendants).Item[0]));

讀懂報告、挑出下一步

先看佔比,再看物件數,兩者間任何大幅落差都要當成訊號。現代 PDF 大多把小型字典放進物件串流裡,所以 Page tree 與 Structure tree 常常顯示出數十個物件卻幾乎沒有位元組——它們真正的成本已經折進 Object streams 那一行。若 Object streams 本身就很大,代表這份檔案密密麻麻都是類中繼資料的結構,而不是內容,該用的手段是修剪物件,而不是壓縮。註解外觀串流也是類似情形:它們帶有 /Subtype /Form,所以一份大量蓋章的文件,重量會顯示在 Form XObjects,而 Annotations 那一行維持很小

function CategoryShare(Lib: TPDFlib; ListID: Integer;
  const Category: string): Double;
var
  I: Integer;
  Parts: TArray<string>;
  Inv: TFormatSettings;
begin
  Result := 0;
  Inv := FormatSettings;
  Inv.DecimalSeparator := '.';   // the report is locale-independent
  for I := 2 to Lib.GetStringListCount(ListID) do   // line 1 is Total
  begin
    Parts := string(Lib.GetStringListItem(ListID, I)).Split([',']);
    if (Length(Parts) = 4) and SameText(Parts[0], Category) then
      Exit(StrToFloatDef(Parts[3], 0, Inv));
  end;
end;

若你要自行剖析百分比而非顯示它,有兩個格式化事實要注意。小數點分隔符號永遠是字面上的句點,與機器地區設定無關,所以在德文或法文工作站上用環境的 FormatSettings 剖析會失敗,或更糟,讀出錯誤結果。而且尾端的零會被裁掉,所以一個恰好佔 40% 位元組的類別會印成 40,而不是 40.0——不要假設小數位數固定。拿到佔比之後,路由就很機械化了:Images 佔比高就指向 DownsampleImages,Font programs 佔比高就指向 SubsetEmbeddedFonts,Content streams 龐大則指向 CompressContent

稽核刻意不會告訴你的事

總計是間接物件的加總,而一份 PDF 檔案略多於它的物件。檔案標頭、trailer、物件間空白,以及傳統的交叉參照表都不是間接物件,所以那些位元組不會被歸屬到任何東西,稽核總計會略低於磁碟上的實際大小。交叉參照串流則不同——它是帶有 /Type /XRef 的真實物件,所以在現代檔案裡那些位元組會出現在 Other 類別。這兩種行為都不是缺陷,但如果你要用檔案系統回報的位元組數去核對稽核結果,落差就是從這裡來的

還有兩個邊界值得明講。首先,這些數字描述的是已載入的檔案,而不是正在編寫中的檔案:對於還沒有儲存長度的記憶體內建立物件,大小會退回序列化輸出,並對串流字典給一個名目配額,這是對最終寫入結果的估計,而不是量測。若想要精確數字,請在儲存並重新載入後再稽核。第二,一行肥大的 Other 是一項發現,不是錯誤回報——它通常代表已無任何東西參照的孤兒物件,那該交給標記清除式垃圾回收來處理,而不是任何壓縮手段

這樣使用之後,稽核改變了對話的形狀。與其對著 80 MB 的報告猜測,你打開它、跑一次呼叫,讀到影像佔 8%、字型程式佔 61%,而這份文件為了一套只用三種字體的公司風格,內嵌了九套完整的字型程式。這是一個附帶數字的、可修的答案。AuditDocumentSpace 連同它指向的最佳化手法,都隨附於 losLab PDF Library for Delphi and C++Builder,其參考頁面記載了完整的分類清單與周邊的字串清單 API