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 頁面