技術文章

在 Delphi 中用 PDFiumPas 做稀疏延遲 PDF 物件索引

您只想要從一份 2 GB 的 PDF 取出一個字典,工具卻先把整張交叉參照表展開成一個依尾頁物件 /Size 決定大小的陣列。PDFiumPas 以稀疏延遲物件索引取代該步驟:它只保留 xref 區段描述元,透過有界視窗依需求解析單一物件編號,並只快取您真正碰過的條目

這段程式碼在 FPdfCompress 中的舊形貌誠實但昂貴。ApplyDefaultOpenAction 把整個檔案讀進一個 TBytes,然後配置一個稠密的 TPdfActiveXrefEntries 陣列,一路到 /Size 為止、每個物件編號一個槽。在規模放大時有兩件事出錯。即使呼叫端只想要四個字典,讀取成本仍隨文件大小線性成長;而稠密陣列與剖析器預算相撞:TPdfParserResourceBudget.DefaultMaxObjects 設為 4,000,000,所以一份完全有效、最高物件編號超過該天花板的檔案,會因記憶體理由而非正確性理由被拒絕

Delphi 中的 PDFiumPas 稀疏延遲物件索引與稠密交叉參照陣列的比較:稠密路徑讀取整個檔案,並一路到尾頁物件大小為止、每個物件編號配置一個槽;稀疏路徑只保留區段描述元
只有描述元留在記憶體,條目留在檔案中,每次讀取都穿過一個有界的一 MiB 視窗

為什麼 PDFium 公開 API 不回答這個問題?

因為資訊存在於 PDFium 內部,卻從不跨越 C 邊界。CPDF_Parser 在內部維護交叉參照表、物件串流成員資格與修訂優先順序,但公開的標頭沒有暴露任何進入點,能接受物件編號並傳回其原始位移、世代、哪個修訂勝出,或它住在哪個 ObjStm。儲存側同樣封閉:FPDF_SaveAsCopyFPDF_SaveWithVersion 只交給您一個循序寫入回呼。因此,原生儲存之後對目錄做的任何位元組層級修補,都必須在 Pascal 層建構——這正是 PDFiumPas 自己剖析這些結構、而不是重用 DLL 的原因

稀疏索引實際在記憶體中保留什麼?

描述元,不是條目。對於傳統表格(ISO 32000-1 §7.5.4),TPdfSparseXrefSubsection 儲存第一個物件編號、物件數、條目列開始的位元組位移,以及量測出的條目寬度。條目本身留在檔案中。寬度是從第一列量測,而不是假定為 20 位元組,因為生產者對行尾的意見不一致;PDFiumPas 接受 18 到 64,拒絕該帶之外的任何值,連同任何「宣告數會越過串流終點」的子區段。對於交叉參照串流(§7.5.8),區段持有三個 /W 欄位寬度(每個限制在 0 到 8)、攤平的 /Index 配對,以及解碼後的條目位元組——其預期長度在任何一個位元組被解壓縮之前,就先從 /W/Index 算出

整個索引由 Initialize 從一個至多 1 MiB 的尾端視窗建立——startxref 就是在那裡找到的——之後每次物件讀取都使用一個 1 MiB 的物件視窗。原始串流天花板是 64 MiB,單一 xref 列不得超過 1024 位元組。如果您讀過我們關於用 PDFiumPas 驗證物件與交叉參照串流的筆記,同樣的欄位寬度紀律在此適用,只不過現在它用來定址一個條目,而不是審查整張表

uses
  FPdfCompress;

var
  Source: TFileStream;
  Revision: TPdfSparseRevisionInfo;
begin
  Source := TFileStream.Create(FileName, fmOpenRead or fmShareDenyWrite);
  try
    { 只走訪 startxref、/Prev 鏈與目錄 }
    if ReadPdfSparseRevisionInfo(Source, Revision) then
    begin
      Writeln('root      ', Revision.RootObjectNumber, ' ',
        Revision.RootGeneration);
      Writeln('max obj   ', Revision.MaximumObjectNumber);
      Writeln('xref str  ', Revision.UsesXrefStream);
      Writeln('encrypted ', Revision.HasEncrypt);
      Writeln(string(Revision.CatalogDictionary));
    end;
  finally
    Source.Free;
  end;
end;

一次查詢如何抵達一個物件?

靠算術,兩種版面皆然。傳統子區段有固定寬度的列,所以條目位址是子區段起點加上「物件偏移乘以量測寬度」;PDFiumPas 接著讀取那一列,解析十位數位移與五位數世代,把世代對照 §7.5.4 的 65535 天花板檢查,並把結尾關鍵字分類為 axkDirectaxkFree。交叉參照串流需要多一步,因為 /Index 子區段在解碼後的位元組連串中相接,所以索引在乘以加總後的 /W 寬度之前,會先累積前面子區段的數量。類型 1 產生位移,類型 2 產生物件串流編號與成員索引,其他任何東西都成為 axkUnknown,而不是一個猜測

{ 傳統表格,ISO 32000-1 第 7.5.4 節 }
EntryOffset := Subsection.EntryOffset +
  Int64(ObjectNumber - Subsection.FirstObject) * Subsection.EntryWidth;

{ 交叉參照串流,ISO 32000-1 第 7.5.8 節 }
EntryWidth := Section.Widths[0] + Section.Widths[1] + Section.Widths[2];
EntryPosition := Integer((PriorCount + ObjectNumber -
  Section.IndexValues[I]) * EntryWidth);

這兩條路徑中沒有任何東西與 /Size 成正比。這正是重寫的全部意義:尾頁物件的大小值做為中繼資料向前攜帶,並在寫入增量修訂時使用,但它絕不驅動配置。回歸測試套裝用一個夾具釘住這點:其頁面樹位於物件 1,000,000,000 與 1,000,000,001,尾頁物件卻宣告 /Size 1000000002。舊的稠密實作拒絕該檔案;稀疏索引能解析這兩個參照,並在輸出尾頁物件中保留宣告的大小

PDFiumPas 在 Delphi 中如何解析單一物件編號:傳統交叉參照表乘以量測出的列寬;交叉參照串流則在乘以 /W 陣列加總欄寬之前,先累積前面子區段的數量
兩種查詢都是純算術,所以都不與尾頁物件宣告的物件數成正比

混合修訂、/Prev 鏈,以及圍繞它們的防護

修訂優先順序,是天真的延遲索引出錯之處。PDFiumPas 從 startxref 以最新優先的順序走訪該鏈,並在第一個回答的區段停止查詢——這重現了優先順序規則,卻不必實體化一張合併表。混合參照檔案(§7.5.8.4)在傳統分支內處理:當尾頁物件帶有 /XRefStm,補充串流區段會先於「參照它的那個傳統區段」註冊,所以純表格看不到的壓縮物件仍會被找到,而傳統條目保有它們的位階。更舊的修訂接著透過 /Prev 追蹤

兩道防護為該走訪設界,在受損檔案上兩者都要緊。每個拜訪過的位移都會記錄,所以指回鏈中某處的 /Prev 會終止,而不是空轉;走訪深度由 MaxRecursionDepth 封頂,預設為 1024。加密旗標是跨整條鏈累積,而不是只從最新尾頁物件讀取,因為最新尾頁物件省略 /Encrypt 的文件,在更早處仍可能是加密的;追加修訂的呼叫端依賴該旗標,拒絕把明文物件寫進加密檔案

PDFiumPas 在 Delphi 中如何走訪混合 PDF 修訂鏈:區段從 startxref 起以最新優先註冊,補充的 XRefStm 區段排在點名它的傳統表格之前,/Prev 走訪則由已拜訪位移與深度上限設界
查詢在第一個回答的區段停止,這重現了修訂優先順序,卻從不實體化合併表

類型 2 條目:物件串流為何等待

類型 2 條目點名一個物件串流,而 PDFiumPas 在呼叫端要求其中某個成員之前,絕不碰觸該串流。終於動手時,它會驗證 /Type /ObjStm,把 /N 對照物件預算檢查、把 /First 對照解碼位元組天花板檢查,並把 /N/First 做健全性檢查——因為每個標頭配對至少需要四個位元組。只有在那之後串流才會被解壓縮,而標頭掃描在被要求的成員及其下一個成員處停止,而不是建立完整成員表。同一時間只保留一個解碼後的物件串流;當頁面樹分支聚集成單一 ObjStm 時,這是正確的取捨。我們關於Delphi 中的物件串流與預測器解碼的文章,涵蓋該解壓縮步驟內部發生的事(§7.5.7)

var
  Reader: TPdfSparseDictionaryReader;
  Generation: Integer;
  Dict: AnsiString;
begin
  { 一個保留的索引,多次感知世代的讀取 }
  Reader := TPdfSparseDictionaryReader.Create(Source);
  try
    if Reader.Valid and
       Reader.ReadLatestDictionary(PageObjectNumber, Generation, Dict) then
      HandlePage(PageObjectNumber, Generation, Dict);
  finally
    Reader.Free;  { Source 仍歸你所有 }
  end;
end;

快取停止承諾之處

索引是一份快照,這件事值得直說。區段在 Initialize 中剖析一次;若底層串流事後被修改,每個快取條目都會過時,而類別不會察覺。TPdfSparseDictionaryReader 在「來源由呼叫端擁有的生命週期」內持有索引——這正是對頁面樹做遞迴走訪想要的,也正是您在重寫前後絕不能做的。條目快取是一個以線性搜尋的平坦陣列,而且它也儲存否定結果,所以幾百次查詢很便宜,幾十萬次則不然。ReadDictionary 要求確切的世代相符,ReadLatestDictionary 則解析作用中的那一個;這個差異是刻意的:參照解析需要前者,目錄檢查需要後者。在這些界線無法被遵守的地方,外圍單元會退回舊式的整檔剖析器,而不是縮減「仍能運作的檔案」的集合——我們在大型 PDF 的隨需串流中也使用同樣的模式

跨編譯器回歸測試在全部三個工具鏈上涵蓋相同行為,包括一個斷言:2 MiB 的來源絕不會見到大於 1 MiB 的單次讀取。如果您維護直接碰觸 PDF 結構的 Delphi、C++Builder 或 Lazarus 程式碼,而且已經厭倦為了四個字典支付整檔剖析成本,稀疏索引與圍繞它的公開接縫,就隨 PDFiumPas Delphi PDFium 元件出貨