技術記事

Delphiで変換後にPDFiumページオブジェクトハンドルが古くなる

FPDFPage_TransFormWithClipがページを書き換えるとき、すでに保持しているすべてのFPDF_PAGEOBJECTハンドルは、変換前のパースをそのまま記述し続ける。Delphi・C++Builder向けPDFium Componentは、これをTransformPageContentの内部で解決している。これはテキストページをアンロードし、コンテンツを再生成し、その後ページをリロードするので、以降のクエリは新しい座標を見ることになる

症状は静かだ。印刷用の余白を追加するために0.9のスケールを適用し、その後PageObjectInfoを読むと、呼び出し前とまったく同じ数値が返ってくる。例外もなく、エラーコードもなく、ログにも何もない。これは編集後にテキストページが古くなる記事で説明されているキャッシュされたテキストページとは異なる失敗だ:あちらはキャッシュが単一のFPDF_TEXTPAGEハンドルであり、それを捨てて再構築できる。ここでの問題は、自分自身の変数に保持しているすべてのページオブジェクトハンドルと、リターンコードを通じて失敗を報告しながらほとんどの呼び出し側がそれを捨ててしまう一群のゲッターだ

なぜページオブジェクトの境界はエラーなしに古くなるのか

ページオブジェクトハンドルは、ある特定のコンテンツストリームをパースした表現へのポインタであり、ページ全体の変換はそのコンテンツストリームを新しいものに置き換えるからだ。PDFiumは、パッチすべきハンドルを探してあなたのコールスタックを歩いたりはしない。新しいオブジェクトグラフを構築し、古いものをそのままの状態で残すので、古いハンドルに対する読み取りは、もはやファイルが述べていることに対応していない構造に対する完全に有効な読み取りになる

ISO 32000-1 §7.8.2は、コンテンツストリームをページを描画するオペレータの並びとして定義しており、§8.3.3は現在の変換行列がユーザー空間をどうデバイス空間にマッピングするかを定義している。ページレベルの変換は、オブジェクトごとの座標をその場で編集することによってではなく、それらのオペレータをラップして書き換えることによって表現される。だからオブジェクトが持つ座標はまったく変わらないかもしれない。変わるのは、それらが描画されるときに有効な行列だ。古い行列の下でパースされたハンドルは、古い行列の下での幾何学の質問に答え、それを何の異議もなく答え続ける

FPDFPage_TransFormWithClipが実際に書き換えるもの

それはページを書き換えるのであって、あなたのスナップショットではない。FPDFPage_TransFormWithClipFS_MATRIXFS_RECTFのクリップ矩形を取り、その両方をページコンテンツ全体に適用する。これは余白、面付けスケーリング、そして奇妙なサイズのページをターゲットボックスに対して正規化するための正しい呼び出しだ。これは既存のハンドルがついてくることを期待している場合には間違った呼び出しであり、それがページコンテンツだけに触れることも覚えておく価値がある:注釈は別の層であり、同じ6つの行列係数をFPDFPage_TransformAnnotsに転送するTransformPageAnnotationsが必要だ

var
  Info: TPdfPageObjectInfo;
  Scale: FS_MATRIX;
  Clip: TPdfRectangle;
begin
  Pdf.PageNumber:= 1;
  Info:= Pdf.PageObjectInfo(0);           // snapshot taken before the transform

  Scale.a:= 0.9;   Scale.b:= 0.0;
  Scale.c:= 0.0;   Scale.d:= 0.9;
  Scale.e:= 29.7;  Scale.f:= 42.0;        // 5% margin, A4 in points
  Clip:= Pdf.GetPageBox(pbMedia);
  Pdf.TransformPageContent(Scale, Clip);

  // Info.Bounds still holds pre-transform geometry, and Info.Handle now
  // points into a page that TransformPageContent has already replaced
end;

TransformPageContentが使うリフレッシュ順序

4つのステップを、この順序で:テキストページをアンロードし、変換し、コンテンツを生成し、ページをリロードする。TPdf.TransformPageContentはまさにその順序を実行する。CheckPageActiveを呼び出し、行列とクリップをそれぞれのネイティブなレコード形式にコピーし、UnloadTextPageを呼び出し、次にFPDFPage_TransFormWithClip、次にFPDFPage_GenerateContentのラッパーであるUpdatePageを呼び出し、最後にReloadPageを呼び出す

各ステップはそれぞれの位置に理由がある。UnloadTextPageが最初に来るのは、キャッシュされたFPDF_TEXTPAGEが古い行列の下で計算された文字ボックスを保持しているからであり、それはまた、それから派生していたWebリンクリストや進行中のfindセッションも捨てる。FPDFPage_GenerateContentはリロードの前に実行しなければならない。変換は、それがコンテンツストリームに直列化されて戻されるまでメモリ上のページの中に存在しており、それがなければリロードは未変更のストリームを再パースしてしまうからだ。ReloadPageは現在のページインデックスに対するFPDF_LoadPageで締めくくられ、これだけが実際に新しいオブジェクトグラフをあなたに与える

// After the transform, re-enumerate. Do not reuse anything captured earlier.
var
  I: Integer;
  Info: TPdfPageObjectInfo;
begin
  Pdf.TransformPageContent(Scale, Clip);   // unload text page, transform,
                                           // generate content, reload page
  for I:= 0 to Pdf.ObjectCount- 1 do
  begin
    Info:= Pdf.PageObjectInfo(I);          // handle and bounds from the new parse
    if Info.Bounds.Right> PageWidth then
      Log('object '+ IntToStr(I)+ ' still overflows after scaling');
  end;
end;

ReloadPageの中の1つの細部は、あなた自身がこのシーケンスを書くなら真似する価値がある。それは新しいページを最初に読み込み、その後でのみそれをフィールドにコミットする。だから失敗したページ読み込みは、半分に引き裂かれた状態に陥れる代わりに、現在のネイティブページとその派生キャッシュのすべてを無傷のまま残す。リロードは無料ではない——ページのフルな再パースにコストを払うことになるが、それはクエリごとではなく変換ごとに1回だけ支払われ、それより安い正しい代替手段はない

リロードをまたいでハンドルを持ち越してはいけない

リロードのあと、古いハンドルは単に古いだけではなく、ぶら下がった状態になる。以前のFPDF_PAGEは閉じられており、それに属していたFPDF_PAGEOBJECTの値は解放されたメモリへのポインタだ。TPdfPageObjectInfoはそのHandleフィールドにネイティブハンドルを公開しており、これは低レベルの呼び出しにオブジェクトをそのまま渡すのに本当に便利だが、コンテンツを再生成する操作をまたいでフォームフィールドやリストの中に保持しておくのも同じくらい本当に危険だ。スナップショットレコードは、コンテンツを再生成する次の呼び出しまでしか有効でないものとして扱おう。それはPDFium境界におけるABIとメモリ安全性のノートで論じられている所有権のルールと同じ精神だ

ゲッターは失敗しても有効なデータのように見え得るのか

できる、これが同じ問題の後半だ。FPDFPageObj_GetRotatedBoundsFPDFPageObj_GetIsActiveは出力パラメータ式のゲッターだ:intの成功フラグを返し、実際の答えを参照引数に書き込む。どちらも、作成されたがそのページがまだ再パースされていないオブジェクトについてFALSEを返すことがある。それが起きると出力パラメータは触れられないまま残り、Default(TPdfPageObjectInfo)で初期化されたPascalレコードはすべてゼロなので、呼び出し側は原点に4つの点がある四角形とActiveフラグがFalseであるものを見ることになる。失敗した呼び出しが黙ってもっともらしく見えるデータに昇格させられてしまったのだ

TPdfPageObjectInfoは、これに明示的な見張り値で応える。HasRotatedBoundsFPDFPageObj_GetRotatedBounds呼び出しの結果を運び、HasActiveStateFPDFPageObj_GetIsActive呼び出しの結果を運び、幾何学と状態のフィールドは対応する見張り値がTrueの場合にのみ書き込まれる。同じ形が、レコードの他の出力パラメータゲッターについても繰り返されており、HasMatrixHasFillColorHasStrokeColorHasStrokeWidthはどれも同じことを意味する:ネイティブ呼び出しが成功し、隣接するフィールドが意味を持つ、ということだ

Info:= Pdf.PageObjectInfo(I);

if Info.HasRotatedBounds then
  // RotatedBounds is array [1..4] of TPdfPoint, in draw order
  UseQuad(Info.RotatedBounds[1], Info.RotatedBounds[2],
          Info.RotatedBounds[3], Info.RotatedBounds[4])
else
  // the native call failed; fall back to the axis-aligned rectangle
  UseRect(Info.Bounds);

if Info.HasActiveState and (not Info.Active) then
  SkipObject(I);         // genuinely inactive
// if HasActiveState is False, the object state is unknown, not inactive

このパターンは、リターンコードとプラス出力パラメータの慣習に従うすべてのPDFiumゲッターに一般化され、その数は多い。ラッパーがその慣習を単純な関数結果に折りたたんでしまったら、「答えはゼロだ」と「答えが存在しない」を区別する唯一の信号を捨ててしまったことになる。フィールドごとに追加のブール値を1つ持つことは1バイトのコストだが、既定値のレコードが測定値と誤解されるバグのカテゴリ全体を取り除く

それでもこれが噛みつくところ

正直な限界が3つある。まず、リフレッシュはページ単位だ:2ページ目を変換しても1ページ目について保持しているハンドルは影響を受けないが、これで異なる時点でパースされた2つのページを持つことになり、どのスナップショットがどのページ由来かを覚えておくのはあなた次第だ。次に、インデックスの安定性はコンテンツ再生成をまたいで保証されない——リロード後、インデックス3は新しいパースの中でインデックス3であるものが何であれそれになるので、位置が保持されると仮定するのではなく、型と幾何学によってオブジェクトを再識別しよう。3つ目に、FPDFPage_TransFormWithClipのクリップ矩形はページコンテンツに適用されるものであり、どのページボックスもリサイズしない。コンテンツを縮小して余白を作っても、MediaBoxは常にそうであった大きさのままであり、ビューアは元の用紙の中に描画が縮んでいるものを表示する。これらのどれも珍しいものではない——それはポインタを外に配り、寿命の管理を呼び出し側に任せるC APIの普通の帰結だ。修正は、他のあらゆる場所で機能するものと同じだ:スナップショットがいつ期限切れになるかを正確に定義し、その境界でリフレッシュし、失敗した呼び出しが値になりすますことを決して許さないことだ

行列の挙動についてより一般的に取り組んでいるなら、変換がどこに落ち着くかを決める乗算順序はprepend、append、pivotに関する記事で扱っている。ここで説明した変換とページオブジェクトのAPIは、Delphi・C++Builder向けPDFium Componentに付属しており、その製品ページには、ページオブジェクトスナップショットレコードとその見張りフィールドの完全なリファレンスがある