技術記事

Delphiでスキャン済みPDFに検索可能なテキストレイヤーを追加する

PDFium Componentは、DelphiからApplyOcrSearchLayerを通じて、スキャン済みPDFのページに検索可能なテキストレイヤーを追加します。選択した各ページをレンダリングし、そのピクセルを利用者側が用意するOCRプロバイダーに渡し、認識された単語を、スキャン内の単語の上に位置づけた不可視のテキストオブジェクトとして書き戻します。元のページ画像はデコードも再エンコードも置き換えも一切行われないため、見た目の結果は最初のページとバイト単位で完全に一致します

認識エンジンは、意図的にライブラリの一部にはなっていません。PDFiumはページのレンダリング、座標マッピング、フォントの読み込み、テキストオブジェクトの生成、不可視レンダリングモードを提供しますが、OCRエンジンそのものは含んでいません。それを含んでいるかのように振る舞うことは、誰かの認識製品をPDFコンポーネントに同梱することを意味してしまいます。代わりに、認識処理はIPdfOcrProviderインターフェースの向こう側に存在します。ライブラリは固定レイアウトの、上端を原点とするBGRAピクセルを渡し、プロバイダーはUnicodeテキスト、信頼度の値、単語の四角形を返します

検索可能なテキストレイヤーとは正確には何か?

スキャン済みのPDFとは、ドキュメントを写した1枚の写真です。ページのコンテンツは1枚の大きな画像であり、選択・検索・コピー・索引付けの対象となるものは何もありません。検索可能なテキストレイヤーは、その画像の上に、レンダリングモードを不可視に設定した本物のテキストオブジェクトを追加します。そのため、ビューアは何も描画しませんが、選択・検索・抽出は単語が実際に現れている場所を正確に見つけ出します

すべては位置づけにかかっています。不可視のテキストが数ポイントでもずれていると、選択のハイライトは単語の上ではなく脇に現れてしまい、段落をコピーすると単語の順序が入れ替わったテキストが得られます。だからこそ、そのジオメトリは、比例的な推測ではなく、PDFiumがページをレンダリングするのに使うのと同じ変換から得なければなりません

プロバイダーを実装する

プロバイダーの契約はメソッド1つです。寸法、ストライド、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行を保持する。
  // これをエンジンに渡し、認識された単語ごとに1エントリを埋めていく
  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は4つの頂点を保持し、傾いたり回転したりした単語でも正確な選択領域を維持できるようにしています。軸に沿った矩形しか報告できないエンジンは、縮退した四角形を構築するFromRectangleを使用できます

なぜ単語の位置を比例的にスケーリングできないのか?

ピクセル座標をページ座標に変換するのに、レンダリング幅で割ってからページ幅を掛けるという方法は魅力的に見えます。しかしそれがうまくいくのは、回転がなく、CropBoxMediaBoxと同一で、原点がゼロにあるページに限られます。多くのスキャン済みドキュメントは、これらの条件のうち少なくとも1つを満たしていません

PDFium Componentは、四角形の4つの頂点それぞれを、FPDF_DeviceToPageを通じて個別にマッピングします。これは、ピクセルを生成したレンダラーが使ったのと同じマッピングであるため、/Rotateエントリやオフセット付きのクロップボックスも、仕組み上自然に処理されます。テキストオブジェクトのアフィン行列は、マッピングされた頂点のうち3つ——左下、右下、左上——から構築されます。これは、位置、拡大縮小、回転、せん断を表現するのにちょうど十分な情報です

テキストオブジェクト自体は、実際のフォントの境界を測定できるように、単位フォントサイズで作成されます。測定されたオブジェクトの境界は、その後ターゲットの四角形へマッピングされます。推測したポイントサイズでサイズを決め、スキャンされた単語に合致することを期待するようなやり方では、フォントが置き換わるたびにずれが生じてしまいます。先に測定することで、レイヤーがどのフォントを使っているかに関係なく、フィットが決まります

ドキュメントに対して実行する

オプションレコードは、解像度、フィルタリング、そしてあらゆる予算を制御します。信頼度によるフィルタリングは、見た目以上に重要です。信頼度の低いゴミのような単語は、検索結果を永続的に汚染してしまいます。誤ったレンダリングとは違い、検索がでたらめな結果を返すまで誰も気づきません:

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;     // 1ページの失敗でジョブ全体を止めてはならない
    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を実行すると、2つ目のテキストレイヤーが追加されてしまい、重複によって抽出処理はすべての単語を2回ずつ返すようになります。ページごとのステータスpopsSkippedExistingTextは、どのページが手つかずのまま残されたかを正確に教えてくれます

予算、キャンセル、失敗の封じ込め

悪意のある、あるいは単に巨大なドキュメントが膨れ上がらせ得るあらゆる量には上限があります。ページごとおよび合計のピクセル数、ページごとおよび合計の単語数、単語あたりの文字数です。これらはすべて、ページが書き込まれた後ではなく前にチェックされ、ピクセル数の見積もりは、ビットマップを確保する前に、ページ寸法とDPIから計算されます。DPIを150から300に上げると、ページあたりのメモリ使用量は4倍になります。そのため、大きな判型でバッチジョブが失敗し始めた場合、まず調整すべきパラメータはページごとの上限です

キャンセルトークンは、プログレッシブレンダリング、プロバイダーの呼び出し、単語ごとの挿入ループという処理経路全体を貫いています。つまり、400ページのファイルの認識処理中にキャンセルしたユーザーは、ドキュメントの最後まで待たされることなく、1ページ以内で処理が止まります。コンポーネント内の他の場所で使われているのと同じトークンのパターンが、キャンセル可能なプログレッシブレンダリングで説明したとおり、ここでも変わらず適用されています

失敗の封じ込めはページ単位で行われます。ライブラリは、あるページに挿入したオブジェクトハンドルを収集し、すべての単語が配置された後に、一度だけFPDFPage_GenerateContentを呼び出します。プロバイダーのエラーであれフォントの問題であれ、途中で何かが失敗した場合、そのページに挿入されたオブジェクトは逆順に取り除かれ、ページのコンテンツは再生成されます。そのため、失敗したページは、中途半端なテキストレイヤーを抱えたままになるのではなく、元の状態に戻ります。その後、ドキュメント全体のループはContinueOnErrorに従って続行または停止し、現在のページは常に元の状態に復元されます

画像が本当に無傷であったことを検証する

利用できる最も強力なチェックは、同時に最もシンプルなものでもあります。レイヤーを適用する前と後で、同じサイズでページをレンダリングし、ビットマップを比較してください。不可視のテキストは何も描画せず、画像ストリームは一切デコードされないため、両者はバイト単位で完全に一致するはずです。何か違いがあれば、テキストレイヤー以外の何かがページを変更したということです

その後、処理済みファイルからテキストを抽出し、単語の位置がスキャン画像上に正しく重なっていることを確認して、テキスト側を検証してください。この抽出処理の経路は、PDFドキュメントからのテキスト抽出で説明したものと同じです。位置合わせを手早く目視確認するには、PDFページのJPEG変換で説明したようにページを画像にレンダリングすれば、単語のボックスをスキャン画像に重ねて表示できます

OCRレイヤーの追加、レンダリング、抽出、編集は、いずれもDelphi、C++Builder、Lazarusにおいて同じドキュメントオブジェクトに対して動作します。完全なAPIの詳細はPDFium Component for Delphiページに掲載されています