技術文章

在 Delphi 中為掃描 PDF 加上可搜尋文字層

PDFium Component 透過 ApplyOcrSearchLayer,從 Delphi 為掃描的 PDF 頁面加上可搜尋文字層。它會轉譯每個選定頁面,把像素交給你自行提供的 OCR 供應器,再把辨識出的文字以隱形文字物件寫回,定位在掃描件上對應文字的位置。原始頁面影像從不會被解碼、重新編碼或替換,因此視覺結果與你一開始的頁面逐位元組相同

辨識引擎刻意不包含在函式庫之內。PDFium 提供頁面轉譯、座標對應、字型載入、文字物件建立與隱形轉譯模式,但本身不含任何 OCR 引擎,若假裝有,等於是把某人的辨識產品捆綁進一個 PDF 元件。辨識功能因此存在於 IPdfOcrProvider 介面之後:函式庫傳入固定版面、頂端為原點的 BGRA 像素,供應器回傳 Unicode 文字、信心值與文字四邊形

可搜尋文字層究竟是什麼?

掃描的 PDF 是一份文件的圖片。頁面內容是一張大型影像,沒有任何東西可以選取、搜尋、複製或索引。可搜尋文字層會在該影像上方加上真正的文字物件,並把轉譯模式設為隱形,因此檢視器什麼都不會多畫出來,選取、搜尋與擷取則能精確找到文字實際出現的位置

定位是整件事的關鍵。若隱形文字偏離幾點,選取的反白就會落在文字旁邊而非文字上,複製一個段落也會得到順序錯誤的文字。這正是為什麼幾何資訊必須來自 PDFium 用來轉譯該頁面的同一套變換,而不是靠比例上的猜測

實作供應器

供應器的合約只有一個方法。它接收一筆頁面影像記錄,帶有尺寸、跨距、DPI、像素格式與像素位元組本身,再加上一個取消權杖,並回傳識別出的文字或錯誤訊息:

uses
  PDFium;

type
  TMyOcrProvider = class(TInterfacedObject, IPdfOcrProvider)
  public
    function RecognizePage(const Image: TPdfOcrImage;
      const CancellationToken: IPdfCancellationToken;
      out Words: TPdfOcrWords; out ErrorMessage: string): Boolean;
  end;

function TMyOcrProvider.RecognizePage(const Image: TPdfOcrImage;
  const CancellationToken: IPdfCancellationToken;
  out Words: TPdfOcrWords; out ErrorMessage: string): Boolean;
var
  I: Integer;
begin
  // Image.Pixels 存放以頂端為原點、每列 Image.Stride 位元組的 BGRA 資料。
  // 把它交給你的引擎,然後為每個識別出的詞填入一筆項目
  SetLength(Words, RecognisedCount);
  for I := 0 to RecognisedCount - 1 do
  begin
    Words[I].Text := EngineWordText(I);
    Words[I].Confidence := EngineWordConfidence(I);   // 0..1
    Words[I].Quad := TPdfOcrQuad.FromRectangle(
      EngineLeft(I), EngineTop(I), EngineRight(I), EngineBottom(I));
  end;
  ErrorMessage := '';
  Result := True;
end;

之所以用四邊形而不是矩形,是因為掃描件很少跟頁面完全對齊。稍微傾斜的頁面上的一個詞會佔據一個平行四邊形,TPdfOcrQuad 帶有四個角點,讓傾斜與旋轉的詞仍能保有精確的選取範圍。只回報軸對齊方框的引擎,可以使用建構出退化四邊形的 FromRectangle

為什麼文字位置無法用比例縮放求出?

把像素座標除以轉譯寬度、再乘上頁面寬度來換算成頁面座標,聽起來很誘人。但這只在頁面沒有旋轉、CropBox 與 MediaBox 相同、且原點在零點時才成立,而不少掃描文件至少不滿足其中一項條件

PDFium Component 會透過 FPDF_DeviceToPage(與轉譯器產生這些像素時所用的同一套對應)分別對映四邊形的每個角點,因此 /Rotate 項目與偏移的裁切框,都能靠這個構造自然處理。文字物件的仿射矩陣,接著由三個已對映的點(左下、右下與左上角)建構而成,這剛好足以表達位置、縮放、旋轉與傾斜

文字物件本身以單位字型大小建立,以便量測其真實的字型邊界,量測到的物件邊界接著會對映到目標四邊形。若用猜測的字級大小、寄望它能符合掃描出來的詞,會隨著每次字型替換而產生偏移;先量測後定型,能讓貼合度不受該文字層所用字型的影響

對整份文件執行

選項記錄控制解析度、篩選條件與每一項預算。信心值篩選比乍看之下更重要:低信心值的垃圾文字會永久污染搜尋結果,而且跟繪製錯誤不同,要等到搜尋回傳一堆胡言亂語,才有人會注意到:

var
  Pdf: TPdf;
  Options: TPdfOcrOptions;
  Report: TPdfOcrReport;
  I: Integer;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := 'scanned-contract.pdf';
    Pdf.LoadDocument;

    Options := TPdfOcrOptions.Default;
    Options.Dpi := 300;                  // 辨識解析度
    Options.MinConfidence := 0.60;       // 捨棄不確定的詞
    Options.SkipPagesWithText := True;   // 原生數位頁面維持原樣
    Options.ContinueOnError := True;     // 一頁失敗不該讓整份作業中止
    Options.MaxPixelsPerPage := 40 * 1000 * 1000;

    if Pdf.ApplyOcrSearchLayer(TMyOcrProvider.Create, Options, Report) then
      Pdf.SaveAs('scanned-contract-searchable.pdf');

    for I := 0 to High(Report.Pages) do
      if Report.Pages[I].Status = popsFailed then
        Writeln(Format('page %d failed: %s',
          [Report.Pages[I].PageNumber, Report.Pages[I].ErrorMessage]));
    Writeln(Format('%d word(s) inserted, %d rejected, %d page(s) skipped',
      [Report.InsertedWordCount, Report.RejectedWordCount,
       Report.SkippedPageCount]));
  finally
    Pdf.Free;
  end;
end;

SkipPagesWithText 在混合檔案庫中特別值得強調。一份已經帶有真實文字(無論是原生數位或先前處理過)的 PDF,若你不加分辨就對它跑 OCR,就會再多出一層文字,重複的文字讓擷取結果每個詞都出現兩次。每頁的狀態值 popsSkippedExistingText 會精確告訴你哪些頁面被保留原樣

預算、取消與失敗隔離

任何惡意文件或單純超大文件能撐大的數量,都設有上限:每頁與總計的像素數、每頁與總計的詞數,以及每個詞的字元數。這些全都在寫入頁面之前檢查,而不是之後,像素估算也是在配置任何點陣圖之前,就依頁面尺寸與 DPI 計算完成。把 DPI 從 150 提高到 300,每頁記憶體用量會變成四倍,因此當批次作業在大版面上開始失敗時,每頁上限是第一個該調整的參數

取消權杖貫穿整條路徑:漸進式轉譯、供應器呼叫與逐詞插入迴圈。這代表在辨識一份 400 頁檔案時取消的使用者,會在一頁之內停下來,而不是要等到文件結束,而元件其他地方使用的同一套權杖模式,也說明於 可取消的漸進式轉譯 一文,用法完全一致

失敗隔離是逐頁進行的。函式庫會收集它在某一頁上插入的物件控制代碼,並在所有詞都放置完畢後,一次呼叫 FPDFPage_GenerateContent。若過程中任何一步失敗,無論是供應器錯誤還是字型問題,該頁上插入的物件都會以相反順序移除,並重新產生頁面內容,因此一頁失敗的頁面會還原為原始狀態,而不是留下半套文字層。文件層級的迴圈接著會依 ContinueOnError 繼續或停止,而目前作用中的頁面則一律會被還原

驗證影像確實沒有被動到

最有力的檢查也是最簡單的:在套用文字層前後,以相同尺寸轉譯同一頁並比對點陣圖。兩者應該逐位元組完全相同,因為隱形文字不會畫出任何東西,影像串流也從未被解碼過。任何差異都意味著除了文字層以外,還有別的東西改變了頁面

之後,可以透過從處理過的檔案擷取文字,確認詞的位置落在掃描件上,來驗證文字這一側。擷取路徑與 從 PDF 文件擷取文字 一文所述的相同,若要快速目視檢查對齊狀況,把頁面轉譯成影像(如 將 PDF 頁面轉換為 JPEG 一文所述)能讓你把詞的方框疊在掃描件上比對

OCR 疊層、轉譯、擷取與編輯,都在 Delphi、C++Builder 與 Lazarus 中共用同一個文件物件;完整 API 表面說明於 PDFium Component for Delphi 頁面