技術記事

PDFiumによるPDFプリフライトとリスク監査の自動化

本番の境界、たとえば印刷キューやアーカイブ、顧客のアップロード用ポータルに届いたPDFは、何かがそれを描画する前に監査されるべきです。そのファイルは、外部プログラムを起動するよう仕込まれたLaunchアクションや、印刷に耐えないほど粗い画像、まさに依頼された印刷そのものを禁じる暗号化辞書、あるいは実態が伴わないPDF/Aのラベルを抱えているかもしれません。文書がワークフローに入る前にこうした規則と突き合わせて検査することをプリフライトと呼び、PDFiumのC APIは、1ページも描画することなくこれらの検査をDelphiで直接実装するのに必要なものをすべて備えています

本稿では検査そのものを組み立てます。4種類の監査クラスがあり、いずれも共通の結果リストへ所見を追加する小さなルーチンです。対話的要素、リソースの指標、セキュリティの状態、規格のマーカーのすべてについて、算術も含めて動くコードを示します。必要なのが検査の周辺装置、つまりフォルダー単位の一括処理ループ、JSONやHTMLのレポートファイル、ファイルごとの隔離であれば、PDFium Componentに出来合いのプリフライトエンジンが同梱されており、一括プリフライトCLIの記事がその配管を扱っています。両者は意図的に1つの終了コード体系を共有しているので、ここで書いた監査処理はそのバッチドライバの下にそのまま収まります

PDF パイプラインの図。入力 PDF が4つの検査クラスへ分岐し、その所見が1つの TPreflightFinding レコードへ集まり、しきい値に基づく終了コードへ対応づけられる
監査は信頼できないファイルを、対話的要素、リソースの指標、セキュリティの状態、規格のマーカーという4つの検査クラスへ分岐させ、すべての結果を数えられる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を分けておくことには意味があります。壊れたスキャン画像だらけのフォルダーは、上流のスキャナーの故障であってコンプライアンスの突然の崩壊ではありません。両者を一緒くたにすると、誰かが誤った問題を追いかけることになります

対話的要素:スクリプト、起動対象、外部リンク

PDFiumは見つけたアクションをすべて整数の型で分類します。fpdf_doc.hの定数は正確に押さえておく価値があります。値を写し間違えると、スキャナーは黙って何も見えなくなるからです。実際の列挙はPDFACTION_UNSUPPORTED = 0PDFACTION_GOTO = 1PDFACTION_REMOTEGOTO = 2PDFACTION_URI = 3PDFACTION_LAUNCH = 4PDFACTION_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を持ちません。持っているのはピクセルであり、ページはそのピクセルをポイント単位の矩形に配置します。1インチは72ポイントです。解像度は両者の比としてのみ存在します。だからこそ、同じ600×400の写真がサムネイルでは非常に鮮明で、全面の主役画像ではぼやけた塊になるのです。したがって監査は画像ごとに2つの数値を必要とします。画像メタデータから得る元のピクセル寸法と、オブジェクトの境界から得る配置後の矩形です

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,
        'user (open) password required; audit cannot proceed')
    else
      Add(Findings, fsError, 'DOC-BROKEN', 0, 'file failed to parse');
    Exit;
  end;

  Revision := FPDF_GetSecurityHandlerRevision(Result);
  if Revision >= 0 then       // -1はファイルが暗号化されていないことを意味する
  begin
    // 空のパスワードで開けたのに暗号化されている: オーナーパスワードのみ。
    // 誰でも読めるが、権限ビットが、準拠したリーダーの許す操作を
    // 制限する。暗号化されていないファイルは全ビットが立った状態を
    // 報告するので、先にリビジョンで門番をかける。
    Perms := FPDF_GetDocPermissions(Result);
    Add(Findings, fsInfo, 'SEC-ENC', 0,
      Format('encrypted, security handler revision %d', [Revision]));
    if (Perms and 4) = 0 then      // ビット3: 印刷
      Add(Findings, fsWarning, 'SEC-NOPRINT', 0,
        'printing is not permitted');
    if (Perms and 16) = 0 then     // ビット5: コンテンツのコピーと抽出
      Add(Findings, fsInfo, 'SEC-NOCOPY', 0,
        'content extraction is not permitted');
    if (Perms and 2048) = 0 then   // ビット12: 高解像度印刷
      Add(Findings, fsWarning, 'SEC-LOWPRINT', 0,
        'only low-resolution printing is permitted');
  end;
end;

マスクはISO 32000-1の表22に由来し、そこではビットが1から数えられます。/P値のビット3はマスク4、ビット5は16、ビット12は2048です。ある所見が重要かどうかは振り分けの判断です。印刷業者なら、SEC-NOPRINTのファイルは締切の3時間前のRIPではなく、提出者に明確なメッセージが届く受付の段階で差し戻すべきです。アーカイブならSEC-ENC自体を阻止要因として扱うべきです。暗号化と長期保存は相容れないからであり、この点はこれから規格の検査が正式に示すところでもあります

規格のマーカー:PDF/Aの宣言を読む

ファイルはPDF/A適合をXMPメタデータのパケットの中で宣言します。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のエントリは、壊れたものを含めどんな生成ソフトでも書ける1行のXMLにすぎません。適合とは、埋め込みフォント、デバイス非依存の色、禁止された機能に関する何百もの規則を、ファイルが実際に満たしていることです。宣言の検出が教えてくれるのは、どのファイルを本物の検証へ回すべきかということだけです。コンポーネントに組み込まれたプリフライトエンジンは、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 + ' conformance claimed (declaration only, not validated)');
    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, 'page failed to parse');
        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

各行はそれ自体で対処可能ですが、本当の判定は組み合わせの中にあります。このファイルはPDF/A-2を名乗りながら、暗号化辞書と生きたJavaScriptを抱えており、PDF/Aはそのどちらも明確に禁じています。つまり、深い検証器を走らせるまでもなく、その宣言は誤りだと証明できます。平坦な所見リストが浮かび上がらせ、真偽1つの合否判定が隠してしまうのが、この種の矛盾です

この監査で分からないこと

適用範囲について正直であることが、プリフライトツールの信頼を保ちます。ここまでのすべては、ファイルが自身について宣言している内容を読むものです。PDFiumは構造を解析し、この監査はそれを棚卸しします。PDF/Aの検証は行いません。埋め込みフォントに対するグリフ網羅の検査も、出力インテントに対する色空間の解析も、宣言と適合を分ける条項レベルの規則も一切含まれません。それにはコンポーネントのプリフライトエンジンやveraPDFのような専用の検証器が必要です。権限ビットは、準拠したリーダーが尊重する宣言であって暗号による壁ではないので、SEC-NOPRINTは強制ではなく意図を表します。アクションの走査が対象とするのはリンク注釈と文書レベルのスクリプトです。フォームフィールドのイベント辞書に埋もれたスクリプトには、その上にフォームのAPIが必要です。そして監査に署名の検査を加えた場合も、報告されるのは宣言された意図であって検証済みの暗号ではありません。証明書チェーンの検証は別の仕事です。プリフライト監査は受付の面談であって裁判ではありません。その仕事は、振り分けの判断を、情報に基づき、速く、繰り返し可能にすることです

注:この監査を通じて使用した文書、ページ、注釈、画像オブジェクトの各APIは、高水準のDelphiラッパーおよび規格検証の完全なプリフライトエンジンとともにPDFium Componentに同梱されています