技術文章

在 Delphi 中安全改寫 PDF 內容串流

HotPDF 會走訪某一頁內容串流中的每個運算子,並透過 VisitLoadedPageContentOperators 把每一個交給你的程式碼處理。針對每個運算子,你可以選擇一項動作:保留它、刪除它、以你自己的位元組取代它、停止走訪,或拒絕整次編輯。當每個運算子都被保留時,改寫後的串流會與原始串流逐位元組完全相同,正是這項特性,讓這套機制能安全地套用在非你自己建立的文件上

內容串流編輯正是 PDF 工具最容易出錯的地方。串流是堆疊機器上的一連串運算子,而不是文件樹,對已解碼位元組直接套用正規表示式的天真做法,一旦字串字面量中出現看起來像運算子的內容,或內嵌影像中出現任意二進位資料,就會立刻失效。已經把串流權杖化的走訪器不會有這個問題

示意圖:BT、Tf、TJ 等 PDF 內容串流運算子流入走訪器事件資料,附帶階段、運算子名稱、運算元數量、位元組範圍與巢狀狀態,再回傳五種動作之一:保留、取代、刪除、停止或拒絕
每個被走訪的運算子都恰好產生一項決定,全部保留的走訪會把未改動範圍原封不動寫回,得到逐位元組相同的輸出

有了運算子層級的存取能力,實際能做什麼?

三大類任務。稽核:計算頁面在已知色彩集合之外設定顏色的次數,或找出使用未內嵌字型繪製的文字。移除:刪除以標記內容形式繪製的浮水印,或剝除特定的影像呼叫。以及正規化:把某種運算子形式替換成下游消費端能更好處理的等效形式

這些任務都有一個共同要求:必須能讓串流其餘 99% 的內容保持不動。HotPDF 的語法樹只保存一份原始碼加上緊湊的範圍記錄,編輯則是排序過、互不重疊的覆蓋層。寫出結果時,未改動的範圍會直接複製,只有在你真正變動的地方才會產出替換位元組,因此一次原封不動的往返過程不會引入任何漂移

走訪器約定

實作介面或指定事件,接著呼叫走訪方法,稽核時將 ApplyChanges 設為 false,改寫時設為 true。事件資料會攜帶目前階段、運算子名稱、其運算元數量、其位元組範圍、目前的巢狀狀態,以及一個用於讀取運算元的借用檢視:

uses
  HPDFDoc, HPDFContentStream;

procedure TAuditor.HandleOperator(Sender: TObject;
  var EventData: THPDFContentOperatorEventData);
var
  Bytes: TBytes;
begin
  if EventData.Phase <> covpOperator then
    Exit;

  // 計算標記內容內繪製文字的 operator
  if (EventData.OperatorName = 'Tj') or
     (EventData.OperatorName = 'TJ') then
  begin
    Inc(FTextOperators);
    if EventData.State.MarkedContentDepth > 0 then
      Inc(FTextInsideMarkedContent);
  end;

  // 只有需要時才讀取 operand
  if EventData.OperatorName = 'Do' then
    if EventData.View.GetOperandBytes(0, Bytes) then
      NoteXObjectUse(TEncoding.ANSI.GetString(Bytes));

  EventData.Action := coaKeep;
end;

procedure TAuditor.Run(const FileName: string);
var
  Pdf: THotPDF;
  Stats: THPDFContentOperatorVisitStatistics;
  I: Integer;
begin
  Pdf := THotPDF.Create(nil);
  try
    if Pdf.LoadFromFile(FileName) <= 0 then
      Exit;
    Pdf.OnContentOperator := HandleOperator;
    for I := 0 to Pdf.LoadedPageCount - 1 do
      Pdf.VisitLoadedPageContentOperators(I, False, Stats);   // audit only
    Writeln(Format('%d operators visited, max q/Q depth %d',
      [Stats.VisitedOperatorCount, Stats.MaximumGraphicsStateDepth]));
  finally
    Pdf.Free;
  end;
end;

為何檢視是借用的,而非可變的?

回呼函式從不會拿到一棵可修改的語法樹。它拿到的是一個檢視,可以依需求取得目前運算的位元組或某個特定運算元,僅此而已。所有變更都必須透過動作欄位與替換欄位進行

這層間接並不是形式上的多餘。若允許直接修改樹狀結構,走訪器就能繞過替換位元組的額度上限與重疊編輯檢查,而在以位元組範圍覆蓋層為模型的機制中,重疊的編輯會產生悄悄出錯而非明顯損壞的輸出。把變更統一導向動作欄位,代表無論走訪器意圖為何,每一筆編輯都會以相同方式驗證

依需求讀取也讓常見情況維持低成本。一個只計算運算子名稱的稽核任務,完全不需要具體化任何運算元位元組,這在一個擁有數十萬項運算的內容串流上很重要

狀態欄位描述的永遠是輸入

每個事件都攜帶圖形狀態深度、文字物件深度、標記內容深度與相容區段深度,記錄的是該運算子執行前,原始輸入當下的狀態。即使你的走訪器刪除或取代了先前的運算子,這一點依然成立

把狀態錨定在原始輸入而非已編輯的輸出上,是一個經過深思、能帶來實務效益的選擇:對同一份文件執行兩次相同的稽核會得到完全相同的結果,而與編輯同時進行的稽核,也不會因此偏移自身的參照座標。若你需要知道輸出中的深度,由於你知道自己刪除了什麼,就能自行計算出來;反過來,從一個不斷變動的輸出重建穩定的輸入位置,則是不可能的

刪除與取代,以及保護你的規則

取代內容就是原始的內容串流位元組,其語法正確性由你自己負責。圍繞它的保護機制包括:替換位元組總量的額度上限、不重疊要求,以及可選的輸出驗證,會在結果被接受前重新剖析一次:

Delphi 中的改寫保護機制示意圖:走訪器宣告的動作與替換意圖,經替換額度、不重疊位元組範圍與輸出驗證這幾道防護,導向已驗證的儲存頁面
每一項變更都會匯流經過動作欄位,進入額度、重疊與重新剖析這幾道防護,因此驗證失敗會中止操作,而不會寫出損壞的運算子
procedure TWatermarkStripper.HandleOperator(Sender: TObject;
  var EventData: THPDFContentOperatorEventData);
begin
  EventData.Action := coaKeep;
  if EventData.Phase <> covpOperator then
    Exit;

  // 捨棄繪製浮水印圖樣的 operators
  if (EventData.OperatorName = 'Do') and OperandIsWatermark(EventData) then
    EventData.Action := coaDelete
  // 將異常色彩正規化為標準灰
  else if EventData.OperatorName = 'rg' then
  begin
    EventData.Replacement := TEncoding.ANSI.GetBytes('0.2 0.2 0.2 rg');
    EventData.Action := coaReplace;
  end
  // 遇到無法理解的內容時拒絕整個編輯
  else if EventData.OperatorName = 'BI' then
    EventData.Action := coaReject;
end;

var
  Options: THPDFContentOperatorVisitOptions;
begin
  Options := THPDFContentOperatorVisitOptions.Default;
  Options.ValidateOutput := True;        // 接受前重新剖析
  Options.MaxReplacementBytes := 64 * 1024;

  Pdf.OnContentOperator := HandleOperator;
  if Pdf.VisitLoadedPageContentOperators(PageIndex, True, Options, nil, Stats) then
    if Stats.Modified then
      Pdf.SaveLoadedDocument('cleaned.pdf');
end;

動作集合刻意包含兩種退出方式。coaStop 會結束走訪並保留目前已決定的內容,這在你已經找到目標時是正確做法。coaReject 會放棄整次走訪,讓頁面完全不受影響,這在你遇到改寫規則未涵蓋的結構時是正確做法。內嵌影像就是經典範例:其二進位內容不該由一個只負責正規化顏色的走訪器去編輯

解讀統計資訊,以及走訪停在何處

狀態欄位會區分「完成」「停止」「拒絕」「輸入無效」「超出限制」「輸出無效」與「走訪器錯誤」這幾種情況。這些是不同的問題:輸出無效代表你的替換位元組無法重新剖析,而輸入無效則代表來源串流本身早已格式錯誤,根本沒有進行任何編輯嘗試

計數器讓稽核結果能自我檢查。VisitedOperatorCount 應等於 KeptOperatorCount、DeletedOperatorCount 與 ReplacedOperatorCount 三者之和,而最大深度數值則能告訴你這個頁面的巢狀結構是否深到值得進一步檢視。SourceBytes 與 OutputBytes 的比較,能用單一數字呈現你這次編輯造成的大小變化

示意圖:保留、刪除與取代的運算子計數加總須等於走訪計數,旁邊列出從完成到走訪器錯誤共七種狀態結果,並附註狀態深度描述的是原始輸入,因此稽核結果每次重跑都一致
計數必須加總相符,加上為每種可能結果命名的狀態欄位,讓稽核在你未曾建立的文件上也能自我檢查

對於常見任務,還有一些更高階的做法,完全不需要撰寫走訪器。永久移除內容,並附帶真正遮蔽(redaction)所需要的資源清理,涵蓋於已載入文件上的遮蔽與頁面拼接一文。取代可見文字則涵蓋於在既有 PDF 中搜尋並取代一文。當你的規則是結構性的而非文字性的,或需要一份現有報表無法回答的稽核時,再選用運算子走訪器

內容串流走訪、擷取與改寫,在 Delphi 與 C++Builder 中都作用於同一個已載入的文件模型;完整功能清單請見HotPDF Delphi PDF 元件頁面