技術記事

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

ドキュメントのすべてのページに透かしやロゴをスタンプすることは、ファイルサイズインスペクターで結果を開くまでは5分の仕事のように見えます。明白なアプローチは、ページを歩き回り、各ページで同じテキストまたは画像オブジェクトを再度構築することです。これは視覚的には機能しますが、複合的な意味で無駄になります。100ページのレポートに直接描画された斜めの「DRAFT」透かしは、コンテンツストリームに存在する同じパスとテキストデータの100個のコピーであり、保存されたファイルはそれらすべてを保持します

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

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

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

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

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

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

var
  Dest, Stamp: TPdf;
  XObject: TPdfXObject;
begin
  Dest := TPdf.Create(nil);
  Stamp := TPdf.Create(nil);
  try
    Dest.FileName := 'Report.pdf';
    Dest.Active := True;
    Stamp.FileName := 'Watermark.pdf';   // one page of artwork
    Stamp.Active := True;
    if not (Dest.Active and Stamp.Active) then
      raise Exception.Create('Could not open the input documents');

    // 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です。このメソッドは、ソースドキュメントがActiveでない場合に例外を発生させ、PDFiumがオブジェクトを構築できない場合に例外を発生させるのではなくnilを返すため、上記の明示的なチェックは必須です。返されるハンドルはあなたが所有するTPdfXObjectであり、それに付随する2つのライフタイムの制約は、この作業全体で人々が引っかかる部分であるため、以下の独自のセクションで説明します

スタンプをページに配置する

キャプチャされたXObjectはそれ自体では何もしません。それを表示させるには、InsertFormObjectFromXObjectを使用して、1ベースのPageNumberプロパティによって選択されたドキュメントの現在のページにそのコピーを挿入します。この呼び出しは基になるページオブジェクトであるFPDF_PAGEOBJECTを返し、返されたハンドルによって配置を位置付けます。変換がない場合、スタンプはソースページ自身の座標の原点に配置されますが、これは意図した場所であることは稀です

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

var
  PageObj: FPDF_PAGEOBJECT;
  M: TPdfMatrix;
  RawM: FS_MATRIX;
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);
    RawM := M.Handle;
    if FPDFPageObj_SetMatrix(PageObj, RawM) = 0 then
      raise Exception.Create('Cannot assign the stamp matrix');
  finally
    M.Free;
  end;
  Dest.UpdatePage;   // commit this page's edits to its content stream
  // if not Dest.SaveAs(...) then ... when every page is done.
end;

2つのハウスキーピングの詳細はこれを安全にします。第一に、挿入されると、ページオブジェクトはXObjectではなくページに属します。後でXObjectを解放しても、すでに行った配置は無効になりません。これが、以下で説明する作成-配置-解放の順序が機能する理由です。第二に、挿入と位置付けはメモリ内のページのオブジェクトリストを変更するだけです。UpdatePageは、そのリストをページのコンテンツストリームにシリアライズして戻すものであるため、それを呼び出さずに編集したページは、スタンプが一度も配置されていないかのように保存されます

人々が引っかかるハンドルのライフタイムルール

XObjectハンドルを支配する制約は2つあり、どちらかを無視すると、その原因とは無関係に見える障害が発生します。第一に、CreateXObjectFromPageを呼び出す時点で、ソースドキュメントがアクティブでなければなりません。キャプチャはアクティブなソースドキュメントからソースページのコンテンツを読み取るため、ハンドルが構築されるときに、そのドキュメントとそのページが開いていて有効でなければなりません。第二に、そしてこれが人々を驚かせるものですが、ソースページが閉じられる前にハンドルを解放しなければならず、実際には、ソースドキュメントを閉じるか解放する前にハンドルを解放しなければなりません

その理由は、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つすべてが混ざるためです。FPdfMatrixユニットからのTPdfMatrixラッパーは、一般的な操作を構成し、進行に合わせて後置乗算を行うため、TranslateScale、およびRotateは呼び出した順序で連鎖します。斜めの透かしは、回転の後に再センタリングするための移動が続きます。コーナーのロゴは、スケーリングの後に移動が続きます。行列の準備ができたら、その生の値であるタイプFS_MATRIXHandleプロパティをローカル変数にコピーし、それをFPDFPageObj_SetMatrixに渡します。インポートは行列をvarパラメーターとして宣言しているため、プロパティを直接渡すことはできず、失敗した場合は結果が0になります。ラッパーを構築するのではなく数値を渡したい場合は、6つの数値を倍精度として直接受け取る低レベルのFPDFPageObj_Transformが利用可能です

すべてのページに適切な順序でスタンプする

完全なパターンは、ライフタイムルールが要求する順序でピースをまとめます。両方のドキュメントを開き、スタンプを1回キャプチャし、1ベースのPageNumberを順番に設定してコピーを挿入および位置付けすることで宛先ページを歩き回り、UpdatePageで各ページをコミットし、次にXObjectを解放し、SaveAsで保存し、最後にソースドキュメントを閉じます

procedure StampEveryPage(const ASource, AStamp, AOutput: string);
var
  Dest, Stamp: TPdf;
  XObject: TPdfXObject;
  PageObj: FPDF_PAGEOBJECT;
  M: TPdfMatrix;
  RawM: FS_MATRIX;
  I: Integer;
begin
  Dest := TPdf.Create(nil);
  Stamp := TPdf.Create(nil);
  try
    Dest.FileName := ASource;
    Dest.Active := True;
    Stamp.FileName := AStamp;
    Stamp.Active := True;
    if not (Dest.Active and Stamp.Active) then
      raise Exception.Create('Could not open the input documents');

    // 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. PageNumber is 1-based.
      for I := 1 to Dest.PageCount do
      begin
        Dest.PageNumber := 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
          RawM := M.Handle;
          FPDFPageObj_SetMatrix(PageObj, RawM);
        finally
          M.Free;
        end;
        Dest.UpdatePage;                     // commit this page's edits
      end;
    finally
      XObject.Free;                          // 3. free BEFORE Stamp closes
    end;

    // 4. Write the result while Dest is still open.
    if not Dest.SaveAs(AOutput) then
      raise Exception.Create('Could not save ' + AOutput);
  finally
    Stamp.Free;                              // source closes last
    Dest.Free;
  end;
end;

tryブロックの形状が実際の作業を行っています。内側のfinallyは、制御がStampを解放する外側のfinallyに到達する前にXObjectを解放するため、ループの途中で例外が発生した場合でも、ハンドルは常にソースがまだ生きている間に解放されます。そのネストを正しく行えば、ライフタイムルールは自然と解決します

スタンプは、ページコンテンツを構築および編集するための大規模なツールキットの一角です。スタンプ自体がキャプチャされたページではなく画像である場合、PDFiumを使用して画像をPDFドキュメントに変換するでは、最初にそのビットマップをドキュメントに取り込む方法について説明しています。また、目に見えるスタンプと一緒に持ち運びたいものが、ページのインクではなくファイルである場合、DelphiでのPDF添付ファイルの操作は、埋め込みファイル側を示しています。これらはすべて、このブログの他の場所で取り上げられているレンダリング、編集、およびドキュメントAPIとともに、DelphiおよびC++Builder用のPDFiumコンポーネントとともに出荷されています