技術文章

Delphi 中用 Tesseract OCR 製作可搜尋 PDF(HotPDF)

HotPDF 用 Tesseract 把掃描的 PDF 頁面變成可搜尋 PDF,入口是 HPDFCreateTesseractOCREngine,這個工廠把本機安裝的 Tesseract 執行檔包成 IHPDFOCREngine。您把引擎交給 ApplyLoadedOCRTextLayer,它渲染每一頁、每頁跑一次 Tesseract、剖析字級 TSV 輸出,然後以單一交易為所有請求頁面提交一層隱形 Unicode 文字層——要嘛全部,要嘛全不

HotPDF 的每頁 OCR 管線:以設定的 DPI 渲染頁面,把 input.bmp 存進私有的 HotPDF-OCR 目錄,帶 tessedit_create_tsv 啟動 Tesseract 子行程,剖析十二欄 TSV,依信心度過濾字詞,為所有請求頁面提交隱形文字層、或全不提交
轉接器只替換辨識本身:渲染、剖析、驗證與全有或全無的提交仍住在既有文字層管線裡,下游程式碼一概不動

這個轉接器存在的原因是範圍。內建模板比對 OCR 引擎刻意窄:機打的 ASCII 字母與數字,僅此而已。帶重音符姓名的發票、中文合約、多語言檔案庫,需要帶訓練語言模型的真正辨識器,而 Tesseract 是顯而易見的候選,它是命令列程式,可以部署在應用程式旁邊。從文件函式庫裡呼叫外部程式,聽起來輕鬆。並不輕鬆,轉接器裡多數有趣的程式碼,都在處理程式不乖、卡死、被取消、或繼承了不該看見的東西時會發生什麼

HotPDF 怎麼從 Delphi 應用驅動 Tesseract?

HotPDF 讓 Tesseract 以隱藏子行程的方式每頁跑一次,餵它一張渲染點陣圖、收回一份 TSV 檔,並透過內建引擎用的同一個 IHPDFOCREngine 接縫暴露結果。下游什麼都不變:座標映射、旋轉處理、Unicode 驗證、信心度過濾與原子提交,都是您已有的文字層管線。工廠住在 HPDFTesseractRecognition 單元裡,而且驗證得很早:執行檔必須存在,tessdata 目錄必須存在,逾時必須落在 1 到 3,600,000 毫秒之間,語言識別碼只能含 ASCII 字母、數字、_ 與 +。最後那個檢查要緊,因為語言字串最終會落在命令列上,eng+chi_sim 是合法的 Tesseract 值,帶引號或空格的東西都不是

uses
  SysUtils, HPDFTypes, HPDFDoc, HPDFTesseractRecognition;

procedure MakeSearchable(const SourceFile, TargetFile: string;
  Token: THPDFCancellationToken);
var
  Doc: THotPDF;
  Engine: IHPDFOCREngine;
  Options: THPDFOCRTextLayerOptions;
  Info: THPDFOCRTextLayerInfo;
begin
  // 執行檔缺失、tessdata 缺失、語言識別碼不合法、
  // 或逾時超出 1..3600000 ms 時,丟 EArgumentException
  Engine := HPDFCreateTesseractOCREngine(
    'C:\OCR\Tesseract\tesseract.exe',
    'C:\OCR\Tesseract\tessdata',
    'eng+chi_sim',      // 多個模型以 '+' 串接
    120000);            // 每頁上限,預設 60000
  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
    Options.CancellationToken := Token;
    // 空頁面清單代表每一頁;已有文字的頁面預設跳過
    if Doc.ApplyLoadedOCRTextLayer([], Engine, Options, Info) then
    begin
      Writeln(string(Info.EngineName), ': ', Info.AcceptedWordCount,
        ' words accepted, ', Info.DroppedWordCount, ' dropped');
      Doc.SaveLoadedDocument(TargetFile);
    end
    else
      case Info.Status of
        otlsCancelled:      Writeln('Cancelled, document unchanged');
        otlsEngineError:    Writeln('Engine: ', string(Info.Diagnostic));
        otlsBudgetExceeded: Writeln('Budget: ', string(Info.Diagnostic));
      else
        Writeln(string(Info.Diagnostic));
      end;
  finally
    Doc.Free;
  end;
end;

每一頁,Recognize 在暫存路徑下建一個名為 HotPDF-OCR-{GUID} 的私有目錄,把渲染點陣圖存成 input.bmp,然後啟動 tesseract input.bmp output --tessdata-dir … -l … --dpi N --psm 3 -c tessedit_create_tsv=1,每個路徑參數都按 Windows 命令列對反斜線與內嵌引號的跳脫規則加引號。--dpi 值取自 THPDFOCRTextLayerOptions.DPI 的渲染 DPI,所以 Tesseract 不必從影像中繼資料猜解析度;--psm 3 要求全自動版面分割。引擎自我回報為 Tesseract (local CLI),這就是落進 Info.EngineName 的字串。Tesseract 與它的語言模型不隨 HotPDF 附帶;安裝是應用程式自己的事

TSV 解析器為什麼這麼嚴?

HotPDF 的 TSV 解析器對任何畸形列都讓整頁失敗,因為剖析到一半的字詞清單,會產出與影像悄悄不一致的文字層。Tesseract 的 TSV 輸出有固定的十二欄表頭,從 level 到 text,HotPDF 剝掉可選的位元組順序標記後,把第一行與那個確切表頭比對。之後每一列都必須切出恰好十二個欄位,而切分在第十一個 tab 之後就停,讓辨識文字裡的 tab 留在字詞裡、不會生出第十三欄。只有 level 5 的列是字詞;level 1 到 4 描述頁、區塊、段落與行,一律跳過。level 5 裡文字為空或純空白的列也跳過,因為空白字詞有框、卻沒有東西可定位或搜尋。其餘全部硬檢:整數幾何、以不變 en-US 格式剖析的信心度(德語 locale 才不會把 93.5 讀成垃圾)、完全落在點陣圖內的框、以及 0 到 100 之間的信心度。單一失敗就丟例外,引擎回傳 False,字詞陣列清空。迴歸測試裡恰好有這個案例:一個合法字詞後面跟著一行壞列,必須得到零個字詞,而不是一個

HotPDF 中每個 Tesseract TSV 列要過的六道閘:精確的十二欄表頭、恰好十二個欄位、只收 level 5、文字非空白、框落在點陣圖內、信心度以不變方式解析且介於 0 到 100,一行壞列讓整頁歸零
剖析到一半的字詞清單會與影像悄悄不一致,所以解析器在第一行畸形列就拒收整頁,而不是留下已經讀進來的字詞
// 摘自 HPDFLocalTSVRecognition 的 level-5 迴圈
if (Fields.Count <> 12) or not TryStrToInt(Fields[0], Level) then
  raise EConvertError.Create('Invalid Local OCR TSV row');
if Level <> 5 then Continue;                 // 頁/區塊/段落/行列
WordText := Fields[11];
if Trim(WordText) = '' then Continue;        // 空白字詞沒有位置
if not TryStrToInt(Fields[6], X) or not TryStrToInt(Fields[7], Y) or
  not TryStrToInt(Fields[8], W) or not TryStrToInt(Fields[9], H) or
  not TryStrToFloat(Fields[10], Confidence, Settings) then
  raise EConvertError.Create('Invalid Local OCR word geometry');
if (X < 0) or (Y < 0) or (W <= 0) or (H <= 0) or
  (Int64(X) + W > Request.Bitmap.Width) or
  (Int64(Y) + H > Request.Bitmap.Height) or
  not ((Confidence >= 0) and (Confidence <= 100)) then
  raise EConvertError.Create('Local OCR word is outside the image');
Words[Count].Confidence := Confidence / 100;  // 管線期待 0..1

最後那行與一個您可能料不到的預設值互動。Tesseract 的信心度從 0 到 100,管線用 0 到 1,而 THPDFOCRTextLayerOptions.MinimumConfidence 預設 0.5,所以任何低於 50 的 Tesseract 字詞都會計入 Info.DroppedWordCount,永遠到不了頁面。乾淨的 300 DPI 掃描,這是合理的地板。雜訊多的傳真,它可能丟掉頁面上多得驚人的一部分,正確的動作是先看丟棄計數、再考慮調低門檻,因為低信心度的字詞恰恰是最可能錯的那批

Tesseract 子行程繼承了什麼?

Tesseract 子行程從 HotPDF 手裡恰好繼承兩個 handle:標準輸入與輸出用的一個 NUL handle,以及標準錯誤用的一個檔案 handle。這個精確度就是重點。CreateProcess 配 bInheritHandles = True 是把標準 handle 傳給子行程的方式,但它同時把宿主行程裡每一個可繼承 handle 都傳了過去,包括應用程式裡不相關程式碼打開的檔案、pipe 與事件。子行程於是把那些物件攥在手裡直到退出,Tesseract 磨一頁的功夫裡,檔案一直鎖著、pipe 永遠等不到端。HotPDF 用擴充啟動記錄補上這個洞:STARTUPINFOEX、一個帶 PROC_THREAD_ATTRIBUTE_HANDLE_LIST 的屬性清單,以及 EXTENDED_STARTUPINFO_PRESENT 建立旗標。handle 清單就位後,bInheritHandles 仍須為 True,但只有列出的 handle 跨過邊界。同一套圈圍思路也驅動著把 PDF 影像編解碼器隔離在工作者行程裡,那裡的子行程是不可信程式碼;這裡的子行程可信,但宿主也不是自己 handle 表的唯一主人

HotPDF 中 Tesseract 子行程的 handle 繼承:普通 CreateProcess 帶 bInheritHandles 會把每個可繼承的檔案、pipe 與事件 handle 都交給子行程,STARTUPINFOEX 配 PROC_THREAD_ATTRIBUTE_HANDLE_LIST 則把集合限縮到 stdin 與 stdout 的 NUL handle 加上 stderr 的檔案 handle
沒有屬性清單,子行程把不相關的物件攥到退出為止,鎖住檔案、餓死 pipe;有了它,只有列出的兩個 handle 跨過邊界
// 常數以名稱顯示;原始碼傳它們的數值
// 兩個 handle 都以 bInheritHandle = True 建立
InheritedHandles[0] := NullHandle;    // stdin 與 stdout
InheritedHandles[1] := ErrorHandle;   // 私有目錄裡的 stderr.txt
InitializeProcThreadAttributeList(Startup.AttributeList, 1, 0, AttributeBytes);
UpdateProcThreadAttribute(Startup.AttributeList, 0,
  PROC_THREAD_ATTRIBUTE_HANDLE_LIST,
  @InheritedHandles[0], SizeOf(InheritedHandles), nil, nil);
CreateProcess(PChar(Executable), PChar(Command), nil, nil,
  True,                                        // handle 清單的要求
  CREATE_NO_WINDOW or EXTENDED_STARTUPINFO_PRESENT,
  nil, PChar(DirectoryName), Startup.StartupInfo, ProcessInfo);

被取消的 OCR 為什麼看起來像引擎失敗?

因為 IHPDFOCREngine.Recognize 只回傳一個布林值,False 同時意味著「Tesseract 失敗」與「使用者按了取消」。子行程跑著的時候,轉接器每 25 毫秒輪詢一次取消權杖與逾時,權杖觸發時,它在 Recognize 裡丟例外、抓住自己的例外、清理現場,然後帶著診斷回傳 False。如果管線把它當引擎錯誤,呼叫端會為一個使用者主動停掉的工作看到 otlsEngineError。所以 ApplyLoadedOCRTextLayer 在 Recognize 回傳 False 時先查權杖,只有權杖沒設時才把結果轉成引擎失敗。這個順序保住了多頁契約:辨識、驗證、預算記帳與內容建構對每個請求頁面都先跑完,圖形交易才開,所以 50 頁裡第 40 頁的取消回報 otlsCancelled,文件連同前 39 頁原封不動。之後不必解釋一個半可搜尋的檔案,其餘失敗處理也是同一套有界風格:

  • 逾時以每次 Recognize 呼叫計、從呼叫開始量,所以預設 60,000 ms 作用在每頁、而不是整份文件
  • 逾時或取消時仍在跑的子行程被終止、最多等 5 秒,其私有目錄在 finally 區塊裡刪除
  • output.tsv 上限 64 MiB、stderr.txt 上限 1 MiB,子行程跑著時與退出後都檢查
  • 字詞數與 UTF-16 碼元按頁受剩餘的 MaxWordsPerPage、MaxTotalWords 與 MaxTextCodeUnits 預算封頂,超了就讓整輪失敗,而不是截斷字詞清單
  • 標準輸出導向 NUL,因為 Tesseract 寫的是 output.tsv;標準錯誤進檔案,非零結束碼會附上引擎自己的抱怨、最多 4,096 字元——通常是發現 .traineddata 檔缺失最快的路

辨識出的字詞怎麼變成隱形文字層

HotPDF 用文字渲染模式 3 把 Tesseract 字詞寫成隱形文字,ISO 32000-1 §9.3.6 定義的既不填充也不描邊模式,頁面上照樣顯示掃描影像,而搜尋與複製作用在辨識出的字詞上。內容串流以 3 Tr 開 BT,每個字詞拿到基線上的一個 Tm 矩陣、一個由框高像素按渲染 DPI 推出的字號、以及一個把字元串拉到實測框寬的 Tz 水平縮放,所以搜尋高亮落在影像裡的字詞上,而不是飄過它

Tesseract 的 TSV 有框、沒有基線,所以轉接器回報每個字詞都無基線,管線把基線估在框底邊上方五分之一框高處。文字本身走一個共用的未內嵌 Type0 字型、Identity-H 編碼加生成的 ToUnicode CMap,整輪裡每個相異 Unicode 純量一個 CID,中文、帶重音的拉丁字母與輔助平面字元因此在複製與搜尋裡都活下來。這個設計有兩個值得先講明的極限:一次至多帶 65,535 個相異純量;未內嵌字型不滿足 ISO 19005 的字型內嵌要求,PDF/A 輸出需要另外內嵌合規字型。驗證結果很簡單、值得自動化:存檔、重載、跑從已載入 PDF 擷取文字的普通已載入文件文字路徑;字詞從預期的頁面回來,文字層就是真的

同一 TSV 協定上的 RapidOCR 與其他引擎

HotPDF 用同一套行程執行器與 TSV 解析器跑 RapidOCR,入口 HPDFCreateRapidOCREngine(PythonExecutable, BridgeScript, ModelDirectory, TimeoutMilliseconds),對簡體中文掃描是更實用的選擇。命令列一模一樣,只是 bridge 腳本路徑插在 Python 執行檔之後,語言固定 chi_sim。HotPDF 以 tools/OCR/rapidocr_tsv.py 附帶 bridge;它要求 rapidocr 與 onnxruntime 套件加三個本地 ONNX 模型,停用自動模型下載,寫出 Tesseract 形狀的 TSV,讓 Delphi 側不需要第二個解析器。Info.EngineName 回報的引擎名是 RapidOCR (local ONNX)。這個形狀暗示了通用配方:任何能用小腳本包起來、接受 Tesseract 式參數清單、發出十二欄 TSV 的辨識器,都免費繼承 handle 隔離、逾時、取消、輸出預算與全有或全無的提交。這些轉接器只在 Windows 上、一次同步跑一頁,也不在渲染器輸出之外做去偏斜或前處理,所以進去的影像品質仍然是出來的東西的天花板

Tesseract 與 RapidOCR 轉接器、隱形文字層寫入器、餵它們的頁面渲染器,以及驗證結果的文字擷取,全都隨同一個原生 VCL 元件出貨,支援 Delphi 與 C++Builder。要為文件擷取或封存應用加 OCR,HotPDF Delphi PDF component 給您整條管線,只剩 OCR 引擎本身待安裝