技術記事

Delphi の PDF ブロックテンプレート: テキスト・画像・PDF 流し込み

PDFlibPas(losLab PDF Library)は、Delphi から PDF ブロックテンプレートを流し込みます。ブロックはテンプレートページ上の名前付き矩形で、種類はテキスト・画像・PDF のいずれかで、FillTextBlock、FillImageBlock、FillPDFBlock が可変データをそこへ描き込みます。ブロックは PDFlibBlock:Text、PDFlibBlock:Image、PDFlibBlock:PDF というページ注釈として、または /PieceInfo の下のページピース辞書に見つかります

ユースケースは退屈で大量生産の類です。デザイナーが証明書、イベントチケット、督促状を一度レイアウトすれば、プログラムは 1 万レコード分の氏名、座席番号、署名画像、事前レンダリングしたバーコードページを固定スロットに押し込みます。フォームフィールドでも一部は可能ですが、AcroForm フィールドは独自のアピアランスストリームと独自のルールを持つ対話的オブジェクトで、結局はフラット化で終わることが多いはずです。ブロックはもっと単純です。矩形とひとつかみのスタイリングヒントがあり、一度消費されて捨てられます

PDFlibPas における PDF ブロックとは正確に何か

PDFlibPas のブロックはページ注釈で、/Subtype が PDFlibBlock:Text、PDFlibBlock:Image、PDFlibBlock:PDF のいずれか、/Rect がプレースホルダー領域、/Contents 文字列が name;key=value;key=value を保持するものです。先頭セグメントがブロック名で、それ以降はプロパティリストです。TPDFlib.AddBlock はこの注釈を /F 4(Print)付き、アピアランスストリームなしで書き込むため、未充填のブロックは画面でも紙でもピクセルを占有しません

Delphi の可変データに対する PDFlibPas ブロックテンプレートの構造。/Subtype が PDFlibBlock:Text、Image、PDF のいずれかで /Rect のプレースホルダー、name と key=value プロパティの /Contents 文字列、/F 4 Print、アピアランスストリームなしのページ注釈であり、第 2 の情報源として /PieceInfo /PDFlib /Blocks のページピース辞書がある
ブロックはページごとに 1 起点で列挙され、注釈ブロックが先でページピースブロックがその後に続きます。配列エントリは Block1、Block2 という名になり、/Custom のキーはプロパティへ展開され、Image、PDF、Page 以外はすべてテキストとして扱われます

読み取り側には第 2 の情報源があります。ブロック注釈を列挙した後、PDFlibPas はページ辞書のパス /PieceInfo << /PDFlib << /Blocks ... >> >> とその変種 /PieceInfo << /PDFlib << /Private << /Blocks ... >> >> >> へフォールバックします。ページピース辞書は標準 PDF の機能で、ISO 32000-1 §14.5 に、アプリケーションがページに付随するプライベートデータを格納する場所として定義されており、外部テンプレート作成ツールのブロック定義はたいていここに住んでいます。/Blocks コンテナはブロック名をキーとする辞書でも素の配列でも構いません。配列形式では、ブロックが独自の /Name を持たない限り、PDFlibPas はエントリを Block1、Block2 と名付けていきます

ページピースブロックの内部では、/Subtype(または /Type)の値が Image なら画像ブロック、PDF か Page なら PDF ブロックに対応し、それ以外はテキストとして扱われます。/Type、/Subtype、/Name、/Rect、/Custom 以外のキーはすべてプロパティになり、ネストした /Custom 辞書のキーも同様に展開されるため、/Custom << /department (Finance) >> というエントリは GetBlockProperty(Page, Index, 'department') で読めます。ブロックインデックスはページごとに 1 起点で、注釈ブロックが先、ページピースブロックがその後です

AddBlock でブロックテンプレートを作る

TPDFlib.AddBlock(BlockType, BlockName, Left, Top, Width, Height, Properties) は現在選択中のページにブロックを追加し、成功で 1 を返します。空の名前、ゼロか負の幅や高さ、'text'、'image'、'pdf' 以外の(大文字小文字を区別しない)BlockType に対しては 0 を返します。Properties はセミコロン付きで名前に連結されるため、格納文字列と同じ key=value;key=value 構文を使います

var
  Lib: TPDFlib;
begin
  Lib := TPDFlib.Create;
  try
    // 静的アートワーク:枠線、ロゴ、固定文言 ...
    Lib.DrawText(72, 200, 'This certifies that');

    Lib.AddBlock('text', 'recipient', 72, 230, 450, 44,
      'fontname=Times-Bold;fontsize=30;fillcolor={cmyk 0 0.55 1 0.1};alignment=center');
    Lib.AddBlock('text', 'course', 72, 290, 450, 24,
      'fontname=Helvetica;fontsize=14;alignment=center;fitmethod=word');
    Lib.AddBlock('image', 'signature', 360, 600, 160, 60, '');
    Lib.AddBlock('pdf', 'seal', 72, 580, 100, 100, '');

    if Lib.GetBlockCount(1) <> 4 then
      raise Exception.Create('Template blocks missing');
    Lib.SaveToFile('certificate-template.pdf');
  finally
    Lib.Free;
  end;
end;

プロパティパーサは意図的に緩い作りです。キーは大文字小文字を区別せずに照合され、値はトリムされるものの、それ以外は書かれたとおりに返ります({top left} と書いた値は波括弧ごと返ります)。存在しないキーは空文字列を返します。唯一の硬い制限はセパレータで、セミコロンがセグメントを終わらせるため、値にセミコロンは入れられません

FillTextBlock はフォント・色・配置をどう適用するのか

TPDFlib.FillTextBlock(Page, BlockIndex, Text, Options) はブロックのプロパティを読み、描画状態に適用し、FitTextBox でテキストをブロック矩形にフィットさせ、それから finally ブロックで呼び出し元のフォント、テキストサイズ、テキスト色、配置を復元します。マージループではこの復元が重要です。赤い 30 pt の見出しブロックが、同じページで次に呼ぶ DrawText にスタイルを漏らすわけにはいかないからです

  • fontname(または font): まずその名前で文書に読み込み済みのフォントを再利用し、次に Helvetica-Bold、Times-Roman、Courier といった Standard 14 の名前をマッピングし、最後にその名前で AddTrueTypeFont を試します
  • fontsize(または size): フィッターに渡されるテキストサイズで、収まらなければ縮小します
  • fillcolor(または textcolor、color): #RRGGBB、{rgb r g b}、{cmyk c m y k}、{gray g}、数 3 つか 4 つの素のリスト、または black、white、red、green、blue、gray の名前。1 を超える RGB 成分は 0–255 として読まれます
  • alignment、align、position はまとめて読まれます。left、right、center(centre も可)、justify が水平配置を決め、top と bottom が垂直位置を決め、既定は垂直方向の中央です
  • fitmethod: word か nofitsplit は長い単語の途中折り返しを禁止し、scale か expand はボックスを満たすまでサイズを伸ばします

CMYK の経路は印刷会社が関心を持つ理由です。{cmyk 0 0.55 1 0.1} は SetTextColorCMYK を呼ぶため、可変テキストは DeviceCMYK で出力され、RGB を往復せずに刷版上でブランドカラーに乗ります。FillTextBlock はテキストが描かれると 1 を返します。空の Text、見つからないブロック、テキストブロックでないブロックに対しては 0 を返し、いずれの場合もブロックはその場に残されます

Delphi 向け PDFlibPas における FillTextBlock の処理順序。ブロックプロパティの fontname、fontsize、fillcolor、alignment が描画状態に適用され、CMYK 値は SetTextColorCMYK を経由し、テキストは FitTextBox でフィットされ、呼び出し元のフォント、サイズ、色、配置は finally ブロックで復元される
ブロックのスタイリングは一度適用されてロールバックされるため、赤い 30 pt の見出しが次の DrawText に漏れることはありません。テキストが描かれれば 1 を返し、空テキストやテキストでないブロックでは 0 とともにブロックがその場に残ります

画像ブロックと PDF ブロックの充填、Options ビット

FillImageBlock(Page, BlockIndex, ImageID, Options) は事前に追加した画像を描き、FillPDFBlock(Page, BlockIndex, SourceDocumentID, SourcePage, Options) は読み込み済みの別文書のページを Form XObject として取り込んで描きます。後者のおかげで、印章、QR コードページ、レターヘッドのようなベクターコンテンツはベクターのまま保たれます。どちらもソースのアスペクト比を保ち、結果をブロック矩形の中で中央に置きます。3 つの fill 関数は同じ Options ビットを共有します。ビット 1(値 1)は充填に成功した後でブロックを取り除き、注釈の削除か /Blocks コンテナからのエントリ削除のどちらかを行い、ビット 2(値 2)はフィットの代わりに画像と PDF コンテンツを矩形そのものへ引き伸ばします

function FillCertificate(const TemplateFile, SealFile, SignatureFile,
  OutFile: WideString; Values: TStrings): Boolean;
var
  Lib: TPDFlib;
  SealID, SignatureID, I: Integer;
  BlockName, BlockType: WideString;
begin
  Result := False;
  Lib := TPDFlib.Create;
  try
    if Lib.LoadFromFile(SealFile, '') <> 1 then Exit;
    SealID := Lib.SelectedDocument;          // 本物の文書 ID
    if Lib.LoadFromFile(TemplateFile, '') <> 1 then Exit;
    // ここからはテンプレートが選択中の文書
    SignatureID := Lib.AddImageFromFile(SignatureFile, 0);

    // 後ろ向きに走る:ビット 1 が各ブロックを取り除き、後続の番号が詰まるため
    for I := Lib.GetBlockCount(1) downto 1 do
    begin
      BlockName := Lib.GetBlockName(1, I);
      BlockType := Lib.GetBlockType(1, I);
      if BlockType = 'text' then
        Lib.FillTextBlock(1, I, Values.Values[BlockName], 1)
      else if (BlockType = 'image') and (SignatureID <> 0) then
        Lib.FillImageBlock(1, I, SignatureID, 1)
      else if BlockType = 'pdf' then
        Lib.FillPDFBlock(1, I, SealID, 1, 1);
    end;
    Result := Lib.SaveToFile(OutFile) = 1;
  finally
    Lib.Free;
  end;
end;

このループには間違えやすい細部が 2 つあります。ループが最も大きいインデックスから降りていくのは、ブロック 2 を取り除くと旧ブロック 3 がブロック 2 になるからです。ビット 1 を立てたまま上向きに数えると、ブロックを 1 つおきに飛ばします。そして FillPDFBlock のソース文書は、テンプレートをロードする前にロードして ID を確保しておく必要があります。画像 ID は選択中の文書に属するため、AddImageFromFile はテンプレートが選択されている間に走らなければならず、ID の確保は先に済ませるわけです。レコードごとに印章やレターヘッドが変わるバッチ実行には、PDFlibPas の Form XObject ページ結合と組み合わせ可能な透かしテンプレートが、同じ「確保して描く」仕組みをページ全体に使います

ブロック座標が反転して出てくるのはなぜか

ブロック矩形が反転して出てくるのは、PDFlibPas が扱う 2 つの座標系をコードが混ぜたときです。注釈の /Rect は PDF ユーザー空間で、ページの下端から測られ(ISO 32000-1 §12.5.2)、一方 TPDFlib の描画 API は上端から測ります。AddBlock は上基準の入力を受け取り、下端を PageHeight - Top - Height として格納します。GetBlockRect と 3 つの fill 関数は矩形を読み戻し、PageHeight - URy で変換します

実務上はこういうことです。スロットのジオメトリが必要なとき、たとえばデバッグ枠を描くときやブロックの隣にキャプションを置くときは GetBlockRect(Page, BlockIndex, Left, Top, Width, Height) を使い、汎用の注釈呼び出しで /Rect を読んでそのまま DrawText に流し込んだりしないでください。792 pt のレターページ上の /Rect [40 700 240 730] というページピースブロックは、700 ではなく Top = 62 と報告します。これで防げるバグは古典で、充填された全値が下マージン付近に鏡像で着地し、何もエラーを出さないというものです

Delphi 向け PDFlibPas におけるブロック矩形の座標反転。/Rect の値は下基準の PDF ユーザー空間にあり、AddBlock は上基準の入力を受け取り PageHeight から Top と Height を引いて格納するため、792 pt レターページ上の /Rect 40 700 240 730 は GetBlockRect と PageHeight から URy を引く変換を通じて Top 62 として読み戻る
2 つの座標系を混ぜると、エラーを 1 つも出さずに充填値がすべて下マージン側へ鏡像になります。/Rect を DrawText に直結せず、スロットのジオメトリは GetBlockRect 経由で読んでください

教訓になる落とし穴

ブロックコードで最もためになったバグは 1 文字のミスでした。ブロック注釈はサブタイプの接頭辞テストで認識されるのですが、'PDFlibBlock:' は 12 文字です。初期のバージョンは Copy(Subtype, 1, 13) をその 12 文字のリテラルと比較しており、等しくなることは決してないため、GetBlockCount はどのテンプレートに対しても 0 を返し、どの充填も "no such block" を報告しました。クラッシュもログもありません。この教訓はあらゆるテンプレートエンジンに当てはまります。既知のテンプレートのブロック数をテストで assert してください。無言でゼロ件になる列挙は、空のページと見分けがつかないのです

2 つ目の罠は文書 ID です。TPDFlib.LoadFromFile が返すのはステータスコードで、成功 1、失敗 0 であって、ロードした文書の ID ではありません。その 1 を SourceDocumentID として FillPDFBlock に渡すと失敗します。1 は有効な文書 ID ではないからです。成功したロードの直後に SelectedDocument を呼んで本物の ID を取得し、後からソースをロードした場合は SelectDocument でテンプレートに切り替え直してください

小さいものだと、空文字列での FillTextBlock は何もせずブロックを残します。2 行目住所がないような任意フィールドのあるメールマージでは、たいていそれが望む挙動ですが、未充填の注釈はそのまま出力ファイルに残ります。出力をきれいに保つ必要があるなら、DeleteAnnotation で明示的に取り除くか、ビット 1 を立てて空白 1 文字を充填してください。すべてのブロック呼び出しは選択中ページを保存して復元するため、ページ 3 への充填が次の描画呼び出しの行き先を変えることはありません

ブロック注釈の相互運用性の限界

ブロック注釈は私的慣習であって、標準 PDF の機能ではありません。PDFlibBlock:Text とその兄弟は、ISO 32000-1 §12.5.6.1 に列挙された注釈型には含まれないため、ビューアは未知の注釈として扱います。Invisible フラグが立っておらずアピアランスストリームもないため、準拠ビューアは何も描きませんが、コメントパネルに一覧表示するビューアもあり、他の PDF ツールはそのセマンティクスを理解もし保存もしません

  • システムの外に出るものにはビット 1 を立てて充填してください。最終ファイルには普通のページコンテンツだけが残ります
  • PDF/A は PDF 仕様外の注釈型を許さないため(ISO 19005-1 §6.5.2)、アーカイブされる出力に未充填のブロック注釈が残っていてはなりません
  • ページピースブロックはテンプレートに保持しておく方が安全です。/PieceInfo は標準で、コンシューマは所有していないデータを無視するためです
  • ブロックはフォームフィールドではありません。対話性はなく、ファイル内にフィールド値もなく、FDF や XFDF へのエクスポートもありません。編集可能なままにすべきデータは、PDFlibPas の FDF・XFDF・XFA フォームデータ交換を参照してください

印刷ワークフローのために、テンプレートが可変フィールドを標準化されたバリデータ検証可能な形で宣言しなければならないなら、ブロックは間違った道具です。そのためのものが PDF/VCR-1 で、PDFlibPas の PDF/VCR-1 テンプレートプレースホルダーマニフェストが、注釈ではなく構造ツリーに住むそれらのプレースホルダーを扱います

FAQ

PDFlibPas は他のツールが作ったブロックテンプレートを流し込めるか

はい。ツールがブロックを /PieceInfo /PDFlib /Blocks または /PieceInfo /PDFlib /Private /Blocks の下に、辞書か配列の形式、4 つの数値の /Rect 付きで格納している場合です。GetBlockCount はそれらを PDFlibBlock:* 注釈の後に数え、カスタムキーは GetBlockProperty で露出します

FillTextBlock は呼び出し前に選択したフォントを変えてしまうか

いいえ。FillTextBlock は選択中のフォント、テキストサイズ、テキスト色、配置を保存し、ブロックの fontname、fontsize、fillcolor、alignment プロパティを適用し、その後で元の状態を復元します。フィットが失敗した場合でもです

ベクターロゴやバーコードページをブロックに入れるには

ソース PDF をロードし、SelectedDocument をその ID として保存し、必要ならテンプレート文書を再選択して、FillPDFBlock(Page, BlockIndex, SourceID, SourcePage, 1) を呼びます。ページは Form XObject として取り込まれるため、ベクターのままで、ビット 2 を足さない限りアスペクト比を保ったままブロックにフィットします

ブロックテンプレート、Form XObject ページ取り込み、その他の可変データツールキーは、Delphi と C++Builder プロジェクト向けに PDF 作成・編集・印刷生産をカバーする PDFlibPas(losLab PDF Library for Delphi)に同梱されています