技術記事

PDFiumを使用した自動化されたPDFプリフライトとリスク監査

本番環境の境界(印刷キュー、アーカイブ、顧客のアップロードポータル)に到着したPDFは、レンダリングされる前に監査される必要があります。ファイルには、外部プログラムを起動するように結び付けられたLaunchアクション、印刷に耐えられないほど粗い画像、提出された印刷ジョブそのものを禁止する暗号化辞書、または条件を満たしていないPDF/Aラベルが含まれている可能性があります。ドキュメントがワークフローに入る前にこのようなルールに照らして検査することをプリフライトと呼びます。PDFium C APIは、ページを1ページもレンダリングすることなく、チェックを直接実装するために必要なすべてをDelphiに提供します

この記事では、チェックそのものを構築します。4つの監査クラスであり、それぞれが共有結果リストに調査結果を追加する小さなルーチンです。インタラクティブ要素、リソースメトリクス、セキュリティ状態、および標準マーカーのすべてに、算術計算を含む実際のコードが用意されています。バッチフォルダーのループ、JSONおよびHTMLのレポートファイル、ファイルごとの分離など、チェックの周囲の仕組みが必要な場合は、PDFiumコンポーネントに既製のプリフライトエンジンが付属しています。配管についてはバッチプリフライトCLIの記事で取り上げています。この2つは意図的に1つの終了コードの語彙を共有しているため、ここで記述された監査プログラムはそのバッチドライバーの直下に組み込まれます

調査結果のレコードと終了コードの契約

すべてのチェックは1つのフラットなレコードタイプに書き込まれます。なぜなら、代替案として各チェックが独自の文章を印刷すると、後でカウント、フィルタリング、またはしきい値処理ができないためです。4つのフィールドで十分です

uses
  System.SysUtils, System.Math, System.IOUtils,
  System.Generics.Collections, pdfium_lib;

type
  TFindingSeverity = (fsInfo, fsWarning, fsError);

  TPreflightFinding = record
    Severity: TFindingSeverity;
    Code: string;       // 安定したマシンキー、例: 'ACT-LAUNCH'
    Page: Integer;      // 1ベース; 0はドキュメントレベルを意味する
    Message: string;    // 人間用; リリース間で自由に言い換えることができる
  end;

  TFindings = TList<TPreflightFinding>;

procedure Add(Findings: TFindings; Severity: TFindingSeverity;
  const Code: string; Page: Integer; const Msg: string);
var
  F: TPreflightFinding;
begin
  F.Severity := Severity;
  F.Code := Code;
  F.Page := Page;
  F.Message := Msg;
  Findings.Add(F);
end;

ダウンストリームのツールは Code をキーにしますが、変更可能な Message テキストをキーにすることは決してありません。プロセスの終了コードは、バッチの記事と同じ3値の契約に従います。0 はファイルが調査結果を生成しなかったことを意味し、1 は調査結果が存在することを意味し、2 はファイルの解析に失敗したかパスワードが必要なために監査自体が実行できなかったことを意味します。コード2を分けておくことは重要です。破損したスキャンのフォルダは、突然のコンプライアンスの崩壊ではなく、上流のスキャナが壊れていることを意味します。この2つを一緒に折りたたむと、誰かが間違った問題を追いかけることになります

インタラクティブ要素:スクリプト、起動ターゲット、外部リンク

PDFiumは、見つけたすべてのアクションを整数型で分類し、fpdf_doc.h の定数は正確に書き留める価値があります。間違ってコピーされた値は、スキャナーを静かに盲目にするためです。実際の列挙は、PDFACTION_UNSUPPORTED = 0PDFACTION_GOTO = 1PDFACTION_REMOTEGOTO = 2PDFACTION_URI = 3PDFACTION_LAUNCH = 4、および PDFACTION_EMBEDDEDGOTO = 5 です。欠落しているものに注意してください。JavaScriptのメンバーはありません。ドキュメントレベルのスクリプトはリンクアクションではなく、FPDFAction_GetType を通じて表示されることはありません。それらは別の呼び出しファミリーによって列挙されます。アクションタイプを架空のJavaScript定数に対してテストする監査プログラムは、コンパイルされ、実行され、そして永久に何も見つけません

const
  PDFACTION_GOTO         = 1;   // ドキュメント内のジャンプ:無害
  PDFACTION_REMOTEGOTO   = 2;   // 別のローカルファイルへのジャンプ
  PDFACTION_URI          = 3;   // 外部URLを開く
  PDFACTION_LAUNCH       = 4;   // 外部プログラムを起動する
  PDFACTION_EMBEDDEDGOTO = 5;   // 埋め込みファイルへのジャンプ

function ActionTarget(Doc: FPDF_DOCUMENT; Action: FPDF_ACTION;
  AType: ULONG): string;
var
  Buf: array[0..2047] of AnsiChar;
begin
  FillChar(Buf, SizeOf(Buf), 0);
  if AType = PDFACTION_URI then
    FPDFAction_GetURIPath(Doc, Action, @Buf, SizeOf(Buf))
  else
    FPDFAction_GetFilePath(Action, @Buf, SizeOf(Buf));
  Result := string(UTF8String(PAnsiChar(@Buf)));
end;

procedure AuditPageActions(Doc: FPDF_DOCUMENT; Page: FPDF_PAGE;
  PageNo: Integer; Findings: TFindings);
var
  StartPos: Integer;
  Link: FPDF_LINK;
  Action: FPDF_ACTION;
  AType: ULONG;
begin
  StartPos := 0;
  while FPDFLink_Enumerate(Page, @StartPos, @Link) <> 0 do
  begin
    Action := FPDFLink_GetAction(Link);
    if Action = nil then
      Continue;                 // 宛先のみのリンク、フラグを立てるものは何もない
    AType := FPDFAction_GetType(Action);
    case AType of
      PDFACTION_LAUNCH:
        Add(Findings, fsError, 'ACT-LAUNCH', PageNo,
          'Launch action targets "' + ActionTarget(Doc, Action, AType) + '"');
      PDFACTION_URI:
        Add(Findings, fsWarning, 'ACT-URI', PageNo,
          'link opens ' + ActionTarget(Doc, Action, AType));
      PDFACTION_REMOTEGOTO, PDFACTION_EMBEDDEDGOTO:
        Add(Findings, fsWarning, 'ACT-XFILE', PageNo,
          'cross-file destination "' + ActionTarget(Doc, Action, AType) + '"');
    end;                        // PDFACTION_GOTOは設計上無言のまま
  end;
end;

procedure AuditDocumentBehaviors(Doc: FPDF_DOCUMENT; Findings: TFindings);
var
  N: Integer;
begin
  N := FPDFDoc_GetJavaScriptActionCount(Doc);
  if N > 0 then
    Add(Findings, fsError, 'JS-DOC', 0,
      Format('%d document-level JavaScript action(s) run on open', [N]));
  N := FPDFDoc_GetAttachmentCount(Doc);
  if N > 0 then
    Add(Findings, fsWarning, 'ATT-EMB', 0,
      Format('%d embedded file attachment(s)', [N]));
end;

重大度の分割はポリシーをエンコードします。Launchアクションはエラーです。なぜなら、任意のプログラムを開始することはPDFのクリックができる最も危険なことであり、どの請求書もそれを必要としないからです。外部URIは警告です。合法的なドキュメントでは一般的ですが、目に見えるリンクテキストと実際の宛先が一致している必要はないため、レビュアーはクリックせずにターゲットを見る必要があります。ドキュメント内のGoToジャンプは動作ではなく構造であり、レポートにはまったく含まれません。目次エントリごとにオオカミ少年のように叫ぶプリフライトは、人々がそれを無視するように訓練します。JavaScriptカウントの背後にあるスクリプト本体の読み取り、および署名のMDPレベルとXFAの検出については、セキュリティリスクの監査に関する記事で、コンポーネントのオブジェクトラッパーを通じて同じ側面を説明しています

リソースメトリクス:有効な画像DPI

PDF内の画像自体にはDPIがありません。それにはピクセルがあり、ページはそれらのピクセルをポイントで測定された長方形に配置し、72ポイントが1インチになります。解像度はその2つの比率としてのみ存在します。そのため、同じ600x400の写真はサムネイルとしては非常に鮮明ですが、フルページのヒーロー画像としてはぼやけて乱雑になります。したがって、監査ではすべての画像に両方の数値が必要です。画像のメタデータからのソースピクセル寸法と、オブジェクトの境界からの配置された長方形です

procedure AuditPageImages(Page: FPDF_PAGE; PageNo: Integer;
  Findings: TFindings);
var
  I, ObjCount: Integer;
  Obj: FPDF_PAGEOBJECT;
  Meta: FPDF_IMAGEOBJ_METADATA;
  L, B, R, T: Single;
  WidthPt, HeightPt, DpiX, DpiY, EffDpi: Double;
begin
  ObjCount := FPDFPage_CountObjects(Page);
  for I := 0 to ObjCount - 1 do
  begin
    Obj := FPDFPage_GetObject(Page, I);
    if FPDFPageObj_GetType(Obj) <> FPDF_PAGEOBJ_IMAGE then
      Continue;
    if FPDFImageObj_GetImageMetadata(Obj, Page, @Meta) = 0 then
      Continue;
    if FPDFPageObj_GetBounds(Obj, @L, @B, @R, @T) = 0 then
      Continue;

    WidthPt  := R - L;              // ページ上の配置サイズ、ポイント単位
    HeightPt := T - B;
    if (WidthPt <= 0) or (HeightPt <= 0) or
       (Meta.Width = 0) or (Meta.Height = 0) then
      Continue;

    // 72ポイント = 1インチ、したがって配置されたインチ = ポイント / 72、および
    // 有効DPI = ソースピクセル / 配置されたインチ
    DpiX := Meta.Width  / (WidthPt  / 72.0);
    DpiY := Meta.Height / (HeightPt / 72.0);
    EffDpi := Min(DpiX, DpiY);      // 悪い方の軸が印刷品質を決定する

    if EffDpi < 150.0 then
      Add(Findings, fsWarning, 'IMG-LOWRES', PageNo,
        Format('image %dx%d px placed at %.1fx%.1f pt = %.0f DPI effective',
          [Meta.Width, Meta.Height, WidthPt, HeightPt, EffDpi]))
    else if EffDpi > 600.0 then
      Add(Findings, fsInfo, 'IMG-BLOAT', PageNo,
        Format('image is %.0f DPI at placed size; resampling would ' +
          'shrink the file with no visible loss', [EffDpi]));
  end;
end;

しきい値は物理学ではなくポリシーです。150 DPIはオフィスの印刷が目に見えてピクセル化する下限であり、300は通常の商業上の目標であり、600を超えると目に見える品質の向上はなく、ファイルサイズが膨らむだけです。そのため、欠陥ではなく情報としての膨張として報告されます。1つの正直な警告があります。FPDFPageObj_GetBounds は軸に揃えられたボックスを返すため、回転を伴って配置された画像の場合、計算された数値は真の密度を過小評価します。FPDF_IMAGEOBJ_METADATA 構造体には、PDFiumが完全な変換マトリックスから導き出す horizontal_dpivertical_dpi のフィールドも含まれており、2つの結果を比較することは、回転した配置を見つけるための低コストな方法です。同じポイントからピクセルへの算術計算は、反対方向のレンダリングを駆動します。これについてはJPEGエクスポートの記事で取り上げています

セキュリティ状態:暗号化と権限ビット

PDFの暗号化は、異なる役割を持つ2つのパスワードを定義します。ユーザーパスワードは復号化のゲートになります。これがないとファイルはまったく開かず、FPDF_LoadDocumentnil を返し、FPDF_GetLastErrorFPDF_ERR_PASSWORD を報告します。所有者パスワードは権限のゲートになります。所有者パスワードのみで保護されたファイルは資格情報なしで開きますが、準拠するリーダーが尊重しなければならない制限ビットが含まれています。したがって、読み込みの試行自体が最初のセキュリティプローブであり、その区別が終了コードを決定します。ユーザーパスワードのファイルは監査不可(コード2)ですが、所有者パスワードのファイルは通常どおり監査され、単に調査結果を蓄積します

const
  FPDF_ERR_PASSWORD = 4;

function AuditSecurity(const FileName: string;
  Findings: TFindings): FPDF_DOCUMENT;
var
  Perms: ULONG;
  Revision: Integer;
begin
  Result := FPDF_LoadDocument(PAnsiChar(AnsiString(FileName)), nil);
  if Result = nil then
  begin
    if FPDF_GetLastError() = FPDF_ERR_PASSWORD then
      Add(Findings, fsError, 'SEC-USERPW', 0,
        'ユーザー(オープン)パスワードが必要です。監査を続行できません')
    else
      Add(Findings, fsError, 'DOC-BROKEN', 0, 'ファイルの解析に失敗しました');
    Exit;
  end;

  Revision := FPDF_GetSecurityHandlerRevision(Result);
  if Revision >= 0 then       // -1 はファイルが暗号化されていないことを意味する
  begin
    // 空のパスワードで開いたが暗号化されている:所有者パスワードのみ。
    // 誰でも読むことができるが、権限ビットは準拠するリーダーが
    // 彼らに許可するものを制限する。暗号化されていないファイルは
    // すべてのビットがセットされていると報告するため、リビジョンゲートが最初に来る。
    Perms := FPDF_GetDocPermissions(Result);
    Add(Findings, fsInfo, 'SEC-ENC', 0,
      Format('暗号化されています、セキュリティハンドラーリビジョン %d', [Revision]));
    if (Perms and 4) = 0 then      // ビット 3: 印刷
      Add(Findings, fsWarning, 'SEC-NOPRINT', 0,
        '印刷は許可されていません');
    if (Perms and 16) = 0 then     // ビット 5: コンテンツのコピー / 抽出
      Add(Findings, fsInfo, 'SEC-NOCOPY', 0,
        'コンテンツの抽出は許可されていません');
    if (Perms and 2048) = 0 then   // ビット 12: 高解像度印刷
      Add(Findings, fsWarning, 'SEC-LOWPRINT', 0,
        '低解像度印刷のみ許可されています');
  end;
end;

マスクはISO 32000-1の表22からのもので、ビットの番号は1から始まります。/P 値のビット3はマスク4であり、ビット5は16、ビット12は2048です。与えられた調査結果が重要であるかどうかは、ルーティングの決定によります。印刷局は、期限の3時間前にRIPで通知するのではなく、提出者が明確なメッセージを受け取る受付の時点で SEC-NOPRINT ファイルを拒否する必要があります。アーカイブは、暗号化と長期保存が相容れないため、SEC-ENC 自体をブロッカーとして扱う必要があります。この点については、標準チェックで正式に説明します

標準マーカー:PDF/Aクレームの読み取り

ファイルはXMPメタデータパケットでPDF/Aへの準拠を宣言します。pdfaid:part プロパティ(1から4まで)と pdfaid:conformance(視覚的忠実度の b や完全な構造タギングの a などのレベル文字)を通じて行われます。PDFiumのC APIはXMPアクセサーを提供していません。FPDF_GetMetaText はInfo辞書のみを読み取り、そこには識別情報は存在しません。逃げ道は、標準自体のルールにあります。ISO 19005では、XMPメタデータストリームを非圧縮で保存することが義務付けられており、ツールが完全なPDFパーサーなしでそれを見つけることができるようにするためです。したがって、生のバイトスキャンは正当なクレーム検出器であり、クレームが圧縮ストリーム内に隠されているファイルは、すでに自身が主張する標準に違反しています

function PdfAClaim(const FileName: string): string;
var
  Bytes: TBytes;
  S: RawByteString;
  P, Limit: Integer;
begin
  Result := '';                     // 空 = PDF/A クレームが存在しない
  Bytes := TFile.ReadAllBytes(FileName);
  if Length(Bytes) = 0 then
    Exit;
  SetString(S, PAnsiChar(@Bytes[0]), Length(Bytes));
  P := Pos('pdfaid:part', S);       // XMP 識別スキーマ
  if P = 0 then
    Exit;
  // <pdfaid:part>2</pdfaid:part> と pdfaid:part="2" の両方を処理する:
  // プロパティ名の後の最初の数字を取る。
  Limit := Min(P + 32, Length(S));
  Inc(P, Length('pdfaid:part'));
  while (P <= Limit) and not (S[P] in ['1'..'4']) do
    Inc(P);
  if P <= Limit then
    Result := 'PDF/A-' + Char(S[P]);
end;

これが生成する調査結果は、クレームがファイルのプロパティではなく宣言であるため、意図的に情報のレベルになっています。XMPエントリは、壊れたものも含め、どのプロデューサーでも書き込むことができるXMLの1行です。適合性とは、埋め込まれたフォント、デバイスに依存しない色、および禁止された機能に関する何百ものルールをファイルが実際に満たしているかどうかです。クレームを検出することで、どのファイルを実際の検証にルーティングするかがわかるだけであり、それ以上のものではありません。コンポーネントの組み込みのプリフライトエンジンは、PDF/A、PDF/UA、およびPDF/Xの各プロファイル全体でその検証を実行します。バッチCLIの記事では、後で監査者が開くことができるレポートを使用して、それをパイプラインに配線する方法を示しています

問題ファイルに対する実行

ドライバーはチェックを結合します。セキュリティは、監査がまったく実行されるかどうかを決定するため最初に配置され、次にドキュメントレベルの動作と標準クレーム、最後にアクションと画像のページループになります

function AuditFile(const FileName: string; Findings: TFindings): Integer;
var
  Doc: FPDF_DOCUMENT;
  Page: FPDF_PAGE;
  I: Integer;
  Claim: string;
begin
  Doc := AuditSecurity(FileName, Findings);
  if Doc = nil then
    Exit(2);                        // 監査の失敗、評決ではない
  try
    AuditDocumentBehaviors(Doc, Findings);
    Claim := PdfAClaim(FileName);
    if Claim <> '' then
      Add(Findings, fsInfo, 'STD-PDFA', 0,
        Claim + ' 準拠が主張されています (宣言のみ、検証されていません)');
    for I := 0 to FPDF_GetPageCount(Doc) - 1 do
    begin
      Page := FPDF_LoadPage(Doc, I);
      if Page = nil then
      begin
        Add(Findings, fsError, 'PAGE-BROKEN', I + 1, 'ページの解析に失敗しました');
        Continue;
      end;
      try
        AuditPageActions(Doc, Page, I + 1, Findings);
        AuditPageImages(Page, I + 1, Findings);
      finally
        FPDF_ClosePage(Page);
      end;
    end;
  finally
    FPDF_CloseDocument(Doc);
  end;
  if Findings.Count > 0 then
    Result := 1
  else
    Result := 0;
end;

外部の代理店から戻ってきたパンフレットに対して、出力はこのようになります

> preflight_audit brochure_final.pdf
brochure_final.pdf: 5 finding(s)
  [ERROR]   ACT-LAUNCH   page 3   Launch action targets "..\tools\setup.exe"
  [ERROR]   JS-DOC       doc      2 document-level JavaScript action(s) run on open
  [WARNING] IMG-LOWRES   page 7   image 412x287 px placed at 396.0x275.8 pt = 75 DPI effective
  [WARNING] SEC-NOPRINT  doc      printing is not permitted
  [INFO]    STD-PDFA     doc      PDF/A-2 conformance claimed (declaration only, not validated)
exit code 1

各行は単独でもアクション可能ですが、組み合わせが本当の評決となります。このファイルは、暗号化辞書とライブJavaScriptを含みながらPDF/A-2を主張しており、PDF/Aはそれらを両方とも完全に禁止しています。したがって、深いバリデーターが実行される前に、その主張は偽であることが証明可能です。フラットな調査結果のリストが表面化し、ブール値の合否が隠すのは、このような矛盾です

この監査が教えてくれないこと

範囲に関する誠実さが、プリフライトツールを信頼させる理由です。上記の内容はすべて、ファイルがそれ自体について宣言していることを読み取ります。PDFiumは構造を解析し、この監査はそれをインベントリ化します。PDF/A検証は行いません。埋め込まれたフォントに対するグリフカバレッジチェック、出力インテントに対する色空間分析、クレームと準拠性を分ける条項レベルのルールのいずれも行いません。そのためには、コンポーネントのプリフライトエンジンやveraPDFなどの専用のバリデーターが必要です。権限ビットは準拠するリーダーが尊重する宣言であり、暗号化の壁ではないため、SEC-NOPRINT は施行ではなく意図を記述します。アクションスキャンはリンク注釈とドキュメントレベルのスクリプトをカバーします。フォームフィールドのイベント辞書に埋もれたスクリプトには、上にフォームAPIが必要です。また、署名チェック(それを監査で拡張した場合)は、宣言された意図を報告するものであり、検証された暗号化ではありません。証明書チェーンの検証は別の仕事です。プリフライト監査は、裁判ではなく、事前の面接です。その役割は、ルーティングの決定を、情報に基づいた、迅速かつ反復可能なものにすることです

注:この監査を通じて使用されているドキュメント、ページ、注釈、画像オブジェクトAPIは、高度なDelphiラッパーと完全な標準検証プリフライトエンジンとともに、PDFiumコンポーネントに同梱されています