技術文章

編輯後的文字仍然過時:PDFium FPDF_TEXTPAGE 快取

你使用 PDFiumPas 的 AddText 在 PDF 頁面上蓋印一行文字,接著立即呼叫 FindFirst 確認蓋印成功,搜尋卻回傳空結果。文字明明已在頁面上,Acrobat 也看得到,但 PDFiumPas 的 TPdf 元件另外保留了一個快取的 FPDF_TEXTPAGE 結構,它只會從頁面內容串流解析一次,編輯不會自行回溯更新這個結構。在重新整理前查詢它,讀到的會是變更前的頁面,而不是變更後的頁面

為什麼 PDFium 在編輯後立即回傳過時文字?

PDFiumPas 將 Google 的 PDFium 轉譯引擎封裝給 Delphi 與 C++Builder 使用,而引擎內的文字呼叫和編輯呼叫會進入兩個不同的子系統。FPDF_TEXTPAGE 屬於讀取側:FPDFText_LoadPage 會走訪頁面的內容串流一次,建立包含字元編碼、位置、字型度量與文字邊界的文字頁;只要頁面仍保持載入,PDFiumPas 就會快取這個結構。FPDFPage_InsertObjectFPDFPage_GenerateContent 等編輯呼叫則操作頁面物件與內容串流圖,而 PDFium 不會自行把變更推送到已開啟的文字頁。每次編輯都重建會讓批次編輯慢到無法接受,因此設計選擇以一項規則換取效能:持有控制代碼的一方在內容變更後關閉它,下一次讀取時再建立新的文字頁

TPdf 文字快取內部:FTextPage、LoadTextPage 與 UnloadTextPage

TPdf 將快取的控制代碼追蹤在單一私有欄位 FTextPage 中,並以兩個方法管理其生命週期。LoadTextPage 會檢查 FTextPage 是否為 nil,只有在此情況下才針對目前頁面呼叫 FPDFText_LoadPage;如果控制代碼已存在,LoadTextPage 會直接重用它,不會確認頁面在建立後是否已變更。另一半是 UnloadTextPage:它以 FPDFText_ClosePage 關閉原生控制代碼,將 FTextPage 設回 nil,也會丟棄快取的網頁連結清單與任何進行中的尋找工作階段,因為兩者都源自同一個文字頁,也會因相同原因過時

LoadTextPage 不檢查便重用的行為,正是呼叫順序重要的原因。TPdf 上的每個文字查詢——TextFindFirstGetWebLinks——都會先經過 LoadTextPage,所以只要 FTextPage 仍持有編輯前的控制代碼,這些呼叫都無從得知頁面已發生變更。切換頁面、重新載入或關閉文件時執行的 UnloadPage 一直都會連同頁面一起關閉文字頁;真正的問題是你仍停留在同一頁時對它套用編輯

哪些 PDFiumPas 方法會自動重新整理快取?

TPdf 自己的頁面編輯方法——AddTextSetTextSetTextPositionsAddPathRemoveObjectInsertFormObjectFromXObject——都會在呼叫 UpdatePage(PDFium 的 FPDFPage_GenerateContent)之前先呼叫 UnloadTextPage。呼叫其中任何一個方法後,下一次 TextFindFirstGetWebLinks 都會從目前的內容重建文字頁,不需要你額外呼叫任何方法

var
  Pdf: TPdf;
  Index: Integer;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := 'invoice.pdf';
    Pdf.Active := True;
    Pdf.PageNumber := 1;

    Pdf.AddText('Reviewed by J. Alvarez', 'Helvetica', 10, 72, 40, clBlack, 255, 0);
    // AddText already closed the cached text page, so this FindFirst
    // call rebuilds it fresh before it searches
    Index := Pdf.FindFirst('Reviewed by J. Alvarez');
    if Index >= 0 then
      ShowMessage('Stamp confirmed at character ' + IntToStr(Index));
  finally
    Pdf.Free;
  end;
end;

仍會失效的模式:快取原始 TextPage 控制代碼

TPdf 透過唯讀的 TextPage 屬性公開目前的控制代碼,供少數需要呼叫 PDFiumPas 尚未封裝之 FPDFText_* 函式的情況使用。這個逃生口也是自動失效機制無法協助的位置:一旦你將 FPDF_TEXTPAGE 值複製到區域變數,PDFiumPas 就無法知道你仍持有它,也無法在程式其他位置執行 UnloadTextPage 時更新你的副本

var
  Pdf: TPdf;
  RawHandle: FPDF_TEXTPAGE;
  StaleCount: Integer;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := 'contract.pdf';
    Pdf.Active := True;
    Pdf.PageNumber := 1;

    RawHandle := Pdf.TextPage;    // FPDFText_LoadPage handle, cached in FTextPage
    Pdf.SetText(0, 'Amended Clause 4.2');
    // SetText already closed RawHandle and set Pdf.TextPage back to nil.
    // Calling any FPDFText_* function against the old value now touches a
    // handle PDFium has already freed — undefined behavior, not a bug you
    // can catch with a nil check
    StaleCount := FPDFText_CountChars(RawHandle);
  finally
    Pdf.Free;
  end;
end;

FPDFText_ClosePage 已對控制代碼執行後仍繼續使用它,是 PDFium 本身未定義的行為;它可能回傳最後已知的資料、什麼也不回傳,或使處理程序當機。安全規則很簡單:就在需要呼叫 FPDFText_* 函式前重新讀取 Pdf.TextPage,絕不要在可能編輯頁面的陳述式前後持有副本

批次完成編輯,再查詢一次

這不代表每次 AddTextRemoveObject 呼叫後都要立即進行文字查詢。每個編輯方法本來就會付出一次關閉文字頁的成本;在迴圈中每次編輯後都查詢,只會再次付出相同成本而沒有收益,因為 FPDFText_LoadPage 每次執行都會重新走訪整個內容串流

var
  Pdf: TPdf;
  I: Integer;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := 'watermarked.pdf';
    Pdf.Active := True;
    Pdf.PageNumber := 1;

    // Strip every text object that looks like a draft watermark. Each
    // RemoveObject call already invalidates the cache on its own, so
    // nothing needs refreshing by hand between iterations
    for I := Pdf.ObjectCount - 1 downto 0 do
      if (Pdf.ObjectType[I] = otText) and (Pdf.ObjectBounds[I].Top > 700) then
        Pdf.RemoveObject(I, True);

    // Query once, after the whole batch is done, not once per removal
    if Pdf.FindFirst('DRAFT') < 0 then
      ShowMessage('Watermark cleared');
  finally
    Pdf.Free;
  end;
end;

相同的批次邏輯也適用於搜尋狀態。FindNextFindPrevious 會延續由 FindFirst 開始的工作階段,而 UnloadTextPage 會拆除此工作階段。因此編輯後若再次呼叫 FindNext,而不是重新呼叫 FindFirst,會引發例外。把任何編輯視為文字內容與搜尋位置的明確邊界,並在編輯完成後以一次全新的 FindFirst 重新開始搜尋

這與擷取及註解工作如何銜接

純文字擷取——讀取頁面文字而不做任何變更——不會遇到上述問題,因為未被編輯觸碰的控制代碼不會失效。若要了解未修改頁面上的 Text、字元矩形與文字邊界如何運作,請參閱使用 PDFiumPas 擷取文字的配套文章

快取生命週期對先編輯、再立即處理結果的工作流程最重要,例如蓋印修正後搜尋該修正、遮蔽段落後確認它已消失,或在插入文字後尋找片語來定位標記註解。四邊形點標記註解 是根據從文字頁讀出的字元矩形定位,因此若使用編輯前擷取的座標建立註解,編輯套用後就會標記錯誤的位置

TPdf 的編輯與文字 API 屬於適用於 Delphi 與 C++Builder 的PDFium Component,產品頁面提供本文涵蓋之編輯、擷取與搜尋介面的完整方法參考