技術記事

PDFium Componentで塗りつぶし矩形を表の罫線として扱う

PDFium Componentの表抽出は、version 3.117.0から、薄い塗りつぶし矩形を表の罫線として扱います。既定で有効なDetectFilledRulingsをオンにすると、MaxRulingThickness(3ポイント)以下の太さの軸並行な塗りつぶしボックスは長軸方向の罫線1本になり、それより大きい塗りつぶしボックスは4辺を提供し、グリッドを組み立てる前に各罫線の座標がRulingSnapTolerance(4ポイント)以内でスナップされます。そのためWord、Google Docs、ブラウザからエクスポートされた表は、断片として空白検出に落ちるのではなく、完全なグリッドとして罫線検出器に届きます

表の検出と抽出の以前の記事では、罫線検出は描かれた線を使い、ストロークされた各パスセグメントがページ座標へ変換されると述べていました。その文は正しく、そして不完全でした。実世界のサンプル文書13件でパスオブジェクトを数えると、9件はストロークされたパスを一切含んでいないのに、各ページには0.5から1ポイントの太さの塗りつぶし矩形が数百個ありました。ストローク専用の検出器は何も見ず、すべてのページが空白検出に落ち、出力は表ではなく小さな断片の散らばりでした。3.116.4で追加されたcompact-columnsプリセットが断片レベルでそれを和らげましたが、根本原因は検出器が間違った描画演算子を読んでいたことでした

Wordからエクスポートした表にストロークされた線がない理由

ワープロは罫線を線として捉えず、幅を持つボックスとして捉え、そのボックスを塗りで描きます。ISO 32000-1の8.5.2.1項はre演算子を矩形サブパスの追加として定義し、8.5.3項は描画演算子を分けています。Sは現在の線幅でパスをストロークし、fはその内部を塗りつぶします。0.5ポイントのセル罫線はx y w 0.5 re fとして出てきて、ストロークの仕組み——線幅、結合、破線パターン——は一切走りません。セルの網掛けも同じ構造で、ボックスが大きいだけです。m、l、Sで描かれたストロークのグリッドこそ元の検出器が想定していたものであり、オフィスアプリケーションからエクスポートされるものはほとんどそれを生成しません

% ワープロ出力のセル罫線1本:高さ0.5 ptの塗りつぶしボックス
72 700 468 0.5 re f
% セルの網掛け:セルと同じ大きさの塗りつぶしボックス
72 676 117 24 re f
% 元の検出器が想定していたストロークのグリッド線
72 700 m 540 700 l S

FPDFPath_GetDrawModeにストロークフラグが立っているかだけを尋ねる検出器には、どちらの塗りつぶしボックスも見えません。セル内の単語は次に空白検出へ届きますが、そこでは6ポイントのガターで区切られた列が既定のMinColumnGapである12ポイントを下回り、返ってくるのはMinRowsを通るほど十分に整列した行の部分集合だけです。これが断片の挙動であり、パラメーターをどう調整しても、著者が描いたグリッドにはなりません

PDFium Componentは塗りつぶしボックスをどう罫線にするのか

TableCollectObjectRulingsはすべてのパスオブジェクトを、サブパス1つずつ調べます。描画モードはFPDFPath_GetDrawModeから取得し、DetectFilledRulingsがオンで塗りモードがnoneでない場合にパスを塗りつぶしと見なします。各点はオブジェクト行列を通して変換され、サブパスごとにMaxSubpathPoints(8)まで収集され、曲線セグメントがあればそのサブパスは曲線と印を付けられます。サブパスが閉じるか新しいMoveToが始まると、FlushSubpathがそれが何だったかを判断します。曲線のサブパスは破棄され、閉じた多角形で、少なくとも1つの軸においてすべての点が境界ボックスの辺からPointTolerance(0.05ポイント)以内に収まらないものも破棄されます。三角形やシェブロン、角丸のタブが罫線になることは決してなく、これが装飾的な図版をグリッドから締め出しています

PDFium ComponentのTableCollectObjectRulingsがDelphiで閉じたサブパスを表の罫線に変える図:FlushSubpathは曲線の輪郭と境界ボックスの辺から外れた多角形を破棄し、MaxRulingThicknessは薄いボックスを長軸ごとに1本の罫線へ分け、網掛けセルは4辺の罫線を与え、DetectFilledRulingsが小さな正方形を締め出します
閉じたサブパスが生き残るのは軸並行である場合だけで、あとは境界ボックスが、罫線1本なのか、網掛けセルの4辺なのか、それとも何でもないのかを決めます

生き残るのは軸並行な矩形で、境界ボックスによって分類されます。MaxRulingThickness以下の幅で高さがそれを上回る場合は、水平方向の中心に1本の垂直罫線が、ボックスを下から上まで覆う形で得られます。逆の場合は1本の水平罫線が得られます。両方の寸法がしきい値を上回る場合は網掛けセルで、ボックスは辺ごとに1本、合計4本の罫線を提供します。両方の寸法がしきい値以下なら何も提供しないため、2ポイントの正方形の行頭記号が線と誤認されることはありません。ストロークされたパスはAddLineを通る従来の経路を取り、軸並行なセグメントごとに1本の罫線になるため、Sで描かれたグリッドは従来どおり扱われ、塗りとストロークの両方で描かれたパスはマージのパスが畳み込む重複した断片を生みます

uses
  PDFium;

var
  Pdf: TPdf;
  Options: TPdfTableExtractionOptions;
  Tables: TPdfTables;
  Mode: string;
  I: Integer;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := 'itinerary-from-word.pdf';
    Pdf.LoadDocument;
    Pdf.PageNumber := 1;                     // 1始まり

    Options := TPdfTableExtractionOptions.Default;
    // これらは3.117.0の既定値で、明示のために記載
    Options.DetectFilledRulings := True;     // 薄い塗りつぶしボックスが罫線になる
    Options.MaxRulingThickness := 3.0;       // ポイント。これより厚いボックスは網掛けとして扱う
    Options.RulingSnapTolerance := 4.0;      // ポイント。0でスナップを無効化
    Options.IncludeFormXObjects := True;

    Tables := Pdf.ExtractTables(Options);
    for I := 0 to High(Tables) do
    begin
      if Tables[I].DetectionMode = ptdmRuled then
        Mode := 'ruled'
      else
        Mode := 'whitespace';
      Writeln(Format('%dx%d %s, confidence %.2f',
        [Tables[I].RowCount, Tables[I].ColumnCount, Mode,
         Tables[I].Confidence]));
    end;
  finally
    Pdf.Free;
  end;
end;

RulingSnapToleranceは網掛けセルの表に何をもたらすのか

RulingSnapToleranceは、網掛けだけで作られた表が1つのグリッドとして連結するようにするものです。エクスポートによっては罫線を一切描かず、すべてのセルがそれぞれの色の塗りつぶしボックスで、隣り合うボックスは1から3ポイントの白いガターで隔てられています。各ボックスは4辺の罫線を生みますが、あるセルの右辺と次のセルの左辺は2ポイント離れており、連結性の判定は既定1ポイントのRulingToleranceを使います。スナップがなければ、すべてのセルが4本の罫線からなる独自の連結成分を形成し、どの成分もMinRowsに届かず、ページは何も報告しません。TableSnapRulingsは関与するすべてのX座標(各垂直罫線の位置と、各水平罫線の始点と終点)と、同様にすべてのY座標を集め、各リストをソートし、隣の値との差が許容値を超えない値を連鎖させてクラスタ化し、各クラスタをその平均で置き換え、その後、すべての位置、始点、終点を最も近いクラスタ中心へ移動します。ガターの両側が同じ線になり、連結性が成立します

PDFium ComponentのRulingSnapToleranceがDelphiで網掛けセルの表を連結する図:隣り合うセルは2 ptのガターを残し、その辺の罫線は1 ptのRulingToleranceを超えて離れています。TableSnapRulingsが2つのX値を1つのクラスタ平均へ連鎖させることで、連結性の判定がようやく共有されたグリッド線を見ます
スナップはマージの前、そして罫線検出器の前に走るため、白いガターの両側が1本の線になり、すべてのセルが4本の罫線からなる島でなくなる

スナップはTableMergeRulingsの前に走ります。これは罫線をソートし、RulingTolerance以内で接するか重なる共線の断片を結合し、どちらもTableDetectRuledがデータを見る前に走るため、ペアごとの連結性チェックはセル単位の断片の数ではなくグリッド線の数に比例します。ストロークのグリッドではこれらのパスは無害です。もともと同一だった座標は自分自身にスナップするからです。気に留めておくべき1点は、連鎖クラスタリングに独自の幅制限がないことです。それぞれ3ポイント離れた座標の連なりは1つの中心へ潰れます。既定の4ポイントでは文字より狭い列にしか影響しませんが、本当に分けておくべき3ポイントのガターを持つ文書なら、許容値を下げるか0にしてスナップを切ってください

// 罫線戦略を単独で動かし、各設定が1ページで何を見るか比較する
function CountRuledTables(Pdf: TPdf; FilledRulings: Boolean;
  SnapTolerance: Double): Integer;
var
  Options: TPdfTableExtractionOptions;
begin
  Options := TPdfTableExtractionOptions.Default;
  Options.DetectWhitespaceTables := False;
  Options.DetectFilledRulings := FilledRulings;
  Options.RulingSnapTolerance := SnapTolerance;
  Result := Length(Pdf.ExtractTables(Options));
end;

// Wordの出力は典型的に0、N、そしてN未満を報告する:
// ストローク専用は何も見えず、スナップが網掛けセルを連結し、
// スナップを切ると各網掛けセルが独自の島のままになる
Writeln(CountRuledTables(Pdf, False, 4.0));
Writeln(CountRuledTables(Pdf, True, 4.0));
Writeln(CountRuledTables(Pdf, True, 0.0));

フォームXObject内の罫線

ページレイアウトツールは、表やページ本文全体をフォームXObjectで包み、Doで描くことがよくあります。ISO 32000-1の8.10.1項は、フォームが描かれるときにフォーム行列が現在の変換行列と連結されると規定しているため、フォーム内の矩形はフォーム空間に存在し、2つ以上の変換を経て初めてページ上に載ります。TableCollectObjectRulingsはIncludeFormXObjectsが設定されているとフォームオブジェクトへ再帰します。オブジェクト行列を読み、TableMultiplyMatrixで親行列と結合し、その引数順は「最初の行列で写してから2番目で写す」を意味します。そしてFPDFFormObj_CountObjectsとFPDFFormObj_GetObjectで子を列挙し、結合した行列を渡し下げます。MaxFormDepth(8)より深い入れ子は黙ってスキップされます。これは実際の出力が近づくことのない限界ではなく、病的なファイルに対するガードです。乗算の順序が重要な理由は行列のprependとappendで扱ったのと同じです。被演算子を入れ替えると平行移動の項が動き、ページの上部に載るべき罫線が原点に載ってしまいます

PDFium ComponentのフォームXObject内の罫線の図:72 700 468 0.5 re fとして書かれた薄い矩形はフォーム空間に存在し、TableMultiplyMatrixが親CTMとフォーム行列を結合して初めてページ上に載ります。再帰はFPDFFormObj_CountObjectsを通りMaxFormDepthまでです
矩形はフォーム空間で書かれており、平行移動の項をあるべき位置に保つ順序で行列を乗算して初めて、ページの上部に届きます

罫線の予算が4倍になった理由

既定のMaxRulingSegmentsが3.117.0で4096から16384へ上がったのは、セル単位の罫線がストロークのグリッド線よりはるかに多くの数で到着するからです。ストロークの30行6列の表は38本の線分です。同じ表を塗りつぶしボックスとしてエクスポートすると、セルあたり最大4本の罫線、マージ前で720個の断片になり、網掛けセルのあるフォームではそれが倍になります。そのような表が1ページに2つあれば、古い予算を使い切っていたでしょう。予算はTableAppendRulingの中でCheckを通して強制され、EPdfErrorを「Table ruling-segment budget exceeded」というメッセージで送出します。劣化した結果も部分的なグリッドもなく、空白のパスも走りません。信頼できない入力に対して独自のより厳しい予算を設定するなら、例外を捕捉して判断してください。空の結果を「表なし」と読まないようにするためです

Options := TPdfTableExtractionOptions.Default;
Options.MaxRulingSegments := 2048;        // 信頼できない入力向けに意図的に厳しく
try
  Tables := Pdf.ExtractTables(Options);
except
  on E: EPdfError do
  begin
    Log(E.Message);                       // 'Table ruling-segment budget exceeded'
    Options.MaxRulingSegments := 16384;   // 3.117.0の既定値
    Tables := Pdf.ExtractTables(Options);
  end;
end;

測定結果と、このアプローチが止まる場所

同じサンプル文書13件で、抽出は43個の表(うち罫線9個、空白の断片または誤検出34個)から、41個の罫線表と空白の誤検出ゼロになりました。この整理の一部は3.117.0の2つの併走する変更によるものです。罫線グリッドがすでに主張した単語は空白検出が走る前に取り除かれるため、表が二重に報告されることはありません。また空白の列境界は、それが隔てるすべての行を横断するテキストのない回廊でなければならなくなり、これが両端揃えの段落が5x4の表として採点されるのを止めました。塗りつぶし矩形の読み取りが、表そのものを断片の列から罫線の列へ移したものです

境界は率直に述べておく価値があります。テキストレイヤーのないページは依然としてグリッドの骨組みを、すべてのセルが空の状態で返します。罫線は幾何から来てテキストはテキストページから来るからです。スキャンページにはまずOCRが必要です。曲線や角丸、非矩形の輪郭を持つ塗りつぶし図形は完全に破棄されるため、罫線が角丸矩形の輪郭として描かれた表は従来どおり空白検出を必要とします。罫線も網掛けもない表はこれらによって何も変わらず、表抽出の記事で説明した空白戦略の領分のままです。それでも足りないときは、構造化テキストと読み順の単語ボックスとブロックがドメイン固有のリーダーの素材になります。コンポーネントに同梱されるTableExtractionLabデモはオプションパネルでDetectFilledRulingsを公開しており、あるエクスポートがオンとオフでどう見えるかを確かめる最短の方法です。APIの全体はPDFium Component for Delphiのページで説明しています