技術記事

PDFlibPasでPDF/VCR-1テンプレートのプレースホルダを作る

PDFlibPasはPDF/VCR-1をタグ付き構造プロファイルとして実装しています。置換定義はStructTreeRootの直下にある子分岐として存在し、テンプレートルートは/O /GTS_Templateを持ってフィールドセットを宣言し、各プレースホルダは/O /GTS_Replacementを持って1つのデータフィールドにバインドします。ページレベルの辞書という要素はどこにもありません

この一文こそ、ほとんどの実装が間違える部分で、間違えると検証失敗以上のコストがかかります。置換プランを読み間違えたバリアブルデータジョブはクラッシュしません。大量に印刷され、間違ったフィールドが間違ったボックスに入り、最初に気づくのは5万通のダイレクトメールを手にした顧客です

置換定義が実際にある場所

PDF/VCR-1は置換モデルを、プライベートなページ辞書ではなく、ISO 32000-1 §14.7で説明される論理構造階層に置きます。テンプレートルートはStructTreeRootから直接ぶら下がる構造要素で、属性オーナーGTS_Templateによって識別され、GTS_Fieldsによってフィールド語彙全体を1か所で宣言し、オプションのGTS_Pagesでページ選択を駆動するフィールドを指定します

プレースホルダはそのルートの中にネストされた構造要素で、属性オーナーGTS_Replacementを持ちます。それぞれがGTS_Generator(PDFlibPasはPassThroughを要求)、宣言されたフィールドをちょうど1つ指定するGTS_Data値、オプションのGTS_BBoxを持ちます。ページへのバインドもページアノテーションではありません。要素は/Pgで自分のページを指し、/Kでマークコンテンツシーケンスを指します。ページ辞書より構造を選ぶことには、述べるのは簡単でも過小評価しやすい結果が伴います。構造ツリーはカタログから到達できる一般的なグラフなので、プロファイルを消費するものは辞書リーダではなく、それが意味するすべての危険を抱えたツリーウォーカーでなければなりません。構造ツリーを扱ったことがなければ、その仕組みはタグ付きPDFの構造とアクセシビリティで説明されています

PDFlibPasがPDF/VCR-1の置換モデルを置く場所。テンプレートルートはGTS_Templateの属性オーナーでStructTreeRootから直接ぶら下がり、GTS_FieldsとオプションのGTS_Pagesを宣言します。一方、内部にネストされた各プレースホルダはGTS_Replacement、PassThroughのGTS_Generator、1つのGTS_Dataフィールド、オプションのGTS_BBoxを持ち、/Pgでページを、/Kでコンテンツを指します
置換定義はページ辞書ではなく論理構造階層に存在するため、すべての消費者がツリーウォーカーになり、要素は攻撃者が形を変えられるグラフノードになります

最初のトップレベル分岐だけを検査すると、なぜ悪いファイルを通してしまうのか

StructTreeRootは多数の直接の子を持てるため、テンプレートルートを見つけた時点で止まるバリデータは残りを見ることができません。これはPDFlibPasが避けるように作られた具体的な失敗で、検査しないより悪い偽の合格をまさに生み出します。適合と報告されながら2つ目のテンプレートルートを含むファイル、あるいはどの消費者も尊重しないテンプレート分岐の外に置かれたプレースホルダです

そこでPDFlibPasのValidatePDFVCRStructureは同じツリーを2パスで走査します。最初のパスはStructTreeRootのすべての直接の子を列挙し、GTS_Templateを持つものを数えます。ちょうど1つ以外は、プレースホルダを1つも読む前に拒否されます。2つ目のパスはテンプレートの分岐だけでなくすべてのトップレベル分岐に降りていき、2つのものを同時に拒否します。ルートではないのにGTS_Templateを持つネストされた要素と、テンプレート分岐の外で見つかったGTS_Replacement要素です

走査は深さ64レベルと1,000,000ノードに制限され、訪れたすべての要素のオブジェクト番号と世代番号を記録して、サイクルや繰り返し参照を追跡するのではなく報告します。これらの数値はパフォーマンス調整ではありません。構造ツリーは攻撃者が制御するオブジェクトグラフで、上限なしに再帰するバリデータは、ファイル拡張子が付いたスタックオーバーフローです

PDFlibPasのValidatePDFVCRStructureの2パス検証。パス1はGTS_Templateを持つStructTreeRootの直接の子を数え、ちょうど1つ以外を拒否します。パス2はすべてのトップレベル分岐に降りて、ネストされたテンプレートルートとテンプレート分岐の外のGTS_Replacementを拒否します。64レベルと100万ノードに制限されます
最初のテンプレートルートを見つけた時点で止まるバリデータは2つ目を見ることができないため、走査はすべてのトップレベル分岐をカバーし、オブジェクト番号を記録してサイクルを追跡ではなく報告します

テンプレートの作成と、APIが曲げようとしない制約

PDFlibPasは、構造規則を保存時に発見するのではなく書き込み時に強制するブラケット型APIで作成を公開しています。SetPDFVCRModeは文書をこのプロファイルに切り替え、内部的にISO 15930-7のPDF/X-4ベースモードを要求し、mark-infoフラグをオンにします。マークコンテンツのないPDF/VCR-1ファイルには、プレースホルダが指すものが何もないからです。そこからBeginPDFVCRTemplateはフィールドリストとページ選択フィールドを受け取りますが、データスキーマを設計する前に知っておく価値のある厳しさがあります。フィールド名はPDFVCRValidFieldNameを通り、ASCIIの英字、数字、アンダースコア、ハイフン、ピリオドだけを受け入れます。それらはエスケープのあいまいさなしにPDF名へシリアライズできる文字だからです。PDFVCRNormalizeFieldListはリストを大文字小文字区別のまま保持し、重複を即座に拒否します。ページ選択フィールド自体も宣言済みリストに含まれなければなりません

var
  Lib: TPDFlib;
  Diag: WideString;
begin
  Lib := TPDFlib.Create;
  try
    Lib.NewDocument;

    if Lib.SetPDFVCRMode(1) <> 1 then
      raise Exception.Create('PDF/VCR mode was refused');

    Lib.NewPage;
    if Lib.BeginPDFVCRTemplate('Salutation,FullName,AccountNo',
                               'AccountNo') <> 1 then
      raise Exception.Create('Field list rejected');

    // 1つのプレースホルダは宣言されたちょうど1つのフィールドにバインドする
    Lib.BeginPDFVCRPlaceholder('FullName', 72, 690, 320, 712);
    Lib.DrawText(72, 694, 'SAMPLE NAME');
    Lib.EndPDFVCRPlaceholder;

    Lib.EndPDFVCRTemplate;

    Diag := Lib.GetPDFVCRDiagnostics;
    if Diag <> '' then
    begin
      Writeln('PDF/VCR blockers: ', Diag);
      Exit;
    end;

    Lib.SaveToFile('campaign-template.pdf');
  finally
    Lib.Free;
  end;
end;

ブラケット型APIが何をもたらすかに注目してください。BeginPDFVCRPlaceholderは、テンプレートがアクティブでタグスタックがちょうど1レベル深いとき以外は開くことを拒否します。これが、プレースホルダがテンプレートルートの直接の子であり、互いにネストしないことをPDFlibPasが保証する仕組みです。座標も検証され、NaN、無限大、反転した矩形を拒否するため、悪いバウンディングボックスは1週間後のプリフライトレポートではなく呼び出し時点で失敗します

プレースホルダが独自のMCIDシリアライズ経路を必要とするのはなぜか

PDF/VCR-1は/Kが裸の整数であることを求めるからです。これは一般的なタグ付きPDFライタが構造要素とコンテンツのリンクをシリアライズする方法ではありません。ISO 32000-1 §14.6では、構造要素は通常、要素が/Pgで自分のページをすでに識別している場合は直接のMCID整数で、そうでない場合はページを明示的に指名するマークコンテンツ参照辞書を通じて、マークコンテンツを参照します。多くの目的でタグ付き出力を生成するライブラリは、ページをまたいだ合成に優れる辞書形式に標準化しがちです

PDFlibPasはここで一般的な選択肢ではなく狭い選択肢を取りました。SetCurrentStructElemDirectMCIDKidは現在の構造要素にフラグを立て、そのフラグを持つ要素だけが/Kを直接MCIDとしてシリアライズし、ページは/Pgで運ばれます。他のすべては既存のMCR辞書経路をそのまま保ちます。これは意図的なトレードオフで、声に出して言う価値のある種類のものです。プロファイルを追加しても、他のタグ付き文書がすでに生成している出力のバイトを変えるべきではありません。だからプロファイルはグローバルスイッチではなく独自の経路を得ます。既存のタグ付きファイルはすべて以前のシリアライズを維持し、リグレッションの表面積は新機能の大きさのままです

1つの走査に2つの消費者

PDFlibPasは置換マニフェストと適合判定を同じ2パス走査から導出します。だからマニフェストをジョブプランとして信頼できます。走査は検証しながらTPDFVCRManifestDataレコードを埋めます。Fieldsはテンプレートルートから、PageSelectionFieldは宣言されていればそこから、PlaceholdersはFieldName、PageNumber、MCID、HasBBox、BBoxを持つTPDFVCRPlaceholderInfoエントリの配列です。適合のエントリポイントはCheckCompliancePDFVCR(InputStream, Password, Options, ResultList)で、すべてを一度に実行するのではなく依存順でチェックを実行します。最初に完全なPDF/Xチェック、次にGTS_PDFXVersionをPDF/X-4と照合、次にhttp://www.npes.org/pdfvcr/ns/id/名前空間から読むGTS_PDFVCRVersion、そして最後に構造の走査です。Optionsに1を渡すと最初の問題で停止します。これはバッチゲートで望む挙動です。0を渡すとすべてを収集します。これはレポートで望む挙動です

PDFlibPasの1つの走査と2つの消費者。同じ制限付き走査がTPDFVCRManifestDataにFields、PageSelectionField、TPDFVCRPlaceholderInfoエントリのPlaceholders配列を埋める一方、CheckCompliancePDFVCRは完全なPDF/Xチェック、GTS_PDFXVersion、npes.org名前空間のGTS_PDFVCRVersionを実行してから構造の走査を行い、ファイルが資格を満たすときだけ100を返します
マニフェストは契約なので、BuildPDFVCRManifestは問題リストが空のときだけJSONを返し、それ以外はエラー608を報告します。適合しないファイルが信頼できるジョブプランを装うのを防ぎます
// 自分で作ったのではなく受け取ったテンプレートを検証する
var
  Issues: TStringList;
  Stream: TFileStream;
  I: Integer;
begin
  Issues := TStringList.Create;
  Stream := TFileStream.Create('supplier-template.pdf',
                               fmOpenRead or fmShareDenyWrite);
  try
    if CheckCompliancePDFVCR(Stream, '', 0, Issues) <> 100 then
      for I := 0 to Issues.Count - 1 do
        Writeln('PDF/VCR: ', Issues[I]);
  finally
    Stream.Free;
    Issues.Free;
  end;
end;

マニフェストは契約なので、適合ファイルにしか存在しない

BuildPDFVCRManifestは問題リストが空のときだけJSONを返し、それ以外は空文字列を返します。これは自己目的の防御的コーディングではありません。マニフェストはそこから実際の出力を駆動するコンポジションエンジンに消費され、2つのテンプレートルートを持つファイルから抽出した部分的なフィールドリストを渡すと、エンジンが非適合の構造を信頼できる置換プランとして扱ってしまいます。ドキュメントAPIを通じて、GetPDFVCRManifest(InputFileName, Password)は同じ呼び出しをラップし、ファイルが資格を満たさないときに最後のエラーコードをPDFLIB_ERROR_PDFVCR_COMPLIANCE(608)に設定するため、抽出の失敗と空のテンプレートを区別できます。JSONはfields、文字列またはnullのpageSelectionField、そして各エントリがfield、page、mcidと4つの数値のbboxまたはnullを与えるplaceholders配列を報告します

var
  Lib: TPDFlib;
  Manifest: WideString;
begin
  Lib := TPDFlib.Create;
  try
    Manifest := Lib.GetPDFVCRManifest('campaign-template.pdf', '');
    if Manifest = '' then
      // 608はファイルがPDF/VCR-1適合でないことを意味し
      // 401はファイルを開けなかったことを意味する
      Writeln('No manifest, error ', Lib.LastErrorCode)
    else
      Writeln(Manifest);
  finally
    Lib.Free;
  end;
end;

コンシューマに持ち込むべき細部が1つあります。pageの値は、ビューアで人間が見る1始まりの番号ではなく、ライブラリ全体の0始まりのページ規則に従います。これを混同すると、コンテンツが間違ったシートに載る静かなオフバイワンになり、両方の数値が構造的に有効なためどのバリデータも捕捉しません

境界がどこにあるのか

PDFlibPasのPDF/VCR-1サポートは、テンプレート作成、保存ゲート、スタンドアロンのプリフライト、マニフェスト抽出をカバーします。置換自体は実行しません。プレースホルダへのデータ代入とパーソナライズされたインスタンスの生成はコンポジションステップで、意図的にライブラリの外にあります。マニフェストこそ、コンポジションエンジンを独立に書けるようにするインターフェースだからです。保存ゲートも修復ではなく拒否を選びます。間違ったアノテーションフラグを黙って正規化できるプロファイルと違い、PDFVCRReadyForSaveが強制するすべてのPDF/VCR-1制約は、推測が文書の意味を変えてしまうものです。テンプレートルートをでっち上げる、あるいはバインドされていないプレースホルダがどのフィールドに属するかを選ぶと、検証は通るが嘘をつくファイルができます。同じ修復対拒否の線引きが別のプロファイルについてPDF/E-1オーサーモードと制限付きプリフライトで論じられ、1つの文書に対して複数の適合チェックを実行するワークフローは適合性と署名のワークベンチでカバーされています

作成、制限付き構造走査、保存ゲート、マニフェスト抽出はすべてPDFlibPas Delphi PDF libraryに同梱されます。つまりテンプレートをプロファイルの下で生成し、ライタの状態を見たことのない別のコード経路で検証できます。その分離こそ、適合の主張に価値を持たせる唯一の理由です