技術記事

Delphi PDFでのページ合成と透かしテンプレート

ページの合成(Stitching)は、既存のページの「コンテンツ」を共有キャンバス上に配置して組み立てる操作であり、ファイルを単に結合(Merge)する処理とは異なります。losLab PDF Library は、Delphi向けに Form XObject を使用したページ合成機能と、自由に組み合わせ可能な透かし(ウォーターマーク)テンプレートを実装しています。StitchPageOverlayStitchPagesSideBySide、および StitchPagesVertically は、キャプチャされたページを完全なベクター画質のまま任意の倍率で配置でき、TPDFlibCompositeWatermark はテキストと画像の透かしを複数のページ範囲にわたって1回の呼び出しで重ね合わせることができます。もし、単に複数のドキュメントのページ全体を1つのファイルに連結したいだけであれば、それはマージ処理に該当します。これについては、バイトレベルでの高速なPDFマージに関する記事で説明されています。この記事では、同一の座標空間内で複数のページコンテンツを組み合わせる処理について解説します。

この機能が必要とされる背景には、よくあるものの実直な要件があります。例えば、学校のテストで用紙の消費を半分に抑えるために2ページ分の問題を横向きの1枚の用紙に並べて印刷したい場合、翻訳の校正作業で英語の原文ページと翻訳先の日本語ページを1枚のページに並べてスクロール表示させたい場合、あるいは法務部門がすべてのドラフト文書の1ページから3ページ目までに、角にロゴ、中央に斜めに「CONFIDENTIAL」の文字、およびフッターの下部に行を重ねて透かしを入れたい場合などです。これら3つはいずれも根本的には同じ問題であり、すでに組版されたページコンテンツを、ビットマップに劣化させることなく、任意のサイズで別の場所に配置するという課題です。

PDFのコンテンツストリームを単純に連結すると壊れる理由

元のページの /Contents ストリームを対象ページにそのままコピーするような単純な合成は、構造上の3つの理由で失敗します。第一に、座標が衝突します。両方のコンテンツストリームは、それぞれの MediaBox の左下を原点と想定しているため、貼り付けられたコンテンツが意図しない位置で既存のコンテンツと重なってしまいます。第二に、リソース名が衝突します。各ページは /F1 Tf/Im0 Do といったオペレーターを独自の /Resources 辞書に照らして解釈します。2つのページが同じリソース名に対してそれぞれ異なるフォントや画像をバインドしていることはよくあるため、どちらか一方が誤ったフォント等でレンダリングされてしまいます。第三に、生のストリームをコピーするだけではスケーリング(拡大・縮小)処理を行うことができず、単に平行移動させるのが限界です。

Form XObject はこれら3つの問題を一度に解決します。これが、losLab PDF Library が合成処理を Form XObject に基づいて構築している理由です。ISO 32000-1 §8.10 では、Form XObject を「独自のリソース辞書と独自の座標空間を持つ、自己完結したコンテンツストリーム」と定義しています。これは、現在有効な任意の変換行列(Transformation Matrix)の下で、その名前を呼び出すことによってページ内に配置されます。CapturePage は、コンテンツとリソースを含む元のページ全体を /Subtype /Form XObject にパッケージ化し、合成関数はそれを cm 行列(スケール、移動、回転など)を適用して配置します。ソースデータがベクターのまま維持されるため、ズームレベルに関係なくテキストはテキストのまま、パスはパスのまま描画されます。これは、ベクターグラフィックスとシェーディングパターンが解像度に依存しないのと同じ特性です。何もラスタライズされず、独立したリソース辞書により、キャプチャされたページが対象ページと /F1 などの名前を巡って競合することはありません。

2つのPDFページを1つのページにまとめる方法

StitchPagesSideBySide(Page1, Page2, Gap, TargetHeight) がその直接の解決策です。これは、両方のソースページをキャプチャし、新しく幅の広いページを作成して、キャプチャした2つのページを左右に並べ(その間に隙間を空け)、それぞれ TargetHeight に収まるように縮小・拡大します。この関数は、新しく生成されたページ番号(1から始まる)を返し、失敗した場合は0を返します。この操作の過程で、元の2つのページはキャプチャされて非表示になります。2ページを1枚の試験用紙にまとめる処理は、わずか数行の記述で完了します。

var
  Lib: TPDFlib;
  NewPage: Integer;
begin
  Lib := TPDFlib.Create;
  try
    Lib.LoadFromFile('exam.pdf', '');
    // Pages 1 and 2 on one new wider page with an 18pt gutter,
    // both scaled to a 595pt output height (A4 landscape)
    NewPage := Lib.StitchPagesSideBySide(1, 2, 18, 595);
    if NewPage > 0 then
      Lib.SaveToFile('exam-2up.pdf');
  finally
    Lib.Free;
  end;
end;

縦方向の合成については、StitchPagesVertically(PageRanges, Gap, TargetWidth) がページ範囲全体を縦に積み重ねて、1枚の長いポスターのようなページを作成します。PageRanges は、'1-3,5' のような標準的なページ範囲指定の文字列受け取ります。各ソースページは幅が TargetWidth と一致するように拡大縮小されるため、各ページの高さの寄与分は「その元の高さ × 拡大縮小率」となり、出力されるページの全高は、それらの高さの合計にページ間の Gap を足したものになります。これは、スクロール表示で確認する校正用ドキュメントや、レターサイズの用紙から組み立てられる長いレシートなどの用途に適した形状です。

ページを別のページに重ね合わせる方法

StitchPageOverlay(SourcePage, TargetPage, Left, Top, Width, Height, Opacity) は、新しいページを作成するのではなく、既存のページの上に別の既存のページを描画します。元のページは Form XObject としてキャプチャされ、指定された矩形領域に Opacity(0から1の範囲、1は不透明)で対象ページ上に描画されます。幅と高さは自由に変更できるパラメータであるため、縦横比が元のページと異なる場合は配置時に引き伸ばされます。歪みのないサムネイルとして配置したい場合は、比例した縦横比の値を渡してください。典型的な用途としては、承認印のブロックのみを別ページとして管理し、それを表紙ページの上にスタンプする場合などがあります。

var
  Lib: TPDFlib;
begin
  Lib := TPDFlib.Create;
  try
    Lib.LoadFromFile('report.pdf', '');
    // Page 5 holds the approval block; place it on page 1
    // in a 260 x 180 box at 45% opacity. Page 5 is consumed:
    // it is captured and hidden, so pages 6..n become 5..n-1
    if Lib.StitchPageOverlay(5, 1, 300, 500, 260, 180, 0.45) = 1 then
      Lib.SaveToFile('report-approved.pdf');
  finally
    Lib.Free;
  end;
end;

キャプチャ後のページ番号の遷移について

CapturePage は元のページを消費します。そのコンテンツは XObject に移動し、空になった元のページは非表示になるため、そのページより後ろのページ番号はすべて1つずつ手前にシフトします。これは、合成APIを使用する上で最も重要な仕様です。losLab PDF Library は、各合成関数内でこのページ数の計算を自動的に処理します。StitchPageOverlay は対象のインデックスがキャプチャ元より後ろにある場合にそれを調整し、StitchPagesSideBySide はページ番号の大きい方から順にキャプチャすることで、2番目のキャプチャ時のインデックスが有効に機能するように設計されています。しかし、ユーザー独自のコードで連続してキャプチャを実行する場合は、このシフトを考慮する必要があります。連続したページ合成コードを正しく保つためのルールは2つあります。第一に、キャプチャを実行すると情報が変化するため、キャプチャを開始する「前に」必要なページサイズやページ番号を記録しておくことです。第二に、ループ内で複数のページをキャプチャする場合は、後ろのページから順に(降順で)処理を実行し、後のページが非表示になっても、まだキャプチャしていない前のページのインデックスが狂わないようにすることです。

複数レイヤーの透かしを特定のページ範囲に適用する方法

PDFlibWatermark.pas で提供される透かしテンプレートレイヤーを使用すれば、「一度定義して複数のページに適用する」処理をオブジェクトとして構築できます。TPDFlibWatermarkBase は、すべての透かしに共通する要素(9つの基準位置を示す PositionwpTopLeft から wpBottomRight、および生の X/Y 座標を使用する wpCustom)、度単位の角度 Angle、0から1の範囲の不透明度 Opacity、および空のときに全ページを意味するページ範囲文字列 PageRange)を保持します。TPDFlibTextWatermark は、テキスト、フォントID(デフォルトは Helvetica)、フォントサイズ、およびRGBカラーを追加します。TPDFlibImageWatermark は、事前に追加された画像IDと、配置時の幅および高さを追加します。TPDFlibCompositeWatermark は複数の子透かしをリストとして保持し、それらを再帰的に適用します。そして、TPDFlib.ApplyWatermark は、テンプレートで指定されたページ範囲を考慮して透かしを適用し、適用されたページ数を返す一括処理のAPIです。前述した3レイヤーのリーガル透かしのコードは次のようになります。

var
  Lib: TPDFlib;
  Logo: TPDFlibImageWatermark;
  Diagonal, Footer: TPDFlibTextWatermark;
  Stack: TPDFlibCompositeWatermark;
begin
  Lib := TPDFlib.Create;
  try
    Lib.LoadFromFile('draft.pdf', '');
    Stack := TPDFlibCompositeWatermark.Create;
    try
      Logo := TPDFlibImageWatermark.Create;
      Logo.ImageID := Lib.AddImageFromFile('logo.png', 0);
      Logo.Position := wpTopRight;
      Logo.Width := 90;
      Logo.Height := 32;
      Stack.Add(Logo);

      Diagonal := TPDFlibTextWatermark.Create;
      Diagonal.Text := 'CONFIDENTIAL';
      Diagonal.Position := wpCenter;
      Diagonal.Angle := 45;
      Diagonal.FontSize := 60;
      Diagonal.Opacity := 0.15;
      Stack.Add(Diagonal);

      Footer := TPDFlibTextWatermark.Create;
      Footer.Text := 'Internal review copy';
      Footer.Position := wpBottomCenter;
      Footer.FontSize := 9;
      Footer.Opacity := 0.6;
      Stack.Add(Footer);

      Stack.PageRange := '1-3';   // children inherit this range
      Lib.ApplyWatermark(Stack);
      Lib.SaveToFile('draft-marked.pdf');
    finally
      Stack.Free;                 // composite frees its children
    end;
  finally
    Lib.Free;
  end;
end;

ここで、透かしの組み合わせに関する2つの仕様について説明します。範囲の継承は、親から子への一方向かつ非破壊的に行われます。ページ範囲が空の子透かしは、Apply の実行時に複合(Composite)透かしに指定された範囲を一時的に継承しますが、独自の範囲が設定されている子はそれを維持します。これにより、斜めの透かしはドラフト部分のみに適用しつつ、フッターのテキストは全ページに配置する、といった柔軟な設定が可能です。また、オブジェクトの所有権は複合透かしに従います。TPDFlibCompositeWatermark.Destroy はその子が保持するメモリも自動的に解放するため、上記の例のように複合オブジェクトのみを解放すれば漏れはありません。

知っておくべき制限事項

この仕組みの動作仕様に起因する、いくつかの実質的な制限があります。第一に、キャプチャはページのコンテンツストリームとリソースのみをパッケージ化します。アノテーション、リンク、フォームフィールド、およびコメントは、コンテンツストリームではなくページ辞書内に別のオブジェクトとして保存されているため、XObject の中には移行しません。合成されたレイアウトはページの可視コンテンツを保持しますが、元のページのインタラクティブな機能は引き継がれません。第二に、9点指定によるテキスト配置は近似計算です。完全なレイアウトエンジンを持たないライブラリは文字列が描画された際の正確な幅を算出できないため、wpCenter などの位置を決定する際、テキストボックスの幅をページ幅の40%として見積もります。これは短い文字列を中央に配置するには十分ですが、長い文字列をピクセル単位で正確に配置したい場合は、wpCustom に切り替えて座標をご自身で計算してください。

キャプチャと合成処理はページのコンテンツを再エンコードするのではなくオブジェクトの参照を操作するため、ファイルサイズによる制限は実質的にありません。元のドキュメントが数百メガバイトに及ぶような場合は、ページ合成を行う前に、大容量ファイルの直接アクセスによるマージと分割に関するガイドで説明されているのと同じ設計思想を適用してください。ここで紹介した合成関数および透かしテンプレートクラスは、Delphi、C#、およびVB.NET向けの最新のlosLab PDF Libraryで提供されており、3つの定義済み合成関数ではカバーできないレイアウトを構築するために、より低レベルな CapturePageDrawCapturedPage、および DrawCapturedPageMatrix も提供されています。