技術記事

PDFium VCLでDelphiから構造化PDFテキスト抽出

PDFiumPasは、ページのテキストを単なる文字列ではなく構造として返します。GetStructuredTextは、ブロックを含むTPdfStructuredTextPageを生成し、各ブロックは行を保持し、各行はスタイル付きスパンを保持します。すべての階層でページ空間上の境界が付与され、ソースの文字インデックスも保持されるため、どの断片も元のテキストページへマッピングし直すことができます

ほとんどのコードが最初に使うフラットな文字列としての抽出も、今も存在しており、その用途においては今も正しい方法です。それが物足りなくなるのは、どの単語が見出しだったのか、どれが左の列に属していたのか、あるいはマッチした箇所がページ上のどこに実際にあるのかを知る必要が生じた瞬間です

ほとんどの用途において、フラットな文字列が間違った出力形式であるのはなぜか

なぜなら、人が抽出したテキストに対して投げかける問いは、ほとんど「このページにどんな文字があるか」ではないからです。実際の問いは「タイトルは何か」「これは表なのか」「この段落はセクション4に属するのか」「ハイライトはどこに描けばよいのか」といったものです。単一の文字列はそのどれにも答えず、そこから再構築するすべての答えは、自分が抱え込むことになるヒューリスティックです

2段組みのレイアウトが、この点を具体的に示してくれます。2段組みの記事を文字列として抽出すると、生成側がコンテンツストリームをどう書いたかによって、1段目に続いて2段目が得られることもあれば、1段目の1行目、2段目の1行目、1段目の2行目、というようにページを下っていく形で得られることもあります。どちらも準拠したPDFから出てきます。フォーマットのレベルではどちらも誤りではありません。PDFはページ上のマークを記述するものであって、ドキュメントのアウトラインを記述するものではないからです。ブロックベースのモデルであれば、抽出器が順序の判断を明示的に行い、どの判断を下したのかを教えてくれます

コンテンツ順序か物理レイアウトか

TPdfStructuredTextOptions.ReadingOrderroContentOrderroPhysicalLayoutのどちらかを選択し、どちらが正しいかは、生成側とジオメトリのどちらをより信頼するかによって決まります

コンテンツ順序は、コンテンツストリームが描画する順序のままテキストを返します。これは高速であり、行儀の良い生成側が作成したドキュメントでは、通常は意図された読み取り順序と一致します。物理レイアウトはストリームの順序を無視し、文字が実際に置かれている位置から順序を再構築し、行にクラスタリングし、次に段にクラスタリングします。これが求められるのは、スキャンしてOCRにかけたページ、読み取り順序ではなくフォント順にテキストを出力するツールからの出力、そして視覚的な結果だけが頼りにできるあらゆる場面です

uses
  PDFium;

var
  Pdf: TPdf;
  Options: TPdfStructuredTextOptions;
  Page: TPdfStructuredTextPage;
  B, L: Integer;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := 'article.pdf';
    Pdf.LoadDocument;
    Pdf.PageNumber := 1;                     // 1始まり

    Options := TPdfStructuredTextOptions.Default;
    Options.ReadingOrder := roPhysicalLayout;
    Options.IncludeFontInfo := True;
    Options.IncludeSemantics := True;
    Options.MaxCharacters := 200000;         // フェイルクローズな予算

    Page := Pdf.GetStructuredText(Options);

    for B := 0 to High(Page.Blocks) do
    begin
      if Page.Blocks[B].Kind = cfHeading then
        Emit(Format('H%d: %s',
          [Page.Blocks[B].HeadingLevel, Page.Blocks[B].Text]))
      else
        for L := 0 to High(Page.Blocks[B].Lines) do
          Emit(Page.Blocks[B].Lines[L].Text);
    end;
  finally
    Pdf.Free;
  end;
end;

タグ付けが加えるもので、ジオメトリでは得られないものは何か

意図です。IncludeSemanticsを有効にすると、タグ付きPDFから得られるブロックは、構造ツリーから得たKindを持つようになります。つまり、見出しが見出しであるのは、フォントが平均より大きかったからではなく、生成側がそう言ったからです。この種別は、再利用にとって重要な形状をカバーしています。cfParagraphHeadingLevelを伴うcfHeadingcfListItemcfTableCellcfCaptioncfFigure、そしてタグなしのフォールバックであるcfPlainです

Sourceフィールドは、各分類がどこから得られたかを記録します。構造ツリーからはrosStructure、推定からはrosHeuristicであり、これはドキュメント集合全体にわたって抽出パイプラインをどこまで信頼するかを判断する際にログへ残すべきフィールドです。図は知っておく価値のある特殊なケースです。cfFigureブロックでは、図自体には固有の文字が存在しないため、テキストはグリフではなく代替説明から得られます。マッチしない代替テキストも、破棄されるのではなくそのまま表現されるため、ページ上に何も描かれていない場合でも、アクセシビリティ監査は説明が存在することを確認できます。タグ付けモデル自体はPDF/UA構造ツリー検証で扱っています

スパンはスタイル情報と出所情報を運ぶ

TPdfStructuredTextSpanは、そのテキスト、ページ空間上の境界、FontNameFontSizeFontWeightAngle、そしてSourceStartIndexSourceCharacterCountを保持します。スパンはスタイルが変わる場所で分割されるため、太字の単語が3つ含まれる文は3つのスパンになり、HTMLやMarkdownで強調を再構築するのは、フォント名から推測することではなく、プロパティを読み取るだけの作業になります

この2つのソースインデックスフィールドこそが、抽出処理を単なるレポートではなく機能に変えているものです。これらはページの文字シーケンスへ逆に指し示しているため、検索でマッチしたブロックを、テキストに対して2回目の、順序の異なるパスを行うことなく、文字レベルの選択ジオメトリやハイライト用の矩形へ変換できます。その仕組みは文字ボックスによる視覚的なテキスト行選択で説明しています。Angleフィールドは見た目以上に重要です。スタンプや透かしの中の回転したテキストは、本文と同じ座標空間に収まるため、角度を無視するパイプラインは、斜めの「DRAFT」を段落の途中へ何食わぬ顔で結合してしまいます

予算と、2つの品質カウンター

MaxCharactersは切り詰め設定ではなくフェイルクローズな予算です。これを超えるページは、コンテンツの一部を静かに返すのではなく、処理を停止します。信頼できない取り込み経路においては、これこそが求められる挙動です。100万文字を持つページは、機械生成された怪物であるか、自分の抽出処理をシステムの中で最も遅い部分にしてやろうとする試みのどちらかだからです

返されるページにある2つのカウンターは、抽出品質を直接表しています。UnmappedCharacterCountは、使用可能なUnicodeマッピングを持たない文字を数えます。これは、/ToUnicode CMapなしで埋め込まれたサブセットフォントの典型的な症状であり、そうしたテキストは完璧にレンダリングされる一方で、抽出しても何の役にも立ちません。GeometryFailureCountは、境界ボックスを決定できなかった文字を数え、これは物理レイアウトの順序付けを劣化させます。両方をログに残してください。これらの数値が一貫してほぼゼロであるドキュメント集合は自信を持ってインデックス化でき、そうでない集合は、下流の結果を信頼できるものにする前に、自分のパイプライン内のいくつかの生成側に注意が必要であることを教えてくれています

var
  Page: TPdfStructuredTextPage;
  B, S, L: Integer;
  Emphasised: Boolean;
begin
  Page := Pdf.GetStructuredText(Options);

  if Page.UnmappedCharacterCount > 0 then
    Log(Format('page %d: %d characters without a Unicode mapping',
      [Page.PageNumber, Page.UnmappedCharacterCount]));
  if Page.GeometryFailureCount > 0 then
    Log(Format('page %d: %d characters without geometry',
      [Page.PageNumber, Page.GeometryFailureCount]));

  for B := 0 to High(Page.Blocks) do
    for L := 0 to High(Page.Blocks[B].Lines) do
      for S := 0 to High(Page.Blocks[B].Lines[L].Spans) do
      begin
        Emphasised := Page.Blocks[B].Lines[L].Spans[S].FontWeight >= 600;
        AppendRun(Page.Blocks[B].Lines[L].Spans[S].Text, Emphasised,
          Page.Blocks[B].Lines[L].Spans[S].SourceStartIndex);
      end;
end;

実際のページにおける性能

物理レイアウトによる抽出はコストの高いモードであり、実装は実際に大きなページを想定して作られています。文字の順序付けは繰り返しスキャンではなくO(n log n)で実行され、行とスパンのバッファは文字ごとに再確保するのではなく幾何級数的に拡大し、Unicodeテキストは文字列連結ではなくバッファ内で構築され、隣接するテキストオブジェクトに対するフォント検索はキャッシュされます。この組み合わせによって、5,000文字の密なページであっても、二乗のオーダーになることなく予測可能な速度を保てます

ページ数の多いジョブでは、可能な限り安価なモードを選ぶ価値がなお残ります。信頼しているタグ付きドキュメントには、セマンティクスを有効にしたroContentOrderを使い、ジオメトリだけが頼りとなるスキャン済みやレガシーな素材にはroPhysicalLayoutを取っておいてください。必要なのが単なる文字列だけなら、PDFドキュメントからのテキスト抽出で説明しているよりシンプルなAPIのほうが依然として高速な経路であり、テキストをマークコンテンツ識別子までたどる必要がある場合は、BDCとMCIDマークコンテンツの読み書きがその層を扱っています

このブロックモデルは、検索パイプラインが求めるものにもきれいに対応します。段落を伴う見出しはタイトル付きのチャンクであり、境界情報によって引用がドキュメント単位ではなくページ上の位置を指し示せるようになります。PDFiumPasは、PDFiumエンジンを取り巻くDelphi・Lazarus向けコンポーネントであり、PDFium Delphiコンポーネントページにサンプル付きで説明されています