技術記事

HotPDF で Tesseract OCR から検索可能 PDF を作る

EOF を報告しないパイプは、OCR 機能で追いかけるには風変わりなバグです。アプリケーションの 1 つのスレッドがヘルパープログラムの標準出力を読み、ReadFile が ERROR_BROKEN_PIPE で失敗するのを待ちます。別のスレッドは bInheritHandles を True にして CreateProcess で tesseract.exe を起動します。標準エラー用にファイルを子プロセスへ渡すには、そうするしかないからです。ヘルパーのパイプ書き込み端は継承可能だったため、Tesseract はそのコピーを所有し、読み取りスレッドは Tesseract がページを終えるまで固まります—2 つの言語モデルをロードした 300 DPI のスキャンでは、それは速くありません

外部認識器は固まり以上のものを運びます。子に何が見えるか、半分妥当な出力をどう扱うか、ピクセルボックスを検索可能 PDF テキストへどう変えるかです。組み込みテンプレートマッチング OCR エンジンは意図的に、機械印字の ASCII 英数字だけをカバーします。アクセント付きの名前、中国語の契約書、混在言語のアーカイブには学習済み言語モデルが要り、Tesseract が定番の候補です。実行ファイルの隣に置けるコマンドラインプログラムですから

Tesseract を HotPDF の OCR テキストレイヤーへ差し込む

HotPDF はローカルにインストールされた Tesseract 実行ファイルを、HPDFTesseractRecognition ユニットの HPDFCreateTesseractOCREngine 経由で IHPDFOCREngine として包みます。そのエンジンを THotPDF.ApplyLoadedOCRTextLayer へ渡すと、要求された各ページをレンダリングし、ページごとに Tesseract を 1 回走らせ、単語をフィルタし、要求された全ページの不可視 Unicode テキストレイヤーを 1 つのトランザクションで、あるいは 1 ページも書かずに書き込みます。座標マッピング、回転、信頼度フィルタリング、コミットは組み込みエンジンが既に使うパイプラインであり、変わるのは認識だけです

ページごとの HotPDF OCR パイプラインを示す図。設定された DPI でページをレンダリングし、プライベートな HotPDF-OCR ディレクトリーに input.bmp を保存し、tessedit_create_tsv 付きで Tesseract 子プロセスを起動し、12 列の TSV をパースし、信頼度で単語をフィルタし、要求された全ページか無かに対して不可視テキストレイヤーをコミットします
アダプターが置き換えるのは認識だけです。レンダリング、パース、検証、全か無かのコミットは既存のテキストレイヤーパイプラインにとどまり、下流のコードは決して変わりません

ファクトリーはどのページにも触れる前に EArgumentException を上げます。実行ファイルと tessdata ディレクトリーが存在すること、タイムアウトが 1 から 3,600,000 ミリ秒の間であること、言語には ASCII の英字、数字、_、+ しか入れられないこと。言語は最終的にコマンドラインの上に乗るからです。eng+chi_sim は正当であり、引用符やスペースを含む値は発生待ちの注入です。2 つ目のオーバーロードは THPDFTesseractOptions レコードを受け取ります。その Default は --psm 3、--oem 3、ページあたり 60 秒、16,777,216 ピクセルの上限を与え、単語を返さない 2 つのモード tpsOSDOnly と tpsAutoOnly を拒否します

uses
  SysUtils, HPDFTypes, HPDFDoc, HPDFTesseractRecognition;

function MakeSearchable(const SourceFile, TargetFile: string;
  Token: THPDFCancellationToken): Boolean;
var
  Doc: THotPDF;
  Engine: IHPDFOCREngine;
  TessOptions: THPDFTesseractOptions;
  Options: THPDFOCRTextLayerOptions;
  Info: THPDFOCRTextLayerInfo;
begin
  TessOptions := THPDFTesseractOptions.Default; // psm 3、oem 3、60 秒、16,777,216 px
  TessOptions.TimeoutMilliseconds := 120000;    // 各ページに適用される
  // 欠けた実行ファイルや tessdata フォルダー、悪い言語識別子、
  // 範囲外のオプションで EArgumentException を上げる
  Engine := HPDFCreateTesseractOCREngine(
    'C:\OCR\Tesseract\tesseract.exe',
    'C:\OCR\Tesseract\tessdata',
    'eng+chi_sim',                              // モデルは '+' で連結
    TessOptions);
  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;
    // 空のページリストは全ページの意味。テキストを既に持つページはスキップされる
    Result := Doc.ApplyLoadedOCRTextLayer([], Engine, Options, Info);
    if Result then
    begin
      Writeln(string(Info.EngineName), ': ', Info.AcceptedWordCount,
        ' words kept, ', Info.DroppedWordCount, ' below the threshold');
      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;

ピクセルの算術は 1 回だけやりましょう。300 DPI の A4 ページは約 2,481 × 3,508 ピクセル、およそ 870 万で、収まります。同じ解像度の A3 ページは約 3,508 × 4,961、1,740 万近くになり、ピクセル予算の診断で失敗し、実行は otlsEngineError として終わります。タイムアウトだけを受け取るオーバーロードはピクセル上限をまったく適用しません。外からファイルを受け取るサービスでは、オプションのオーバーロードを使い、大きなフォーマットが予想されるときには MaxPixels を上げるか(最大 67,108,864)、THPDFOCRTextLayerOptions.DPI を意図的に下げてください

ページごとに、エンジンはテンポラリーパスの下にプライベートな HotPDF-OCR-{GUID} ディレクトリーを作り、ビットマップを input.bmp として保存し、tesseract input.bmp output --tessdata-dir … -l … --dpi N --psm 3 -c tessedit_create_tsv=1 を実行し、エンジンモードが設定されているときは --oem を足します。パスはバックスラッシュと埋め込み引用符の Windows 規則で引用符付きになり、--dpi はレンダリング解像度を運ぶため、Tesseract がそれを当て推量することはありません。Tesseract とその .traineddata ファイルは同梱されず、調達はアプリケーションの仕事です。私たち自身の Linux テストマシンでは、認識器はバイナリーとその共有ライブラリー閉包と tessdata フォルダーのユーザーレベルバンドルから走ります。/usr/bin/tesseract に頼るのは、誰かがシステムパッケージを入れた場所でしか機能しないからです

なぜ 1 つの壊れた TSV 行がページ全体を空にするのか

Tesseract アダプターは最初の不正な行でページ全体を失敗させます。部分的にパースされた単語リストは画像と食い違うテキストレイヤーを作り、保存された PDF のどこにも、どの単語が消えたかは書いていないからです。出力は埋め込み NUL なしの厳格な UTF-8 としてデコードできなければなりません。最初の行は、任意のバイトオーダーマークを剥いだ後、Tesseract の 12 列ヘッダー、level から text まで、と等しくなければなりません。各行はきっかり 12 フィールドへ分割できなければならず、分割は 11 番目のタブの後で止まるため、認識テキストの中のタブは 13 列目を作らずテキストフィールドに着地し、下の制御文字検査がそれを拒否します

レベル 5 の行だけが単語です。ページ、ブロック、段落、行の行はスキップされ、空や空白だけの単語もスキップされます。制御文字(U+0020 未満、または U+007F から U+009F)を含む単語はページを失敗させます。残りには、整数の幾何、ドイツロケールのアプリケーションでも 93.5 を読めるように不変フォーマットでパースされた信頼度、ビットマップの完全に内側のボックス、0 から 100 の信頼度が要ります。1 つの妥当な単語の後に 1 つの壊れた行が来ると、単語はゼロになります

HotPDF で Tesseract の TSV 行が通る 6 つのゲート。正確な 12 列ヘッダー、きっかり 12 フィールド、レベル 5 のみ、非空白のテキスト、ビットマップの内側のボックス、そして不変にパースされた 0 から 100 の信頼度。1 つの壊れた行がページ全体をゼロ単語まで落とします
部分的に解析されたワードリストは画像と静かに食い違うため、パーサーは、すでに読んだ単語を保持する代わりに、最初の不正な行でページ全体を拒否します

だから 1 つの変な行が実行全体を失敗させ、すべてのページに触れません。それが正しい取引きです。再実行は安い。請求書番号の検索が、いちばん大事なそのページだけ外れる検索可能 PDF は、数か月後、他人の手の中で表面化しがちです

最後のステップは、予想外かもしれないデフォルトにも出会います。Tesseract は信頼度を 0 から 100 で報告し、パイプラインは 100 で割り、THPDFOCRTextLayerOptions.MinimumConfidence はデフォルトで 0.5 です。50 未満の単語はすべてページでなく Info.DroppedWordCount に落ちます。きれいなスキャンではこの床は理にかなっています。ノイズの多いファックスではテキストの大部分を奪いかねないため、下げる前に Info.ReceivedWordCount と Info.AcceptedWordCount を比較してください

既にテキストを持つファイルで OCR をもう一度走らせる

SkipPagesWithText はデフォルトで True であり、2 回目に人を捉えます。バッチが eng で処理され、今は eng+deu が要るとしましょう。すべてのページは既に不可視テキストを運ぶため、Info.SkippedPageCount はページ数と等しくなります。HotPDF は自分が書くコンテンツストリームに自分の OCR レイヤーの印を付け、ReplaceExistingOCRTextLayer はその印を使ってそれらのページを再認識し、古いレイヤーを落とし、元のページ内容を保ちます

Options := THPDFOCRTextLayerOptions.Default;
Options.ReplaceExistingOCRTextLayer := True;  // 前に書いた自分のレイヤーを入れ替える
Options.UseOptionalContentGroup := True;      // 'OCR Text Layer' という名のレイヤー
Options.CancellationToken := Token;
if Doc.ApplyLoadedOCRTextLayer([], Engine, Options, Info) then
  Writeln(Info.ReplacedPageCount, ' replaced, ',
    Info.SkippedPageCount, ' skipped, ',
    Info.RecognizedPageCount, ' recognized')
else
  Writeln(string(Info.Diagnostic));

スキャナードライバーが OCR したファイルはそういう印を運ばないため、そのページは依然としてテキストのあるページとして数えられます。SkipPagesWithText を切ると、最初の層の上に 2 つ目の不可視レイヤーが積まれ、すべての検索ヒットが 2 回現れます。そういうファイルは放置してください

Tesseract プロセスはどのハンドルを継承するのか

Tesseract 子プロセスが HotPDF から継承するのはきっかり 2 つのハンドルです。標準入力と標準出力のために NUL 上で開いたものと、プライベートディレクトリーの stderr.txt 用のものです。上のパイプ固まりは CreateProcess 自体から来ます。標準ハンドルを渡すには bInheritHandles = True が必要で、それだけだとホストの中のすべての継承可能ハンドル、無関係なコードが開いたパイプ、ファイル、イベント込みで渡されます。HotPDF は PROC_THREAD_ATTRIBUTE_HANDLE_LIST を運ぶ属性リスト付きの STARTUPINFOEX と EXTENDED_STARTUPINFO_PRESENT フラグを使います。bInheritHandles は True のままですが、境界を越えるのはリストされたハンドルだけです

HotPDF における Tesseract 子プロセスのハンドル継承。素の CreateProcess と bInheritHandles は、継承可能なファイル、パイプ、イベントのハンドルすべてを子へ渡します。一方 STARTUPINFOEX と PROC_THREAD_ATTRIBUTE_HANDLE_LIST は、集合を、stdin と stdout の NUL ハンドルと stderr のファイルハンドルに限定します
属性リストがなければ、子は無関係なオブジェクトを終了まで生かし続け、ファイルをロックし、パイプを飢えさせます。あれば、境界を越えるのはリストされた 2 つのハンドルだけです

同じパターンは、アプリケーションが起動する他のすべての子プロセスにも属します。すべてを継承するランチャー 1 つで、プロセスの中のすべてのパイプリーダーが固まりますから:

// 自分の CreateProcess 呼び出し向けのパターン(断片:NullHandle、ErrorHandle、
// Exe、CommandLine、WorkDir、ProcessInfo は別の場所で宣言済み)。
// 古い Delphi RTL は属性リスト関数を宣言しない。コンパイラが見つけられないなら
// kernel32 からの external 宣言を自分で足す
// 本番コードでは各 BOOL の結果を検査する
type
  TStartupInfoExRec = record
    StartupInfo: TStartupInfo;
    AttributeList: Pointer;
  end;
const
  HANDLE_LIST_ATTRIBUTE = $00020002;   // PROC_THREAD_ATTRIBUTE_HANDLE_LIST
  EXTENDED_STARTUP_FLAG = $00080000;   // EXTENDED_STARTUPINFO_PRESENT
var
  Startup: TStartupInfoExRec;
  HandleList: array[0..1] of THandle;
  Size: NativeUInt;
begin
  FillChar(Startup, SizeOf(Startup), 0);
  HandleList[0] := NullHandle;         // bInheritHandle = True で作成済み
  HandleList[1] := ErrorHandle;        // 同様
  Size := 0;
  InitializeProcThreadAttributeList(nil, 1, 0, Size);
  GetMem(Startup.AttributeList, Size);
  InitializeProcThreadAttributeList(Startup.AttributeList, 1, 0, Size);
  UpdateProcThreadAttribute(Startup.AttributeList, 0, HANDLE_LIST_ATTRIBUTE,
    @HandleList[0], SizeOf(HandleList), nil, nil);
  Startup.StartupInfo.cb := SizeOf(Startup);
  // …dwFlags と hStdInput / hStdOutput / hStdError を通常通り設定…
  CreateProcess(PChar(Exe), PChar(CommandLine), nil, nil,
    True,                              // ハンドルリスト付きでも依然として必要
    CREATE_NO_WINDOW or EXTENDED_STARTUP_FLAG,
    nil, PChar(WorkDir), Startup.StartupInfo, ProcessInfo);
  // 子の終了後:DeleteProcThreadAttributeList、FreeMem、CloseHandle
end;

ブリッジスクリプトはもう 1 つの Windows の細部を足します。Python ランチャーは 2 つ目のインタープリタープロセスを起動でき、自分が作ったプロセスを終了させてもそれが走り続けます。エンジンにスクリプトパスを与えるとき、下の RapidOCR アダプターのように、HotPDF は子をサスペンド状態で起動し、JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE 付きのジョブオブジェクトへ割り当て、それから再開するため、ジョブを閉じるとツリー全体が終わります。同じ封じ込めの発想が、子が信頼されないワーカープロセスでの PDF 画像コーデックの隔離を駆動します。ここでは子は信頼されますが、ハンドルを保持しているのは自分のプロセスだけではありません

キャンセル、タイムアウト、全か無かのコミット

IHPDFOCREngine.Recognize は 1 つの Boolean を返し、False は「Tesseract が失敗した」と「ユーザーが Cancel を押した」の両方をカバーします。アダプターは Tesseract が走っている間、トークンとタイムアウトを 25 ミリ秒ごとにポーリングし、キャンセルでは後片付けをして False を返します。額面通りなら otlsEngineError と読めるため、ApplyLoadedOCRTextLayer は Recognize が False を返すたびにまずトークンを確認します。認識とコンテンツ構築は、ドキュメントトランザクションが開く前に要求された全ページに対して終わるため、50 ページ中 40 ページでのキャンセルは otlsCancelled を報告し、ロードされた 50 ページ全部をそのまま残します。他の上限も同じ流儀に従います:

  • タイムアウトは各 Recognize 呼び出しの開始から走るため、60,000 ms のデフォルトはページごとの上限です
  • タイムアウトやキャンセルの時点でまだ走っている子は終了させられ、最大 5 秒待ち合わせます。プライベートディレクトリーは finally ブロックの中で削除され、プロセス終了後も出力ファイルが短時間ロックされたままになり得るため、削除は最大 1 秒リトライされます
  • output.tsv は 64 MiB に、stderr.txt は 1 MiB に打ち止められ、子の走行中と終了後に検査されます
  • ページあたりの単語数と UTF-16 コード単位は、MaxWordsPerPage、MaxTotalWords、MaxTextCodeUnits の残りで打ち止められます。越えるとリストを切り詰める代わりに実行を失敗させます
  • ゼロでない終了コードは、stderr.txt の最大 4,096 文字付きで返ってきます。.traineddata ファイルが欠けていると知る最速の方法です

不可視テキストレイヤーはページの内側でどう見えるのか

HotPDF は単語をテキストレンダリングモード 3(フィルもストロークもしない、ISO 32000-1 §9.3.6)で書くため、スキャンは見えたまま、検索とコピーは単語の上で動きます。コンテンツストリームは 3 Tr 付きで BT を開き、各単語はベースラインに Tm マトリクス、ボックス高さピクセル × 72 ÷ レンダリング DPI のフォントサイズ、ランをボックス幅へ伸ばす Tz スケールを受け取ります。この伸びが、画像の中のその単語の上に検索ハイライトを保ちます

TSV にはボックスはあってもベースラインがないため、各ベースラインは下端からボックス高さの 5 分の 1 上に置かれます。ラテンやキリルのようなスペース区切りの文字体系の左から右の行で隣接する単語の間では、伸ばしたスペースが隙間を埋めるため、コピーされたテキストは単語区切りを保ちます。中国語にはそれがありません。グリフは Identity-H エンコーディングと生成された ToUnicode CMap を持つ共有の非埋め込み Type0 フォントを通り、異なる Unicode スカラーごとに 1 つの CID が割り当てられます。中国語、アクセント付きラテン、補助平面の文字がコピーと検索を生き延びるのはこのやり方です。1 つのランは最大 65,535 の異なるスカラーを保持し、ISO 19005 は埋め込みフォントを要求するため、PDF/A 出力には適合フォントを別途埋め込む必要があります。結果を確認するには、保存し、再ロードし、ロード済み PDF からテキストを抽出するの普通の経路を走らせます。期待した単語が期待したページから返ってくれば、レイヤーは本物です

同じ契約の上の RapidOCR とネイティブ DLL

簡体字中国語のスキャンには、HPDFCreateRapidOCREngine(PythonExecutable, BridgeScript, ModelDirectory, TimeoutMilliseconds) が同じプロセスランナーと TSV パーサーを再利用します。ブリッジスクリプトパスは Python 実行ファイルに従い、言語は chi_sim に固定です。HotPDF はブリッジを tools/OCR/rapidocr_tsv.py として同梱します。rapidocr と onnxruntime パッケージ、ローカルの ONNX モデル、フォントファイルが要り、自動ダウンロードを拒否し、Tesseract 形状の TSV を書きます。THPDFRapidOCROptions オーバーロードがモデルファイル、スレッド数、ピクセル上限を設定します。両アダプターの背後の汎用ファクトリー、HPDFLocalTSVRecognition の HPDFCreateLocalTSVOCREngine は、Tesseract 流の引数を受け取り 12 列の TSV を書く任意のプログラムを受け入れ、ハンドルリスト、タイムアウト、キャンセル、予算を継承します

HPDFCreateTesseractDLLOCREngine(LibraryPath, TessDataDirectory, Language) はその代わりに Tesseract C API をインプロセスでロードし、Tesseract (native DLL) と報告し、要求されたすべての .traineddata ファイルが存在することを事前に確認し、キャンセルとタイムアウトを Tesseract 自身のプログレスモニターへ写します。ページごとにプロセス起動 1 回と BMP 往復 1 回が節約できます。実行ファイル方式は認識器のクラッシュとメモリー増加をあなたのアドレス空間の外に保ちます。ユーザー自身のスキャンを扱うデスクトップツールには DLL で構いません。見知らぬ人からドキュメントを取り込むサービスには、コマンドラインエンジンのほうが安全な選択です。どちらでも、認識は Windows 上で、1 ページずつ、レンダリングされたビットマップの上で走るため、難しいスキャンはエンジンへ届く前に傾き補正とクリーンアップを済ませてください

OCR アダプター、テキストレイヤーライター、それらに供給するレンダラー、出力を検査する抽出は、Delphi と C++Builder 向けの 1 つのネイティブ VCL コンポーネントの中に住んでいます。キャプチャーやアーカイブアプリケーションに検索可能 PDF 出力が要るなら、HotPDF Delphi PDF コンポーネントが、自分でインストールする OCR エンジン以外のすべてをカバーします