技術記事

Delphiで両端揃えテキストの表誤検出を修正する

PDFium Component version 3.117.0は、すべての列境界が、それが隔てるすべての行にテキストのない垂直の回廊であることを要求し、罫線グリッドがすでに主張した単語を飛ばし、セルのテキストをグリフボックスの中心距離ではなく縦の重なりで組み立てることで、両端揃えの段落を空白整列の表として報告するのを止めました。3つの変更はすべてExtractTablesとExtractDocumentTablesの内側にあり、オプションは不要です

これを始めた報告は地味なものでした。表がどこにもないプレスリリースのページがExtractTablesから5x4の空白表として返り、信頼度は既定のMinConfidenceである0.5を余裕で上回り、セルには普通の本文テキストの断片が入っていました。ある入会申込書も同様に、その作文の段落から3x4と5x3を生成しました。どちらの文書も両端揃えで組まれていました。明白な対応はしきい値を調整することですが、このリリースから得られる役立つ教訓は、調整では直せないということです。調整していたルールが間違った問いをしていたからです

uses
  PDFium;

// リグレッションチェック:文書内の空白表をすべて列挙し、散文だけだと
// 分かっているページがきれいであることを確認できるようにする
procedure ReportWhitespaceTables(Pdf: TPdf);
var
  Options: TPdfTableExtractionOptions;
  Tables: TPdfTables;
  I: Integer;
begin
  Options := TPdfTableExtractionOptions.Default;   // MinColumnGap 12pt
  Tables := Pdf.ExtractDocumentTables(Options);
  for I := 0 to High(Tables) do
    if Tables[I].DetectionMode = ptdmWhitespace then
      Writeln(Format('page %d: %dx%d whitespace table, confidence %.2f, ' +
        'first cell "%s"',
        [Tables[I].PageNumber, Tables[I].RowCount, Tables[I].ColumnCount,
         Tables[I].Confidence, Tables[I].Cells[0].Text]));
end;

両端揃えのテキストが表に見える理由

両端揃えの段落が表に見えるのは、両端揃えの行が、レイアウトエンジンが引き伸ばした間隔で区切られた単語の並びであり、引き伸ばされた間隔がMinColumnGapに達した時点で、検出器にはそれを列区切りと区別する行ローカルの手段がないからです。PDFium Componentの空白戦略は、単語ボックスを視覚的な行にまとめ、前行との水平距離がMinColumnGap(既定12ポイント)以上になる位置で各行を単語グループに分割し、少なくとも2つの連続する行がMinColumns個以上の左揃えのグループアンカーをAlignmentTolerance(3ポイント)以内で繰り返すときに表として受け入れます。これは表の検出の概要で説明したルールで、本物の整列した表に対してはまさに正しいものです

これを、両端揃えの10ポイントの散文20行に適用してみます。すべての行が同じ右マージンまで引き伸ばされるため、長い単語で終わる行は内部の空白を押し広げ、短い行がいくつかある段落ではそのうちのいくつかが12ポイントを超えます。連続する2行がそれぞれ1つずつ引き伸ばされた間隔を持ち、同じX位置から3ポイント以内に収まれば、2行2列の候補ができあがります。行が十分にあれば、これは不運ではなく、確実に近づく確率です。プレスリリースの5x4は、そうした間隔が4つ、5行の上で揃った回にすぎませんでした

PDFium Componentの、両端揃えの散文が表として採点された理由の図:すべての行が同じマージンまで引き伸ばされるため、単一の間隔が行ごとに違うXでMinColumnGapを超え、AlignmentTolerance以内の2つの連続する間隔が、今は回廊テストが拒否する偽の候補を作り上げていました
本物の表は列アンカーを行ごとに繰り返しますが、両端揃えの段落は行ごとに違う空白を引き伸ばします。だからこそ行レベルの調整だけでは両者を分けられませんでした

どのしきい値も、ある文書クラスと別の文書クラスを引き換えにします。MinColumnGapを20ポイントへ上げると、高密度の財務レポートの詰まった列を失います。それは既定値がすでに下げられていたまさにそのケースです。MinRowsを3に上げると本物の2行の表が捨てられ、長い段落の確率が下がるだけです。AlignmentToleranceを3ポイント未満に締めると、左端がそれ以上に揺れるOCR由来の単語ボックスが壊れます。行レベルの信号は本当に曖昧なので、修正は行が単独では運ばない信号から来なければなりません

列境界を本物にするもの

本物の列境界とは、それが隔てるすべての行にわたって空のままである、ページの垂直の帯です。表では、セルが共有のX位置に沿って配置されているため、構造として各列の組の間に1本あります。両端揃えの段落は行ごとに違う水平位置で単語間の空白を引き伸ばすため、1、2行を超える交差に耐える帯はありません。PDFium Componentは今まさにそれを検査します。候補の単語グループがアンカー列に割り当てられた後、隣り合う列の組ごとに、両方のセルに内容があるすべての行で、左セルの単語の右端から右セルの単語の左端までの区間を取り、それらの区間を行をまたいで交差させ、交差がMinColumnGapの0.5倍、既定では6ポイントより狭ければ候補全体を拒否します

PDFium ComponentのExtractTablesの背後にあるテキストのない回廊テストの図:各行が左セルの右端から右セルの左端までの区間を提供し、その交差は本物の表ではMinColumnGapの半分より広いまま残り、両端揃えのテキストでは何も残らずに潰れます
本物の列境界はそれが隔てるすべての行で空なので、行ごとの間隔を交差させると、表には共有される帯が残り、引き伸ばされた散文には帯がまったく残りません

2つの詳細が重要です。どちらかのセルが空の行は投票しないため、空のセルを持つ表や、本文より少ない列にまたがるヘッダーも通ります。そして回廊の幅は別のオプションとして公開されるのではなくMinColumnGapから導かれます。両者は同じ物理的なもの、つまり設計者が列間に残す間隔を記述しているからです。生の単語ボックスの上で表APIを使わずに構築しているなら、このロジックは再現できるほど小さいものです。下のサンプルはコンポーネント内のチェックを写したものです

uses
  Math, PDFium;

type
  TIndexList = array of Integer;
  TCellIndexes = array of TIndexList;   // Row * ColumnCount + Column

// 隣り合う列の組が、それを使う行にわたってMinColumnGap / 2以上の幅の
// テキストのない垂直回廊を持たない場合にFalseを返す
function HasTextFreeCorridors(const Words: TPdfWordBoxes;
  const Cells: TCellIndexes; RowCount, ColumnCount: Integer;
  MinColumnGap: Double): Boolean;
var
  Col, Row, I, LeftCell, RightCell, Supported: Integer;
  CorridorLeft, CorridorRight, RowLeft, RowRight: Double;
begin
  for Col := 0 to ColumnCount - 2 do
  begin
    CorridorLeft := -MaxDouble;
    CorridorRight := MaxDouble;
    Supported := 0;
    for Row := 0 to RowCount - 1 do
    begin
      LeftCell := Row * ColumnCount + Col;
      RightCell := LeftCell + 1;
      if (Length(Cells[LeftCell]) = 0) or (Length(Cells[RightCell]) = 0) then
        Continue;                             // 空のセルは投票しない
      RowLeft := -MaxDouble;
      RowRight := MaxDouble;
      for I in Cells[LeftCell] do
        RowLeft := Max(RowLeft, Words[I].Rect.Right);
      for I in Cells[RightCell] do
        RowRight := Min(RowRight, Words[I].Rect.Left);
      CorridorLeft := Max(CorridorLeft, RowLeft);
      CorridorRight := Min(CorridorRight, RowRight);
      Inc(Supported);
    end;
    if (Supported > 0) and
       (CorridorRight - CorridorLeft < MinColumnGap * 0.5) then
      Exit(False);
  end;
  Result := True;
end;

罫線表が2回抽出された理由

罫線表が2回抽出されたのは、空白のパスがページ上のすべての単語を見ていたからです。罫線のパスがすでにグリッドへ配置した単語も含まれ、きれいな罫線表は構造として完全に整列した空白表でもあります。既存の表の半分以上を境界が覆う空白候補を拒否する重なりチェックはすでにありましたが、表の下の数行をその下の整列したテキストと組み合わせた候補はその比率を下回り、隣へはみ出す少し大きい2つ目の表として生き残ることがありました。ExtractTablesは空白のパスが走る前にそれらの単語を取り除くようになりました。単語は、その中心点が罫線のパスが生成した何らかの表の境界の内側にあるときに落とされます。完全な包含ではなく中心を使うのは、罫線を1ポイントの何分かだけまたぐ単語が、視覚的に属する表に従うようにするためです。すると空白戦略は自由な単語だけを扱うことになり、罫線表のすぐ下にある小さな罫線なしの表が、上のグリッドと融合させられるのではなく、それ自身の資格で検出されるという意味もあります

「Purpose of Request:」が「of Purpose Request:」になった理由

単語が並べ替わって出てきたのは、PDFium Componentが構築する単語ボックスがグリフ境界ボックスの合併であり、「of」には下付き部分がなく「Purpose」と「Request:」にはあるからです。FPDFText_GetCharBoxは、フォントのアセントとディセントまで広げたボックスではなく、ページ空間におけるグリフのインクのタイトなボックスを返し、単語ボックスはその文字ボックスの合併です。したがって下付き部分のない単語は短く、その垂直中心は2から3ポイント高くなります。問題のフォームではそうでした。古いセルテキストのルーチンは単語を中心Yでまずソートし(「同じ行」の許容値1ポイント)、次に左端でソートしていました。「of」はその許容値を外れ、他の単語の上の独自の行としてソートされ、最初に出力されていました

これはPDFiumの癖というより、PDFがテキストをどう配置するかの帰結です。ISO 32000-1の9.2.2項と9.4.4項は、グリフの配置をテキスト空間におけるベースラインに沿った水平変位として定義しており、ファイルが運ぶ唯一の垂直メトリクスはフォント単位です。9.8.1項のフォントディスクリプターのAscent、Descent、FontBBoxエントリです。2つのグリフが同じ行を共有しているとファイルに書いてあるわけではなく、それは幾何から推測しなければなりません。PDFiumのchar boxによるテキスト行選択で説明した、選択ハイライトを正しく見せるタイトなグリフボックスは、中心距離の比較には間違った入力なのです

version 3.117.0の修正は、問いを「中心がどれだけ離れているか」から「ボックスが縦にどれだけ重なっているか」へ変えます。セルのテキストは、まずセルの単語を視覚的な行にまとめ、ある単語が行の中間境界との縦の重なりが2つの高さの小さいほうの25パーセント以上であればその行に加わり、次に各行を左端で挿入ソートし、その後で行を改行で結合することで組み立てられます。「Purpose」と「of」はxハイト全体で重なるため、短いほうのボックスの25パーセントをはるかに超え、同じ行に落ちて意図どおりXでソートされます

PDFium ComponentのPurpose of Requestの並び修正の図:FPDFText_GetCharBoxのタイトなグリフボックスは、下付き部分のないofに高い中心を与え、古い1 ptの中心Y許容値はそれを独自の行としてソートしていました。25パーセントの縦の重なりルールがそれをベースライン上に保ち、語順を復元します
中心Yはインクがたまたま持つアセントとディセントで動きますが、同じベースライン上の2つのボックスは、高さがどうであれ共有のxハイトで重なります

テキスト行は中心距離ではなく重なりでグループ化する

この不具合から持ち帰る価値のあるルールは一般的です。固定の許容値に対して垂直中心を比較して「同じ行」を決めるPDFテキストレイアウトのコードは、実在のフォントで失敗し、しかもその失敗は静かです。何もエラーにならず、単に単語が間違った順序で出てくるだけです。ディセントの混在は最も軽い引き金です。10ポイントの値の隣の太字12ポイントのラベル、上付きの脚注記号、フォールバックフォントから描かれた通貨記号、単語ごとの高さノイズのあるOCRの単語ボックスはすべて、12ポイントの行送りで10ポイントのテキストの隣接行をまだ分離できるどの許容値よりも大きく中心を動かします。重なりの比率はサイズ不変です。同じベースライン上の2つのボックスは、アセントとディセントがどうであれ共有のxハイトで重なり、隣接する行の2つのボックスはまったく重なりません

同じルールは表抽出の外でも簡単に適用できます。TPdf.PageWordBoxesはアクティブなページのすべての単語をページ空間の矩形つきで返すため、ページを視覚的な行にグループ化するのは短いループです

uses
  Math, PDFium;

function SameVisualLine(const A, B: TPdfRectangle): Boolean;
var
  Overlap, MinHeight: Double;
begin
  Overlap := Min(A.Top, B.Top) - Max(A.Bottom, B.Bottom);
  MinHeight := Min(A.Top - A.Bottom, B.Top - B.Bottom);
  Result := (MinHeight > 0) and (Overlap >= MinHeight * 0.25);
end;

procedure GroupPageIntoLines(Pdf: TPdf; out Lines: TArray<TPdfWordBoxes>);
var
  Words: TPdfWordBoxes;
  Bounds: TArray<TPdfRectangle>;   // 行ごとの中間的な合併
  I, J, Found: Integer;
begin
  Words := Pdf.PageWordBoxes;
  Lines := nil;
  Bounds := nil;
  for I := 0 to High(Words) do
  begin
    Found := -1;
    for J := High(Lines) downto 0 do
      if SameVisualLine(Bounds[J], Words[I].Rect) then
      begin
        Found := J;
        Break;
      end;
    if Found < 0 then
    begin
      SetLength(Lines, Length(Lines) + 1);
      SetLength(Bounds, Length(Bounds) + 1);
      Found := High(Lines);
      Bounds[Found] := Words[I].Rect;
    end;
    SetLength(Lines[Found], Length(Lines[Found]) + 1);
    Lines[Found][High(Lines[Found])] := Words[I];
    Bounds[Found].Left := Min(Bounds[Found].Left, Words[I].Rect.Left);
    Bounds[Found].Right := Max(Bounds[Found].Right, Words[I].Rect.Right);
    Bounds[Found].Top := Max(Bounds[Found].Top, Words[I].Rect.Top);
    Bounds[Found].Bottom := Min(Bounds[Found].Bottom, Words[I].Rect.Bottom);
  end;
  // 読み出す前に各行をRect.Leftでソートする。PageWordBoxesは単語を
  // コンテンツストリーム順で返し、それが視覚順である保証はない
end;

既存の呼び出し側に何が変わり、限界はどこか

あのスニペットの要点は述語であり、ループではありません。簡単なダンプ以上のことが必要なら、ブロック、行、読み順の情報源をすでに備えた構造化テキストモデルから始めてください。読み順つきの構造化PDFテキスト抽出で扱っています。既存の表の呼び出し側は、オプションに触れずに3つすべての修正を得ます。回廊のしきい値はMinColumnGapの半分に固定され、空白戦略はMinRowsが1に設定されても2行の下限を保ち(罫線戦略は1を受け付けるようになりました)、罫線優先の単語フィルタリングは両方の戦略が有効なときは常に無条件です。このリリースで使った13文書のサンプル集では、空白のパスは以前34個の断片と誤検出を罫線表9個と並べて返していました。リリース後は何も返さず、罫線表の数は41に増えましたが、その増加の大半は同じリリースが罫線検出器に塗りつぶし矩形として描かれた罫線を読ませるようになったことによるもので、それは別の話です

正直な限界です。回廊テストは、境界の両側に内容がある行が少なくとも1つないと何も拒否できないため、2つの引き伸ばされた間隔がたまたま6ポイント以内に収まる2行の候補は依然として通ります。それは以前のほぼ確実さではなく狭い偶然ですが、本物の2行の表を持たない散文主体の文書はMinRowsを3に設定して塞げます。左揃えの不揃いなテキストはもともと問題ではなく、影響を受けません。そしてPDFには依然として表オブジェクトがありません。ISO 32000-1 §14.8.4.3はTable構造要素を定義していますが、それを運ぶのはタグ付きPDFだけなので、それ以外ではグリッドは幾何からの推測のままであり、各TPdfTableに信頼度の値があるのは、推測にはスコアがふさわしいからです

表抽出、構造化テキスト、単語ボックスはすべて、Delphi、C++Builder、Lazarusで同じページモデルから読み取ります。TPdfTableExtractionOptionsとそれに同梱されるTableExtractionLabデモを含む完全なAPIは、PDFium Component for Delphiのページで説明しています