技術文章

編輯後的文字仍然過時: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 不會自行把變更推送到已開啟的文字頁。每次編輯都重建會讓批次編輯慢到無法接受,因此設計選擇以一項規則換取效能:持有控制代碼的一方在內容變更後關閉它,下一次讀取時再建立新的文字頁

PDFium 編輯寫進頁面內容串流,快取的 FPDF_TEXTPAGE 仍是載入時快照的圖解:AddText 之後立刻查詢 Delphi 的 FindFirst 會讀到編輯前的頁面而漏掉印章
編輯與閱讀在 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 已關閉快取的文字頁,因此這次 FindFirst
    // 呼叫會在搜尋之前重新建置一個全新的文字頁
    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 控制代碼,快取在 FTextPage
    Pdf.SetText(0, 'Amended Clause 4.2');
    // SetText 已關閉 RawHandle,並將 Pdf.TextPage 設回 nil。
    // 對舊值呼叫任何 FPDFText_* 函式,現在會觸及一個
    // PDFium 已釋放的控制代碼——這是未定義行為,不是你能用
    // nil 檢查捕捉的缺陷
    StaleCount := FPDFText_CountChars(RawHandle);
  finally
    Pdf.Free;
  end;
end;

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

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

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

AddText、SetText、RemoveObject 等 TPdf 編輯方法先呼叫 UnloadTextPage 再 UpdatePage 的圖解:讓下一次 Delphi 的 Text、FindFirst 或 GetWebLinks 查詢從編輯後內容重建 FPDF_TEXTPAGE
每個包裝過的編輯先丟掉過時的文字頁、再生產內容;下一次文字查詢便自動重建 FPDF_TEXTPAGE
var
  Pdf: TPdf;
  I: Integer;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := 'watermarked.pdf';
    Pdf.Active := True;
    Pdf.PageNumber := 1;

    // 移除每個看起來像草稿浮水印的文字物件。每次
    // RemoveObject 呼叫本身就會讓快取失效,所以
    // 各次迭代之間不需要手動重新整理
    for I := Pdf.ObjectCount - 1 downto 0 do
      if (Pdf.ObjectType[I] = otText) and (Pdf.ObjectBounds[I].Top > 700) then
        Pdf.RemoveObject(I, True);

    // 在整批完成後查詢一次,而非每次移除後都查詢
    if Pdf.FindFirst('DRAFT') < 0 then
      ShowMessage('Watermark cleared');
  finally
    Pdf.Free;
  end;
end;

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

把原始 FPDF_TEXTPAGE 控制代碼從 TPdf 的 TextPage 屬性複製出來、SetText 之後對它呼叫 FPDFText_CountChars的圖解:Delphi 程式碼會用到已釋放的 PDFium 控制代碼,屬未定義行為
複製出來的 FPDF_TEXTPAGE 值會繼續指向編輯路徑早已關閉的控制代碼;每次裸呼叫 FPDFText_* 前,請即時重新讀取 Pdf.TextPage

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

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

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

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