技術文章

Form XObject 遞迴:Delphi 中的 PDFlibPas 迴圈偵測

PDFlibPas 會透過追蹤目前的呼叫鏈,而不是全域已造訪集合,解析 Delphi PDF 內容串流中的遞迴 Form XObject 呼叫,因此 TPDFlib.EnumPageContentStatesEx 能在同一頁多次走訪同一個 Form,而不會把合法重用誤判為迴圈。發票範本中的印章 Form XObject 是典型案例:同一個物件會在同一頁由頁首、頁尾與浮水印圖層呼叫,而只有回到自身的呼叫鏈才是真正的迴圈

ISO 32000-1 §8.10 將 Form XObject 定義為自包含的內容串流,頁面或另一個 Form 會使用 Do 運算子呼叫它;它在 /Matrix 中擁有自己的座標系統,在該座標系統的 /BBox 中擁有裁切邊界,也可以選擇擁有自己的資源字典。規格沒有上限限制同一個 Form 可被呼叫的次數,也沒有規定 Form 彼此呼叫時的深度,因此符合規格的解析器必須接受合法重用與合法巢狀,同時防禦規格明確禁止的唯一安排:某個 Form 的內容串流直接或傳遞地呼叫自己。PDFlibPas 會透過附加到每個 Do 快照的 TPDFlibContentFormTraversalStatus 值回報這項區別,其中最重要的是成功向下走訪的 ftsEnumerated,以及真正形成迴圈時的 ftsCycle

為什麼重複使用相同的 Form XObject 不會觸發誤判迴圈

重複的 Form XObject 參照本身並不代表任何錯誤。ISO 32000-1 允許同一個 Form 物件在內容串流中由作者想要的任意數量位置呼叫,這正是標誌印章、信頭範本或頁碼頁尾能在頁面上重複使用,而不必多次複製內容串流的方式。防止遞迴失控的直覺防護,是使用以物件編號為鍵的單一已造訪集合:走訪器第一次看到 Form 物件 12 時,會將 12 標記為已看過,並拒絕在樹狀結構的其他地方再次進入。當同一個印章出現在同一頁兩個互不相關的角落時,這種方法就會失效,因為第二次完全合法的呼叫發生在物件編號已被標記之後,結果被當成迴圈而拒絕

PDFlibPas 將迴圈偵測限定在目前的呼叫鏈,而不是整份文件,以避免這種誤判。EnumPageContentStatesEx 會在向下走訪前立即將解析出的 Form 串流推入目前的呼叫鏈,並在走訪返回的瞬間,無論成功與否,都將同一個項目彈出。相同串流的同層呼叫只有在前一個呼叫已經彈出後才會開始,因此同層呼叫檢查時,該串流已不在呼叫鏈中,走訪器會像處理其他 Form 一樣完整列舉它。真正的迴圈在同一條鏈上則不同:Form A 呼叫 Form B,B 仍在鏈上時,其內容又回頭呼叫 A,而 A 仍因外層尚未返回而留在鏈上——這是 ftsCycle 回報的唯一形狀,也就是目前呼叫鏈較早位置仍開啟的 Form 串流,而不是僅僅存在於頁面其他地方的串流

Form XObject 遞迴可以深入到什麼程度才會被 PDFlibPas 停止

迴圈偵測與深度限制解決的是兩個不同問題,PDFlibPas 也因此將它們保留為兩種不同的 TPDFlibContentFormTraversalStatus 結果。由二十個各不相同的 Form 組成、每個 Form 呼叫下一個且沒有任何重複的鏈,無論如何定義都不是迴圈——目前鏈檢查不會找到重複串流——但二十層真實的巢狀仍代表二十層解析、矩陣串接與資源解析,格式錯誤或惡意的 PDF 可能在沒有其他限制時任意推高這個數值。EnumPageContentStatesEx 正是為此接受 MaxFormDepth 參數,並將傳入的值限制在最多 64,不論呼叫端要求什麼值。深度為零是值得單獨注意的特殊情況:它會完全停用 Form 遞迴,重現舊版 EnumPageContentStates 方法僅處理頁面的行為,因此該模式中的每個 Do 快照都會回報 ftsNotRequested,而不會嘗試任何處理

var
  Lib: TPDFlib;
  States: array of TPDFlibContentGraphicsState;
  Count, I: Integer;
begin
  Lib:= TPDFlib.Create;
  try
    if Lib.LoadFromFile('invoice-batch.pdf', '')<> 1 then
      Exit;
    Lib.SelectPage(1);
    Count:= Lib.EnumPageContentStatesEx(True, 8, States);  // count only
    SetLength(States, Count);
    Lib.EnumPageContentStatesEx(True, 8, States);          // fill
    for I:= 0 to Count- 1 do
      if States[I].FormTraversalStatus= ftsCycle then
        LogSuspectForm(States[I].XObjectResource, States[I].ContentDepth);
  finally
    Lib.Free;
  end;
end;

每次呼叫各自使用一個子追蹤器:隔離圖形狀態

每次進入 Form XObject 都會取得自己的圖形狀態追蹤器,而不是共用原本走訪頁面的追蹤器,因為 Form 的內容串流必須讓圖形狀態完全恢復到進入時的樣子,而 PDFlibPas 不能假定它開啟的每個 PDF 都確實遵守這項要求。子追蹤器會從呼叫端 Do 指令當下有效的 CTM、色彩狀態與文字參數快照開始,接著在執行 Form 的單一指令前,將自己的儲存與還原堆疊以及目前路徑追蹤重設為空。在粗心或損壞的 Form 內,沒有對應 Q 的不平衡 q 並不罕見於舊工具產生的 PDF,會被限制在該次呼叫的追蹤器內,不會洩漏到頁面追蹤器,也不會洩漏到內容串流下一行相同印章的同層呼叫

Form /Matrix 會像 cm 運算子一樣,與 Do 當下生效的 CTM 組合,對目前轉換進行左乘,而不是取代它;PDFlibPas 刻意重用同一條程式碼路徑,而不是維護第二套公式,因為同一套矩陣代數的兩個獨立實作,正是經過幾輪縮放、旋轉與傾斜組合後會悄悄產生偏差的重複。/BBox 接著會在矩陣已套用後,於 Form 自己的座標空間中進行裁切;邊界框的四個角也會個別轉換,而不是只轉換對角的兩個角,因為旋轉或傾斜的 Form 否則可能回報遺漏實際內容的邊界框:內容原本位於極端角落,轉換後卻被移到其他位置。沿用前一個範例擴充同一個 States 陣列,就能直接讀取這些欄位

for I:= 0 to Count- 1 do
  if (States[I].OperatorName= 'Do')and (States[I].XObjectKind= cxkForm)and
    States[I].FormBBoxKnown then
    Writeln('Form ', States[I].XObjectResource, ' matrix ',
      States[I].FormMatrix.M11:0:3, ',', States[I].FormMatrix.M12:0:3,
      ' bbox ', States[I].FormBBoxLeft:0:1, '..', States[I].FormBBoxRight:0:1);

兩個具有相同資源名稱的 Form 是否共用同一個字型

不會。像 /F1 這樣的資源名稱,只在使用它時有效的資源字典範圍內才有意義,而兩個不同的 Form XObject 可以在相同名稱下定義兩個完全不同的字型。PDFlibPas 會在每個資源名稱旁追蹤資源範圍來解析這種情況:當 Form 帶有自己的 /Resources 字典時,該字典會成為其中所有內容的完整資源範圍,不會因 Form 自己的字典恰好缺少某個項目,而按鍵回退到頁面或呼叫端字典。只有完全沒有 /Resources 索引鍵的 Form,這是某些較舊 PDF 產生器仍會產生的模式,才會完整繼承呼叫端字典;這是刻意保留的相容性例外,不是新輸出值得依賴的一般規則。因此 TPDFlibContentGraphicsState 快照中的字型身分是 FontResourceFontResourceScope 的配對,而不只是名稱;FontObjectNumber 則可用來確認特定 /F1 在該資源範圍中解析到哪一個間接物件

同樣的範圍限定也適用於 Form 可攜帶的每個其他具名資源,包括 ExtGState 項目與巢狀 XObject 項目,因為底層解析機制不會特別處理字型——字型案例只是最重要,因為不相符的字型身分會靜默產生錯誤字形,而不是明顯失敗。若擷取程式只依字型名稱分組文字執行,而沒有同時依資源範圍分組,就會將兩種視覺上不同、碰巧共用名稱的字型合併;直到有人發現本應一致的字型中出現錯誤字體的數字之前,這個錯誤都不會浮現

for I:= 0 to Count- 1 do
  if (States[I].OperatorName= 'Tj')and States[I].TextAdvanceResolved then
    RecordGlyphRun(States[I].FontResource, States[I].FontResourceScope,
      States[I].FontObjectNumber, States[I].ContentDepth);

在自己的處理流程中讀取 FormTraversalStatus

FormTraversalStatus 會讓每個 Do 快照本身成為一份小型診斷報告,而忽略它的處理流程,等於丟掉能解釋擷取不完整的關鍵資訊。ftsNotApplicable 表示該指令根本不是已解析的 Form 呼叫;ftsNotRequested 表示這次呼叫已關閉遞迴;ftsEnumerated 表示 Form 已成功解析並走訪;ftsDepthLimitftsCycle 標示刻意截斷走訪的兩種方式;ftsMalformed 則涵蓋其他所有使走訪停止的情況——無法解析的串流參照、解析 /Matrix/BBox 失敗,或執行 Form 自己的內容時擲出的例外。最後一種情況在實務上很重要,因為失敗的巢狀走訪會回滾該分支已產生的部分輸出,因此呼叫端不必猜測 Form 是確實為空,還是在內容的兩個指令之後才發生錯誤

var
  Tally: array[TPDFlibContentFormTraversalStatus] of Integer;
  Status: TPDFlibContentFormTraversalStatus;
begin
  for Status:= Low(Tally) to High(Tally) do
    Tally[Status]:= 0;
  for I:= 0 to Count- 1 do
    Inc(Tally[States[I].FormTraversalStatus]);
  if (Tally[ftsCycle]> 0)or (Tally[ftsMalformed]> 0) then
    FlagForManualReview(SourceFileName, Tally[ftsCycle], Tally[ftsMalformed]);
end;

界線、成本,以及它適用的位置

每次列舉呼叫中,Form 的內容串流只會被解碼與解析一次,不論 Form 被呼叫多少次,因為 PDFlibPas 會將解析後的指令清單快取在底層串流物件上,而不是每次同層呼叫都重新解析——開頭範例中的三角印章會解碼一次並走訪三次,而不是解碼三次。每次呼叫都會重建的,是不同呼叫位置之間真正會不同的內容:子追蹤器、串接後的 CTM、交集裁切區域與資源範圍。每次呼叫的 CTM 與裁切區域記錄,正是PDFlibPas 內容串流 CTM 與裁切狀態追蹤器所使用的同一套機制;對於超出 Form 遞迴本身的內容串流走訪,值得與本文一併閱讀

在將此 API 放入更大型的處理流程前,有兩項限制值得先建立預期。64 層深度上限不是為合法深層文件準備的調整旋鈕,因為真實的發票、對帳單與報表範本幾乎從不超過三或四層 Form 巢狀;實際觸及 ftsDepthLimit 的文件,比起格外複雜,更可能是格式錯誤或惡意文件,值得記錄為資料品質訊號,而不是悄悄用更大的數字重試。EnumPageContentStatesEx 也是讀取端分析 API:它回報內容串流做了什麼,而不判斷 Form 是否應該可見;當印章或浮水印 Form 位於檢視器可能切換關閉的圖層後方時,這是由選用內容群組可見性狀態回答的另一個問題。呼叫鏈迴圈偵測、每次呼叫隔離與資源範圍限定,共同構成 Delphi 與 C++Builder 使用的PDFlibPas元件內容串流檢查介面的一個部分