技術文章

HotPDF Tesseract DLL OCR:從 Delphi 呼叫 C API

HotPDF 讓 Tesseract 跑在您的 Delphi 行程裡,靠的是 v2.772.0 新增的工廠函式 HPDFCreateTesseractDLLOCREngine:動態載入 Tesseract 5 相容的 DLL,驅動它的 C API(TessBaseAPIInit2、TessBaseAPIRecognize 與結果迭代器),回傳一個 IHPDFOCREngine。THotPDF.ApplyLoadedOCRTextLayer 用這個引擎,為掃描 PDF 頁面加上隱形、可搜尋的 Unicode 文字層

同一個辨識器先前就能透過寫 BMP、解析 TSV 的外接 tesseract.exe 配接器用到。那條路能用,但每一頁都要付行程啟動、暫存點陣圖檔,以及一種沒有 baseline、也管不了頁面分割的文字格式。改呼叫 DLL,三筆帳一次清掉。但它同時也拆掉了行程那道牆——意味著 Pascal 繫結直接坐在 C 結構、C 布林值與 C 配置的字串上。這個配接器最值得知道的事,多半是這份繫結會在哪裡悄悄出錯

怎麼用 HotPDF 從 Delphi 行程內執行 Tesseract?

用 HotPDF 在行程內跑 Tesseract,只需要 HPDFTesseractRecognition 單元裡的一次工廠呼叫,加上每種 HotPDF OCR 引擎都在用的 ApplyLoadedOCRTextLayer。工廠函式驗證從早。DLL 檔與 tessdata 目錄必須存在;語言識別碼只能含 ASCII 字母、數字、_ 與 +;chi_sim+eng 這種組合裡的每個模型都必須有對應的 .traineddata 檔;全部 21 個必要 export 都解析得到,引擎才會回傳。設定錯誤丟 EArgumentException;載入失敗的 DLL 丟 EOSError,帶 Windows 錯誤碼與「檢查架構與相依」的提示

uses
  SysUtils, HPDFDoc, HPDFTesseractRecognition;

procedure MakeSearchable(const SourceFile, TargetFile: string);
var
  Doc: THotPDF;
  Engine: IHPDFOCREngine;
  Options: THPDFOCRTextLayerOptions;
  Info: THPDFOCRTextLayerInfo;
begin
  // Win64 應用程式需要 64 位元 DLL;相依 DLL 放在它旁邊
  Engine := HPDFCreateTesseractDLLOCREngine('C:\OCR\Win64\libtesseract-5.dll',
    'C:\OCR\tessdata', 'chi_sim+eng');   // THPDFTesseractOptions.Default
  Doc := THotPDF.Create(nil);
  try
    Doc.AutoLaunch := False;
    if Doc.LoadFromFile(SourceFile) < 1 then
      raise Exception.Create('Cannot load ' + SourceFile);
    Options := THPDFOCRTextLayerOptions.Default;   // 300 DPI、MinimumConfidence 0.5
    // 空頁面清單代表每一頁;已有文字的頁面會跳過
    if not Doc.ApplyLoadedOCRTextLayer([], Engine, Options, Info) then
      raise Exception.Create(string(Info.Diagnostic));
    Writeln(string(Info.EngineName), ': ', Info.AcceptedWordCount,
      ' words accepted, ', Info.DroppedWordCount, ' dropped');
    Doc.SaveLoadedDocument(TargetFile);
  finally
    Doc.Free;
  end;
end;

THPDFTesseractOptions.Default 把 PageSegMode 設為 tpsAuto、EngineMode 設為 temDefault、TimeoutMilliseconds 設為 60,000、MaxPixels 設為 16,777,216。像素預算比看起來更要緊。US Letter 頁面在預設 300 DPI 下渲染出 2,550 × 3,300 像素、約 840 萬,裝得下。同一頁開 600 DPI 就是 5,100 × 6,600、約 3,370 萬,配接器在 Tesseract 見到任何像素之前就拒收。調高 MaxPixels(上限 67,108,864),或把 DPI 維持原位;每個邊還另外以上限 32,767 像素為限

DLL 以 LoadLibraryEx 載入,搜尋旗標涵蓋 DLL 自身資料夾加預設安全目錄,所以 Tesseract 相依的影像程式庫可以擺在它旁邊,不必動 PATH 或目前目錄。HotPDF 不綑綁也不下載任何 OCR 執行環境或模型;兩者都由您備妥

跟 tesseract.exe 配接器相比,變的是什麼?

DLL 配接器拿行程隔離,換更豐富的輸出與更低的每頁開銷。兩種配接器插進同一條文字層管線,座標映射、信心值過濾與全有或全無的提交都一樣;差的是像素怎麼進去、詞怎麼出來

面向tesseract.exe 配接器Tesseract DLL 配接器
工廠函式HPDFCreateTesseractOCREngineHPDFCreateTesseractDLLOCREngine
像素進入私有暫存目錄裡的 BMP 檔記憶體中的 8-bit 灰階緩衝區
詞輸出詞級 TSV,上限 64 MiB結果迭代器,每詞 UTF-8
基線無法取得從 TessPageIteratorBaseline 直接傳出
頁面分割與引擎模式僅自動分割THPDFTesseractPageSegMode、THPDFTesseractEngineMode
逾時硬性:子行程被終止合作式:Tesseract 必須自己注意到
崩潰與記憶體隔離獨立行程無,共享您的位址空間

有一筆成本不會消失。每次 Recognize 呼叫都建立自己的 API 實例、呼叫 TessBaseAPIInit2,語言模型因此是每頁初始化一次、而不是每顆引擎一次。作業系統的檔案快取會緩和重新載入,但對龐大的多語言模型集,它仍是每頁主要的一次性成本,而且計入辨識期限。行程內 RapidOCR DLL 引擎走相反的設計,ONNX 模型在引擎壽命內常駐;邊界問題(C ABI、借用的緩衝區、不可中斷的原生工作)則是同一家人

為什麼 Delphi 不能照抄 Tesseract 的 monitor 結構?

Delphi 無法安全地鏡射 Tesseract 的進度監視器,因為 ETEXT_DESC 含有隨版本而異的內部欄位,手工照抄的記錄在某些建置上會把取消回呼與期限放到錯的偏移。出事時沒有任何東西大聲失敗。Tesseract 只是從一個如今裝著別的東西的欄位讀您的回呼指標,或者根本看不到期限

所以 HotPDF 把 monitor 當不透明指標,只經匯出函式觸碰:TessMonitorCreate、TessMonitorSetCancelThis、TessMonitorSetCancelFunc、TessMonitorSetDeadlineMSecs 與 TessMonitorDelete。您若為別的目的自己繫結這個 C API,同一套模式照用。下面的素描是您自己的繫結程式碼、不是 HotPDF API,宣告則與 HotPDF 內部所用一致

HotPDF 的 Tesseract DLL monitor 處理示意圖:照抄隨版本而異的 ETEXT_DESC 記錄會把取消回呼與期限放到錯的偏移、無聲失敗;HotPDF 把 monitor 當不透明、只驅動 TessMonitorCreate、TessMonitorSetCancelThis、TessMonitorSetCancelFunc 與 TessMonitorSetDeadlineMSecs,並讓 cdecl 回呼絕不丟例外
一個不透明指標加五個 export 就是全部契約;回呼始終是一個位元組的 Boolean,只讀旗標與時鐘
type
  // C: typedef bool (*TessCancelFunc)(void *cancel_this, int words);
  TTessCancelFunc = function(CancelThis: Pointer; Words: Integer): Boolean; cdecl;
  TTessMonitorCreate = function: Pointer; cdecl;   // ETEXT_DESC*,永不解參照
  TTessMonitorDelete = procedure(Monitor: Pointer); cdecl;
  TTessMonitorSetCancelFunc = procedure(Monitor: Pointer; Func: TTessCancelFunc); cdecl;
  TTessMonitorSetCancelThis = procedure(Monitor, CancelThis: Pointer); cdecl;
  TTessMonitorSetDeadlineMSecs = procedure(Monitor: Pointer; MSecs: Integer); cdecl;
  TTessBaseAPIRecognize = function(Handle, Monitor: Pointer): Integer; cdecl;

  TOCRJob = record
    CancelRequested: Boolean;
    DeadlineTick: UInt64;
  end;
  POCRJob = ^TOCRJob;

function ShouldCancel(CancelThis: Pointer; Words: Integer): Boolean; cdecl;
begin
  // 跑在 Tesseract 的堆疊上:只讀旗標與時鐘,絕不丟例外
  Result := (CancelThis = nil) or POCRJob(CancelThis)^.CancelRequested or
    (GetTickCount64 >= POCRJob(CancelThis)^.DeadlineTick);
end;

// 用法,函式指標經 GetProcAddress 解析:
//   Monitor := MonitorCreate();
//   try
//     MonitorSetCancelThis(Monitor, @Job);
//     MonitorSetCancelFunc(Monitor, ShouldCancel);
//     MonitorSetDeadlineMSecs(Monitor, RemainingMs);
//     RC := BaseAPIRecognize(API, Monitor);
//   finally
//     MonitorDelete(Monitor);
//   end;

素描裡有兩個細節是刻意的。回呼回傳 Boolean——在 Delphi 與 Free Pascal 裡都是一個位元組,正好對上 TessCancelFunc 裡的 C bool。四個位元組的 Windows BOOL 或 Delphi LongBool 看起來可以互換、其實不行:一邊寫一個位元組、另一邊讀四個時,回傳暫存器的高位元組是殘留的雜訊,false 可能以 true 的姿態抵達。同一個標頭檔還添了亂:TessPageIteratorBoundingBox 這類函式回傳 int,HotPDF 宣告成 Integer。每個回傳值都照 C 型別讀,別假設整個 API 是同一套慣例

第二個細節是回呼絕不丟例外。Delphi 例外穿過 Tesseract 的 C++ 堆疊框展開,是未定義行為,所以 HotPDF 的回呼只讀取消權杖與單調遞增的 GetTickCount64 值。TessBaseAPIRecognize 返回之後,配接器把結果轉成取消或逾時的診斷,而且不管原生回傳碼為何都會執行這項檢查

Delphi 這一側擁有哪些原生指標?

HotPDF 的 Tesseract DLL 配接器每個請求擁有三個原生物件——API 實例、monitor 與結果迭代器——其餘全是借的。每次 Recognize 呼叫建立自己的一套,並在 finally 區塊裡釋放:先 TessResultIteratorDelete、再 TessMonitorDelete、最後 TessBaseAPIDelete。釋放引擎介面即卸載程式庫

HotPDF 的 Tesseract DLL 每次 Recognize 呼叫的物件所有權示意圖:結果迭代器、monitor 與 API 實例被擁有、在 finally 裡按此順序釋放;TessResultIteratorGetPageIterator 給的 page iterator 是借用的視圖、絕不可釋放;GetUTF8Text 字串先複製、再經 TessDeleteText 歸還
三個物件是自己的、其他全是借的:按固定順序釋放,page iterator 絕不二次釋放,配置器絕不混用
  • TessResultIteratorGetPageIterator 回傳的是指進結果迭代器的借用視圖、不是新物件。HotPDF 用它跑 TessPageIteratorBoundingBox 與 TessPageIteratorBaseline、從不釋放;單獨刪它會把同一塊記憶體釋放兩次
  • TessResultIteratorGetUTF8Text 回傳的字串由 DLL 自己的執行期配置。HotPDF 複製一份、在 finally 區塊裡經 TessDeleteText 歸還;Pascal 的 FreeMem 會把它還到錯的堆積上
  • 詞的文字以嚴格的 UTF-8 驗證解碼、轉換前先查長度。含控制字元、畸形 UTF-8、框跑出影像、矩形反轉、信心值落在 0–100 之外的詞,讓請求失敗,而不是悄悄修補
  • 每次請求的文字總量以上限 1,048,576 個 UTF-16 code unit 為頂,詞數也必須裝得進 ApplyLoadedOCRTextLayer 下發的請求預算

信心值以 0–100 抵達、縮放到 0–1,所以 THPDFOCRTextLayerOptions.MinimumConfidence 對每種引擎都是同一個意思。Tesseract 有報 baseline 時,兩個端點直接傳出;否則文字層管線後備到幾何估計——跟 TSV 輸入的做法一模一樣

為什麼 enum 要在抵達 DLL 之前驗證?

HotPDF 在範圍檢查之前,先把 PageSegMode 與 EngineMode 的原始序數複製進 Integer,因為編譯器可能假設 enum 變數永遠持有宣告過的值、把 Ord(X) > Ord(High(T)) 摺疊成常數 false。序數不是裝飾:THPDFTesseractPageSegMode 跟著 Tesseract 的頁面分割編號 0 到 13,THPDFTesseractEngineMode 跟著引擎模式編號 0 到 3,兩者都以普通整數送進 DLL。用 FillChar 清出、從串流填入、或從 C++Builder 帶著轉型整數傳來的選項記錄,都可能裝著 200 這樣的位元組。驗證複製出來的序數,讓它變成工廠階段的 EArgumentException,而不是原生程式碼裡的未定義模式。工廠函式另外拒收不產詞的 tpsOSDOnly 與 tpsAutoOnly,並要求 tpsAutoOSD 與 tpsSparseTextOSD 備妥 osd.traineddata

辨識逾時實際上保證什麼?

Tesseract DLL 的逾時是合作式的:HotPDF 能停自己的工作、請 Tesseract 停手,但沒辦法強迫原生程式碼返回。時鐘從 Recognize 開跑那一刻起算,所以點陣圖轉換與模型初始化跟辨識吃同一份預算。HotPDF 在灰階轉換期間、迭代結果時的詞與詞之間,檢查已耗時間與取消權杖,並在呼叫 TessBaseAPIRecognize 之前把剩餘毫秒數交給 TessMonitorSetDeadlineMSecs

缺口在原生呼叫的內部。Tesseract 的 monitor 在詞辨識期間被徵詢,TessBaseAPIInit2 或頁面版面分析期間沒有,所以慢的模型載入或病態的版面,可能在逾時被回報之前就跑過期限。像素與輸出預算也管不到原生程式庫自己的記憶體用量。需要殺得掉的 worker,就用行程配接器;這是誠實的取捨,不是缺功能

HotPDF 的 Tesseract DLL 合作式逾時解剖圖:時鐘從 Recognize 開跑起算、涵蓋灰階轉換、TessBaseAPIInit2 與版面分析,但 monitor 只在詞辨識期間被徵詢,所以模型載入與版面可能在 HotPDF 回報 otlsEngineError 或 otlsCancelled 之前就超時
這裡的期限是請求、不是保證:init 與版面分析可能跑很久,真正殺得掉的 worker 得靠行程配接器

頁面分割是 DLL 配接器在難纏輸入上掙回本錢的地方。欄位四散的表單、標籤與掃描表格,用 tpsSparseText 往往比自動分割認得更好——後者會努力拼湊出不存在的欄與段

procedure OCRFormPages(Doc: THotPDF; const Pages: array of Integer);
var
  Engine: IHPDFOCREngine;
  TessOptions: THPDFTesseractOptions;
  LayerOptions: THPDFOCRTextLayerOptions;
  Info: THPDFOCRTextLayerInfo;
begin
  TessOptions := THPDFTesseractOptions.Default;
  TessOptions.PageSegMode := tpsSparseText;  // 欄位四散,不拼欄
  TessOptions.EngineMode := temLSTMOnly;     // tessdata 裡要有 LSTM 模型
  TessOptions.TimeoutMilliseconds := 20000;  // 含模型初始化
  Engine := HPDFCreateTesseractDLLOCREngine('C:\OCR\Win64\libtesseract-5.dll',
    'C:\OCR\tessdata', 'eng+deu', TessOptions);

  LayerOptions := THPDFOCRTextLayerOptions.Default;
  LayerOptions.MinimumConfidence := 0.6;
  if not Doc.ApplyLoadedOCRTextLayer(Pages, Engine, LayerOptions, Info) then
    case Info.Status of
      otlsCancelled:
        Writeln('OCR cancelled, document unchanged');
      otlsEngineError:
        Writeln('Tesseract failed or timed out: ', string(Info.Diagnostic));
    else
      Writeln(string(Info.Diagnostic));
    end;
end;

逾時以 otlsEngineError 浮現、診斷為 Tesseract DLL OCR timed out;取消的權杖則浮現為 otlsCancelled。兩種情況下,ApplyLoadedOCRTextLayer 都是在啟動提交交易之前,就把每個選中的頁面辨識完畢,所以 50 頁裡第 40 頁失敗,已載入的文件原封不動。注意 tpsSingleLine、tpsSingleBlock 與 tpsSparseText 只改分割;沒有任何一個能把歪斜的掃描扶正

Free Pascal 與 Lazarus:過期像素與消失的中文

經過兩個 FPC 專屬修正,v2.772.1 起兩個 Tesseract 工廠函式在 Windows 的 Free Pascal 與 Lazarus Win32/Win64 建置上都能用。先為目標架構重建 Lazarus 套件;整體移植的部分見 HotPDF 與 Free Pascal/Lazarus Win64

第一個修正關於像素。經 scanline 寫入的 LCL TBitmap 可能更新了原始影像、卻沒重整 Windows 點陣圖 handle,對那個 handle 跑 GetDIBits 拿回的是舊像素。症狀相當謎:直接畫到點陣圖上的文字認得,HotPDF 的 PDF 渲染器渲染出的頁面卻回傳空的詞清單。FPC 上配接器現在經 CreateIntfImage 讀取格式感知的快照,尊重原始影像的像素格式與列序。Delphi 建置維持在私有 24-bit 副本上走 GetDIBits。兩種建置都不修改呼叫方的點陣圖

第二個修正屬於 tesseract.exe 配接器。FPC 的 TStringList 存的是 ANSI 字串,把解碼後的 UTF-8 TSV 文字指派給 Lines.Text,系統 ANSI 字碼頁表示不了的中文或增補平面字元就悄悄消失。FPC 路徑現在把 TSV 按 UTF-8 位元組保留、在位元組層級剝掉 BOM、逐詞解碼成 UnicodeString。DLL 配接器從來沒這毛病,因為它直接從迭代器逐詞解碼

速查

  • 工廠函式:HPDFTesseractRecognition 裡的 HPDFCreateTesseractDLLOCREngine(LibraryPath, TessDataDirectory, Language[, Options]),v2.772.0 新增,FPC 支援自 v2.772.1
  • 預設:tpsAuto、temDefault、60,000 ms、16,777,216 像素;逾時範圍 1–3,600,000 ms,像素上限 67,108,864
  • DLL 位元數對上應用程式,相依 DLL 放在 Tesseract DLL 旁邊
  • monitor 當不透明對待;絕不把 ETEXT_DESC 抄進 Pascal 記錄
  • 取消回呼宣告成 cdecl、結果是一個位元組的 Boolean,並讓例外絕不逃出它
  • 迭代器的文字用 TessDeleteText 釋放;從結果迭代器取得的 page iterator 絕不釋放
  • 預期期限是合作式的:模型初始化與版面分析可能跑過頭
  • 需要硬性終止或崩潰隔離時,用 tesseract.exe 配接器

Tesseract DLL 配接器、各種行程配接器與內建 OCR 引擎,都隨 HotPDF Delphi PDF 元件出貨,支援 Delphi、C++Builder 與 Free Pascal;版本與下載見 HotPDF 產品頁