技術記事

DelphiでのPDFテキスト行選択とPDFium文字ボックス

PDFテキストページは文字とボックスを公開するが、行は決して公開しない。PDFium Componentは、垂直方向の中心がシード文字の高さの半分以内にある文字ボックスをクラスタリングし、クリックされた文字から外側にスキャンして許容範囲を超えたところで止めることで、視覚的な行を構築する。ビューア内のすべての選択パスはその1つのヘルパーを呼び出すので、マウス、キーボード、コードは一致する

これを探すきっかけになる症状は具体的で不快なものだ。ユーザーが2段組レポートの段落をトリプルクリックすると、ページの半分が選択されてしまう。あるいはテーブルセルをトリプルクリックすると、選択がその行全体とフッターのページ番号まで飲み込んでしまう。ビューアが壊れているわけではない。ファイルが答えられない問いを尋ねているのだ。PDFには選択すべき行は存在せず、それがあるふりをする実装はどれも推測しているにすぎない。この記事は、その推測を意図的なものにし、一貫したものにすることについてだ。実際に必要なものがドキュメントからのテキスト抽出であればPDFiumでDelphiのPDFドキュメントからテキストを抽出するを、テキストをレイアウトして幅が必要ならテキスト計測と折り返しを見てほしい。ここでの主題はもっと狭い:視覚的な行がどこで始まりどこで終わるかを決め、まさにそこを選択することだ

なぜPDFテキストページには行オブジェクトがないのか

PDFのコンテンツストリームは、構造ではなく描画を記述しているからだ。ISO 32000-1 §9.4は、テキストオブジェクトを、位置決めと表示のオペレータを含むBT/ETのペアとして定義している。§9.4.2の位置決めオペレータ(TdTDTmT*)はテキスト行列をページの周りに動かし、§9.4.3の表示オペレータ(TjTJ'")はその行列が現在指しているところにグリフを描画する。そのモデルの中には「このグリフの並びは1つの行だ」と述べるものは何もない。行は、描画が終わったあとに人間が見るものなのだ

プロデューサーは、あなたが制御できない方法でこれを悪化させる。両端揃えの段落は、行ごとに1つのTJ配列として出力されるかもしれないし、明示的なTmを各単語の前に伴うTj1つずつとして出力されるかもしれないし、間隔をカーニング調整で運ぶ1回の表示操作として出力されるかもしれない。2段組レイアウトは、左の列を上から下まで出力し、それから右の列を出力するかもしれないし、生成アプリケーションが自身の内部オブジェクトリストを別の順序で歩いた場合は両者をインターリーブするかもしれない。PDFiumがあなたに渡す文字の並びはコンテンツストリームに従い、コンテンツストリームは生成アプリケーションがそのとき何をしたいと思ったかに従う。だから実際に得られる生の語彙はFPDFText_CountChars(ページが保持する文字数を報告する)とFPDFText_GetCharBox(1文字のバウンディングボックスをページ空間で返す)の2つの関数だ。それが生の語彙のすべてだ。その上にある単語、行、段落、列はすべて、あなたが幾何学に対して行う推論だ

なぜCRとLFの検出は間違ったテストなのか

テストしようとする文字が信頼できる形で存在せず、存在するときも信頼できる形であなたのものではないからだ。PDFiumは、抽出されたテキストを読みやすくするために、テキストページに合成文字を注入する:2つの実行部分が視覚的に分離されている箇所には空白を、次の実行部分が新しいベースラインで始まる箇所にはCRまたはLFを挿入する。FPDFText_IsGeneratedは、まさにファイルから出てきた文字とそれらを区別できるように存在しており、PDFium ComponentはそれをCharacterGeneratedプロパティとして公開している

これらの文字で分割すると、PDFiumが合成する際に下したあらゆる判断を引き継ぐことになる。折り返された段落の中の強制改行とソフトラップは、合成後は同一に見える。プロデューサーがセルごとに出力したテーブル行は、最後のセルと次の行の最初のセルの間に改行が一切ないかもしれない。ベースラインがたまたま十分近いからだ。一方、異なるサイズのボディテキストが続く見出しは、人間が1つと見るところに2つの改行を得るかもしれない。合成文字は全ページ抽出のためのレンダリングの便宜であり、行モデルではない。そして選択が最も重要になるまさにそのドキュメントで劣化する

垂直方向の中心で文字ボックスをクラスタリングする

信頼できる信号は幾何学だ。ユーザーがクリックした文字をシードとし、そのボックスの垂直方向の中心を計算し、隣接するボックスがその垂直方向の中心を許容範囲内に保つ限り両方向に外側へ歩く。PDFium Componentは、そのシードボックスの高さの半分をその許容範囲として使い、ページ単位で0.5の下限を設ける。これにより、退化したボックス、ピリオド、細い空白、ほぼゼロの高さのボックスを持つグリフが許容範囲をゼロに崩壊させて1文字後に行を切ってしまうことを防ぐ

function TPdfView.LineRangeAt(TxtPage: FPDF_TEXTPAGE; CharIndex: Integer;
  out StartIndex, Count: Integer): Boolean;
var
  Lo, Hi, Total: Integer;
  SeedBox, Box: TPdfRectangle;
  SeedYMid, BoxYMid, HalfH: Double;
begin
  Result := False;
  StartIndex := -1;
  Count := 0;
  Total := FPDFText_CountChars(TxtPage);
  if (CharIndex < 0) or (CharIndex >= Total) then
    Exit;

  if FPDFText_GetCharBox(TxtPage, CharIndex, SeedBox.Left, SeedBox.Right,
    SeedBox.Bottom, SeedBox.Top) = 0 then
    Exit;
  SeedYMid := (SeedBox.Top + SeedBox.Bottom) / 2;
  HalfH := Abs(SeedBox.Top - SeedBox.Bottom) / 2;
  if HalfH < 0.5 then          // floor for degenerate boxes
    HalfH := 0.5;

  Lo := CharIndex;
  Hi := CharIndex;
  while Lo > 0 do
  begin
    if FPDFText_GetCharBox(TxtPage, Lo - 1, Box.Left, Box.Right,
      Box.Bottom, Box.Top) = 0 then
      Break;
    BoxYMid := (Box.Top + Box.Bottom) / 2;
    if Abs(BoxYMid - SeedYMid) > HalfH then
      Break;
    Dec(Lo);
  end;
  while Hi < Total - 1 do
  begin
    if FPDFText_GetCharBox(TxtPage, Hi + 1, Box.Left, Box.Right,
      Box.Bottom, Box.Top) = 0 then
      Break;
    BoxYMid := (Box.Top + Box.Bottom) / 2;
    if Abs(BoxYMid - SeedYMid) > HalfH then
      Break;
    Inc(Hi);
  end;
  StartIndex := Lo;
  Count := Hi - Lo + 1;
  Result := True;
end;

そのループの中で3つの細部が意味を持つ。許容範囲は定数からではなくシードから導出されるので、24ptの見出しは広い帯を得て、7ptの脚注テキストは狭い帯を得て、どちらも互いから文字を奪わない。比較はベースラインやボックスの上端ではなく垂直方向の中心を使うので、上付き文字、インラインの異なるサイズの実行部分、あるいはフォントが混在した文章も、その隣人と同じ行のままになる。そしてFPDFText_GetCharBoxの失敗はスキップされるのではなくスキャンを終了させる。取得できる幾何学を持たない文字は、どちらの方向にも証拠を与えないからであり、それを越えて続行することは、それより先にある文字の力を借りて本物の境界を飛び越えることを許してしまうだろう

なぜすべての選択パスは1つのヘルパーを共有しなければならないのか

それぞれが独自に「行」を実装する3つのコードパスは分岐し、しかも静かに分岐するからだ。PDFium Componentでは、トリプルクリックの拡張、Shift+HomeShift+End、そして公開されているSelectLineAtメソッドはすべて、同じLineRangeAt呼び出しを通じて自身の境界を解決する。トリプルクリックはそれを選択のアンカーからシードし、shiftキーは選択カーソルからシードして片端だけを動かし、SelectLineAtは呼び出し側が供給した文字インデックスからシードし、その結果をマウスパスが使うのと同じ範囲バリデータであるSelectTextRangeに渡す。代わりにロジックを複製すると、失敗はクラッシュではなく緩やかなドリフトになる。誰かが行間の詰まったレポートを修正するためにトリプルクリックの許容範囲を調整し、するとShift+Endは同じ段落でトリプルクリックが止まる位置より1文字手前で止まるようになる。ユーザーがマウスで行を選択し、キーボードでそれを拡張すると、選択が縮むのを見ることになる。SelectLineAtが通常の選択パイプラインに供給しているので、プログラムによる選択もまた、マウス入力が有効かどうかとは無関係のままであり、それでも範囲検証、再描画、そして無料でOnSelectionChange通知を得る

// Select the visual line under a client-space point, then read it back
procedure TForm1.SelectLineUnderCursor(X, Y: Integer);
var
  CharIndex: Integer;
begin
  CharIndex := PdfView1.CharacterIndexAtPos(X, Y, 6.0, 6.0);
  if CharIndex < 0 then
    Exit;
  if PdfView1.SelectLineAt(PdfView1.CurrentPage, CharIndex) then
    Memo1.Lines.Add(PdfView1.SelectedText);
end;

CharacterIndexAtPosの許容範囲の引数に注目してほしい。ヒットテストは独自の遊びをページ単位で持っており、それは行の許容範囲とは別の関心事だ。2つの行の間のリーディング(行間)に着地したクリックは、そのボックス内で最も近い文字に解決される。行のスキャンはその後、それがたまたま何であったかの文字から実行される。寛容すぎるヒット許容範囲をシードに供給することは、ユーザーが指していなかった行を選択してしまう最も簡単な方法の1つだ

2つのインデックス空間:文字インデックスとテキストインデックス

範囲を手に入れたら、それを文字列オフセットとして使いたくなる衝動に抵抗しよう。FPDFText_GetTextはページテキストをUTF-16バッファとして返すが、そのインデックスはFPDFText_GetCharBoxFPDFText_CountCharsが使う文字インデックスと同じインデックス空間ではない。前述の合成文字は、使える幾何学を持たない文字スロットを占めながらテキストバッファの中に座っており、この2つの番号付けはページをまたいで乖離していく。その橋渡しをするのがFPDFText_GetTextIndexFromCharIndexFPDFText_GetCharIndexFromTextIndexであり、PDFium ComponentはCharacterIndexToTextIndexTextIndexToCharacterIndexとしてラップしている

var
  TextStart, TextEnd: Integer;
begin
  // char-index range from LineRangeAt -> offsets into the page text buffer
  TextStart := Pdf.CharacterIndexToTextIndex(StartIndex);
  TextEnd   := Pdf.CharacterIndexToTextIndex(StartIndex + Count - 1);
  if (TextStart >= 0) and (TextEnd >= TextStart) then
    Caption := Pdf.Text(TextStart, TextEnd - TextStart + 1);
end;

最も強く噛みつく方向は逆方向だ。抽出された文字列に対して実装された検索はテキストインデックスを与え、それらをボックスや選択APIにそのまま渡すと、黙って間違った文字がアドレスされ、その誤差はページを下るほど大きくなる。何かが幾何学に触れる前にTextIndexToCharacterIndexで変換しよう。サロゲートペアはこの上にもう1つの独立したオフセット問題を追加しており、それは絵文字、CJK、サロゲートペアの記事で扱っている

このヒューリスティックが曲がるところ

自分自身に対してその限界について正直であるべきだ。それらは本物であり、到達可能だからだ。回転したテキストは最も明確なケースだ:文字ボックスはページ空間における軸並行の矩形なので、90度回転したテキストについては、1つの視覚的な行のボックスがページ全体に広がる垂直方向の中心を持つことになり、スキャンはほぼ即座に止まる。得られるのは間違った選択ではなく短い選択であり、それはより良い失敗モードだが、それでも失敗だ。縦書きモードも同じ理由で同じように振る舞う。2段組レイアウトは、列が互いに垂直方向にオフセットしている場合はうまく機能し、そうでない場合は壊れる。両方の列が同じベースライングリッドを共有していると、右の列の文字は左の列の行の許容範囲内に座ってしまい、純粋な幾何学の中には止まるべきものが何もないので、スキャンはまっすぐに溝を越えて走ってしまう。それを検出するには垂直方向のクラスタリングの上に水平方向のギャップテストが必要であり、そのギャップ閾値を選ぶこと自体が、どのドキュメントについて間違っていることを甘受するかについての独自の判断だ。混在フォントサイズは、シード相対の許容範囲がうまく処理するケースだ:ボディテキスト11ptの中のインライン8ptのコードスパンはその中心を帯の内側に保ち、次のベースラインにある24ptの見出しはボディの行を自分の中に引き込まない

ここで説明した行選択のセマンティクスは、この例で使われているヒットテスト、選択範囲、テキストインデックスのAPIと並んで、Delphi・C++Builder向けPDFium Componentに付属している。その製品ページには、テキストページと選択モデルの完全なリファレンスがある