Technical Article

PDFiumによるForm XObjectを介した再利用可能なページスタンプ

ドキュメントのすべてのページに透かしやロゴをスタンプする作業は、ファイルサイズインスペクタで結果を開くまでは、5分で終わる簡単な仕事に見えます。明快なアプローチは、ページをスキャンして、それぞれのページで同じテキストや画像オブジェクトを再構築することです。これは視覚的には動作しますが、データが蓄積するにつれて無駄になります。100ページのレポートに直接描画された斜めの「DRAFT」透かしは、コンテンツストリーム内に格納された同じパスおよびテキストデータの100個のコピーであり、保存されたファイルはそのすべてを保持します。

Form XObjectは、まさにこれを回避するためにPDFが提供する構造です。再利用可能なコンテンツ(ページ全体または小さなテンプレート)を、多くの位置で何度も描画できる単一の名前付きオブジェクトにラップします。コンテンツはファイル内に一度だけ保存されます。スタンプを必要とする各ページは、「このアフィン変換を使用してXObject Nをここに描画する」という短い指示を保持します。100ページの透かしの場合、ファイルに追加されるコンテンツオブジェクトは100個ではなく1個になり、これが、ページ数に応じて線形に増加するドキュメントと増加しないドキュメントの違いです。透かし、ロゴスタンプ、ページ番号テンプレート、封印はすべて同じ種類の問題であり、Form XObjectはそれらすべてに対して適切なツールです。

保存された1つのオブジェクトが100回の再描画に勝る理由

節約されるのは構造的なものであり、表面的なものではありません。PDFページは、描画オペレータのシーケンスであるコンテンツストリームを実行することによってレンダリングされます。ページごとにスタンプを再描画すると、そのスタンプの完全なオペレータシーケンスがすべてのページのストリームに追加され、バイトデータはページ数と同じ回数だけ複製されます。Form XObjectは、これらのオペレータをドキュメント内に一度だけ保存される1つのストリームに移動します。個々のページが保持する参照は小さく、変換行列をスタックにプッシュし、XObjectを呼び出し、状態を復元するだけです。ページ数によってアートワークのコストが乗算されることはなくなります。

これはスタンプが重い場合に最も重要になります。何百ものパスセグメントを持つベクター封印や、ロゴビットマップは、保存するのにコストがかかります。一度保存して参照するようにすれば、重い部分のコストは一度だけで済み、ページごとのオーバーヘッドは数バイトの呼び出しデータだけになります。ページ上の視覚的な結果は直接の再描画と同じであり、それが重要です。リーダーはその違いを認識できませんが、ファイルサイズは大きく変わります。

ページをXObjectにキャプチャする

PDFiumは、既存のページから再利用可能なオブジェクトを構築します。ソースは、開いているいくつかのドキュメントのページ、透かしのアートワークのみを含む小さな1ページのPDF、または大きなファイルの特定のページです。CreateXObjectFromPageは、そのソースページのコンテンツを、スタンプを適用しているデスティネーションドキュメントが所有する再利用可能なハンドルにキャプチャします。

var
  Dest, Stamp: TPdf;
  XObject: TPdfXObject;
begin
  Dest := TPdf.Create;
  Stamp := TPdf.Create;
  try
    Dest.LoadFromFile('Report.pdf');
    Stamp.LoadFromFile('Watermark.pdf');   // one page of artwork

    // Capture page 0 of the stamp document into a reusable handle that
    // is owned by Dest. Source must be active; the index is zero-based.
    XObject := Dest.CreateXObjectFromPage(Stamp, 0);
    if XObject = nil then
      raise Exception.Create('Could not build the stamp XObject');
    // ... place it, then free it before closing Stamp (see below) ...

シグネチャはCreateXObjectFromPage(Source: TPdf; SourcePageIndex: Integer): TPdfXObjectです。このメソッドは、失敗時に例外を発生させるのではなくnilを返すため、上記の明示的なチェックは必須です。戻ってくるハンドルは開発者が所有するTPdfXObjectであり、これに関連付けられている2つの有効期限(ライフタイム)制約は、この一連の作業で人々が陥りやすい部分であるため、以下で個別のセクションを設けて説明します。

ページへのスタンプの配置

キャプチャされたXObjectはそれ単体では何も行いません。それを表示させるには、InsertFormObjectFromXObjectを使用して、ドキュメントの現在のページにそのコピーを挿入します。この呼び出しは、下層のページオブジェクトであるFPDF_PAGEOBJECTを返し、戻されたハンドルを使用して配置位置を決定します。アフィン変換を行わない場合、スタンプはソースページ独自の座標系の原点に配置されますが、そこに配置したいケースは稀です。

InsertFormObjectFromXObjectは呼び出しごとに1つのコピーを挿入し、その都度新しいページオブジェクトを返すため、同じXObjectを異なる変換で1つのページに何度も描画することができ、保存されるコンテンツはファイル内でやはり一度だけカウントされます。コーナーのロゴと、ページ全体の薄い透かしを、同じキャプチャされたオブジェクトから作成できます。

var
  PageObj: FPDF_PAGEOBJECT;
  M: TPdfMatrix;
begin
  // The current page of Dest receives one copy of the XObject.
  PageObj := Dest.InsertFormObjectFromXObject(XObject);
  if PageObj = nil then
    raise Exception.Create('Insert failed on this page');

  // Position it: move 200 units right, 500 up, at 70% scale.
  M := TPdfMatrix.Create;
  try
    M.Scale(0.7, 0.7);
    M.Translate(200, 500);
    FPDFPageObj_SetMatrix(PageObj, M.Handle);
  finally
    M.Free;
  end;
  // Dest.SaveLoadedDocument(...) when every page is done.
end;

所有権に関する1つの詳細が、クリーンアップを安全にします。挿入されたページオブジェクトは、XObjectではなくページに属します。後でXObjectを解放しても、すでに行った配置が無効になることはありません。これにより、以下で説明する作成・配置・解放の順序が機能します。

落とし穴となるハンドルの寿命(ライフタイム)ルール

XObjectハンドルを制御する2つの制約があり、いずれかを無視すると、その原因とは無関係に見えるエラーが発生します。第1に、CreateXObjectFromPageを呼び出す瞬間には、ソースドキュメントがアクティブである必要があります。キャプチャはアクティブなソースドキュメントからソースページのコンテンツを読み取るため、ハンドルが構築されるとき、そのドキュメントとページが開かれて有効である必要があります。第2に(これが人々を驚かせる制約ですが)、ハンドルはソースページが閉じられる前、実用的にはそれが取得されたソースドキュメントを閉じたり解放したりする前に解放される必要があります。

その理由は、XObjectが、ソースドキュメントが依然として所有している構造への参照だからです。ソースが失われた後も持ち運べるような、独立した自己完結型のコピーではありません。先にソースを閉じると、ハンドルは破棄されたコンテンツを指したままになるため、後でそれを解放したりその他の処理を行ったりすると、無効になったメモリを操作することになります。症状は、ダングリングハンドルに典型的なものです。シャットダウン時のアクセス違反、または割り当て順序に応じて変化する断続的な破損であり、コールスタックは実際に問題を引き起こした行ではなく、クリーンアップコードを指します。解決策は、防御的なコーディングではなく、順序を正しくすることです。XObjectを構築し、それを必要とするすべてのページに挿入し、XObjectを解放し、その後にソースドキュメントを閉じます。TPdfXObjectのデストラクタは下層のPDFiumハンドルを解放するため、適切なタイミングでラッパーを解放することが開発者の責任のすべてです。

行列(マトリクス)とその6つの数値の意味

配置は2Dアフィン変換(PDFがコンテンツの配置のためにあらゆる場所で使用しているものと同じ、ISO 32000-1のセクション8.3.4)です。これは a, b, c, d, e, f と表記される6つの数値であり、PDFiumはこれらを FS_MATRIX レコードとして公開します。これらはオブジェクト自体の空間からページ空間へ点をマッピングします:

// x' = a*x + c*y + e
// y' = b*x + d*y + f
//
// a, d : horizontal and vertical scale
// b, c : the shear / rotation terms
// e, f : translation (where the origin lands on the page)

これら6つの値は手動で入力できますが、手動での合成は回転処理で間違いが発生しやすくなります。回転によってa, b, c, dの4つすべてが混ざり合うためです。TPdfMatrixラッパーは一般的な操作を合成し、処理の進行に合わせて後から乗算を行うため、TranslateScaleRotateを呼び出した順にチェーンさせることができます。斜めの透かしは、回転の後に再配置するための平行移動(Translate)を行います。コーナーのロゴは、スケーリングの後に平行移動を行います。行列の準備ができたら、その生の値をFPDFPageObj_SetMatrix(PageObj, M.Handle)に渡します。ここでM.Handleは下層のFS_MATRIXです。ラッパーを作成するよりも数値を直接渡したい場合は、6つの数値をdoubleとして直接受け取る低レベルのFPDFPageObj_Transformを利用できます。

正しい順序での全ページへのスタンプ処理

完全なパターンは、寿命ルールが要求する順序で各パーツを組み合わせます。両方のドキュメントを開き、スタンプを一度キャプチャし、ターゲットページを順番に選択しながらコピーを挿入して配置し、XObjectを解放し、保存して、ソースドキュメントを最後に閉じます。

procedure StampEveryPage(const ASource, AStamp, AOutput: string);
var
  Dest, Stamp: TPdf;
  XObject: TPdfXObject;
  PageObj: FPDF_PAGEOBJECT;
  M: TPdfMatrix;
  i: Integer;
begin
  Dest := TPdf.Create;
  Stamp := TPdf.Create;
  try
    Dest.LoadFromFile(ASource);
    Stamp.LoadFromFile(AStamp);

    // 1. Capture the artwork once. Stamp is active here.
    XObject := Dest.CreateXObjectFromPage(Stamp, 0);
    if XObject = nil then
      raise Exception.Create('Could not capture the stamp page');
    try
      // 2. Place a copy on every page of Dest.
      for i := 0 to Dest.PageCount - 1 do
      begin
        Dest.CurrentPageIndex := i;          // make page i current
        PageObj := Dest.InsertFormObjectFromXObject(XObject);
        if PageObj = nil then
          Continue;

        M := TPdfMatrix.Create;
        try
          M.Rotate(45);                      // diagonal watermark
          M.Translate(150, 100);             // nudge into position
          FPDFPageObj_SetMatrix(PageObj, M.Handle);
        finally
          M.Free;
        end;
      end;
    finally
      XObject.Free;                          // 3. free BEFORE Stamp closes
    end;

    // 4. Write the result while Dest is still open.
    Dest.SaveLoadedDocument(AOutput);
  finally
    Stamp.Free;                              // source closes last
    Dest.Free;
  end;
end;

tryブロックの入れ子構造が重要な役割を果たしています。内側のfinallyは、制御がStampを解放する外側のfinallyに到達する前にXObjectを解放するため、ループの途中で例外が発生した場合でも、ハンドルはソースが生きている間に常に解放されます。このネストを正しく構成すれば、寿命ルールは自動的に守られます。(ビルドでサポートされている現在のページセレクタを使用してください。ループ本体は同じです。)

スタンプ処理は、ページコンテンツの構築と編集のための広範なツールキットの一角にすぎません。スタンプがキャプチャされたページではなく画像自体である場合、PDFiumを使用した画像のPDFドキュメントへの変換で、まずそのビットマップをドキュメントに取り込む方法をカバーしています。また、表示されるスタンプと並行して保持したいデータがページ上のインクではなくファイルである場合、working with PDF attachments in Delphiが、埋め込みファイル側について説明しています。これらすべてが、このブログの他の場所で説明されている描画、編集、およびドキュメントAPIと並んで、DelphiおよびC++Builder向けのPDFium Componentに含まれています。