技術記事

DelphiのサイレントなPDFロード失敗にはロードレポート

DelphiとLazarus向けPDFium Componentでは、TPdf.Active := Trueの代入は、PDFのロードが失敗しても決してraiseしません。TPdf.SetActiveがすべての例外をcatchして、コンポーネントを非アクティブのままにするからです。本当のエラーを見るには、代わりにTPdf.LoadDocument(Options, Report)を呼びます。このオーバーロードは元の例外を再raiseし、ロードステータス、ネイティブのPDFiumエラーコード、クロスリファレンステーブルの再構築が要ったかどうかを、TPdfLoadReportに詰めます

問題はたいていバッチコードで顔を出します。テーブル抽出ジョブが、現実世界のPDF 13個が入ったフォルダを、共有のTPdf1個で巡回し、7個が失敗として戻ってくる。7個のどれも実際には壊れていません。ロードを囲むexceptブロックは決して発火せず、ログは間違ったファイル名を名指し、最初に見えるエラーは非アクティブなコンポーネントについての素のEPdfErrorで、本当に失敗したロードの数行後のプロパティ読み取りからraiseされています。2つの別個の挙動が積み重なってこの絵を作っており、どちらも設計どおりに動いています

PDFのロードが失敗してもTPdf.Active := Trueがraiseしない理由

TPdf.SetActiveはLoadDocumentを、あらゆる例外クラスを飲み込んでコンポーネントを非アクティブのままにするだけのtry..exceptで包みます。飲み込みは意図的です。同じsetterは、フォームデザイナがIDEでActiveを切り替えるときにも走りますし、悪いパスでIDEを落としてはいけません。実行時、TPdf.Activeはネイティブの文書ハンドルが存在するかを報告するだけで、失敗したロードの後はFalseを読み、他には何も起こりません。raiseされたものは何であれ消えます。パーサーからのEPdfErrorでも、ストリームエラーでも、半分だけバインドされたpdfium.dllからのEAccessViolationでも。Delphiでのpdfium.dllロード失敗の診断で述べた詳細なDLLメッセージは、それらを飲み込まない呼び出しを通じてだけ、あなたのハンドラへ届きます

PDFium Componentの2つのロード経路の図。Activeへのtrue代入はsetterで全例外を飲み込み、失敗を最初のガード付き呼び出しへ先送りします。そこでCheckActiveが非アクティブなコンポーネントについてのEPdfErrorをraiseします。一方TPdfLoadOptionsとTPdfLoadReport付きのLoadDocumentは、ヘッダ、startxref、xref、end of fileマーカーを監査し、本当の原因を付けたまま元の例外を再raiseします
飲み込みは意図的です。IDEデザイナがsetterを共有しているからです。バッチコードに要るのは、raiseして報告し、ファイルの本当の事情を語るオーバーロードです
Pdf.FileName := FileName;
try
  Pdf.Active := True;       // SetActiveはロード例外をすべて飲み込む
except
  on E: Exception do
    Log.Add(FileName + ': ' + E.Message);   // 決して実行されない
end;
// 失敗は代わりにここで、一般的なEPdfErrorとして表面化する:
// 'Cannot perform this operation on an inactive Pdf1 component'
Log.Add(Format('%s: %d pages', [FileName, Pdf.PageCount]));

// 既存コードへの最小修正:代入の直後にActiveをテストする
// v3.122.1以降、LastLoadReportが飲み込まれたエラーのテキストを保持する
Pdf.Active := True;
if not Pdf.Active then
  Log.Add(FileName + ': load failed: ' + Pdf.LastLoadReport.ErrorMessage);

失敗はついに、最初のガード付き呼び出しで姿を見せます。TPdf.PageCountは、ほとんどの文書プロパティと同じくCheckActiveから始まり、コンポーネントは名指すものの、ファイルも原因も名指さないEPdfErrorをraiseします。代入の直後にPdf.Activeをテストすれば、誤帰属するクラッシュが、正直な「failed」エントリに変わります。PDFiumPas v3.122.1より前は、原因はその時点で消えていました。v3.122.1以降、失敗した代入はLastLoadReportを、エラーテキストを運ぶplsFailedレポートで置き換えるので、原因は生き残ります。例外オブジェクトそのものとバイトレベルの監査には、それでも別の入り口が要ります

1つのTPdfの使い回しが2つ目のファイルから失敗する理由

TPdf.FileNameはコンポーネントが非アクティブの間にしか代入できません。だから共有インスタンスは、2つ目のファイルをロードしようとするより前に拒否します。TPdf.SetFileNameはCheckInactiveから始まり、同じガードがPasswordとFormFillも守ります。最初のロードが成功した後もインスタンスはアクティブのまま、次の代入はraiseし、バッチループがその例外をcatchして先へ進むと、エラーは新しいファイル名の下に着地します。一方、旧文書はまだ開いたままです。飲み込まれたロード失敗と混ざると、ログは現実と一致しなくなります。13ファイルの再現では、共有インスタンスは7失敗を報告し、文書ごとの新しいTPdf.Create(nil)は全部の13を開きました。ファイル間でActive := Falseを設定するのも有効ですが、文書ごとに1インスタンスなら、構造的にすべてのファイルが隔離されます

共有のPDFium TPdfが2つ目のファイルから失敗していくタイムラインの図。最初のロード後、インスタンスはアクティブのまま、次のFileName代入はロード試行の前にCheckInactiveでraiseし、バッチループは旧文書が開いたままのうちに、エラーを新しいファイル名の下へ記録します。13ファイルのバッチで7つの偽失敗が生じた罠です
SetFileNameはCheckInactiveでガードするので、共有インスタンスは2つ目のファイルを試す前に拒否します。文書ごとにTPdfを分ければ、ログは現実とまた一致します

TPdfLoadReport付きのTPdf.LoadDocumentが得られるもの

TPdf.LoadDocument(const Options: TPdfLoadOptions; out Report: TPdfLoadReport)は本当の例外をraiseし、さらに何が起きたかを構造化された形で教えます。ファイルのオーバーロードはFileNameをロードし、兄弟のオーバーロードはTBytesやポインタ+サイズを取り、ストリームはLoadCustomDocument(AStream, AOwnsStream, Options, Report)が担当します。どれもオプションを検証し、インスタンスが非アクティブかを確認し、ヘッダ、startxref、xrefセクション、%%EOFマーカーのバイトレベル監査を走らせ、それからネイティブロードを実行します。監査は、信頼できないPDFのためのパーサーリソース予算で述べたのと同じ種類の上限に括られます。TPdfLoadOptions.DefaultはAuditByteLimitを256 MiB、MaxIssuesを256、MaxXrefSectionsを1024、MaxXrefEntriesを4,000,000に設定します。失敗時、メソッドはReport.Status := plsFailedを設定して再raiseします。Reportはその場に書かれるので、中身は例外を生き延び、コピーがTPdf.LastLoadReportへ格納されます

レポートのフィールドは、バッチログが実際に必要とする問いに答えます。StatusはplsNotAttempted、plsLoaded、plsLoadedWithRecovery、plsRejected、plsFailedのいずれかです。NativeErrorCodeはFPDF_GetLastErrorを保持します。FPDF_ERR_PASSWORD(4)が、FPDF_ERR_FORMAT(3)として報告された破損ファイルから、パスワードの欠落や誤りを切り分けます。UsedRecovery、CrossReferenceTableValid、RecoveryRouteは、PDFiumがxrefテーブルの再構築を要したかを語り、Issuesは監査の発見をCode、Severity、Offset、ObjectNumber、MessageText付きで列挙します。MaxIssuesがリストを打ち切ったときはIssuesTruncatedが立ちます

PDFium ComponentのLoadDocumentパイプラインとTPdfLoadReportの図。オプション検証と非アクティブ確認はレポートが存在するより前にraiseし、バイト監査はヘッダ、startxref、xrefセクション、end of fileマーカーを歩き、ネイティブロードはFPDF_GetLastErrorを記録し、結果はロード、xref再構築後の復旧付きロード、厳格拒否、失敗へ分かれます
Status、NativeErrorCode、問題リストがバッチログの必要に答えます。バイト監査を足すのはオプション付きオーバーロードだけです。v3.122.1以降、失敗したActive := TrueはLastLoadReportへplsFailedを記録し続けます
uses
  SysUtils, Classes, TypInfo, FPdfView, PDFium;

procedure ProcessBatch(Files, Log: TStrings);
var
  I: Integer;
  Pdf: TPdf;
  Options: TPdfLoadOptions;
  Report: TPdfLoadReport;
begin
  Options := TPdfLoadOptions.Default(plmCompatible);
  for I := 0 to Files.Count - 1 do
  begin
    Pdf := TPdf.Create(nil);          // 文書ごとに1インスタンス
    try
      Pdf.FileName := Files[I];
      try
        Pdf.LoadDocument(Options, Report);
      except
        on E: Exception do
        begin
          // LoadDocumentがraiseしてもReportは埋まっている
          if Report.NativeErrorCode = FPDF_ERR_PASSWORD then
            Log.Add(Files[I] + ': password required')
          else
            Log.Add(Format('%s: %s (%s)', [Files[I],
              GetEnumName(TypeInfo(TPdfLoadStatus), Ord(Report.Status)),
              E.Message]));
          Continue;
        end;
      end;
      if Report.UsedRecovery then
        Log.Add(Files[I] + ': opened after PDFium rebuilt the xref table');
      ExtractTables(Pdf, Log);
    finally
      Pdf.Free;
    end;
  end;
end;

plmStrictでロードすべきとき

黙って修復されたファイルが、拒否されたファイルより困る場面ではplmStrictを使います。アーカイブの受け入れ、証拠の扱い、署名パイプラインなどです。PDFiumは壊れたクロスリファレンステーブル(ISO 32000-1 §7.5.4)を、ファイルからオブジェクトを探して黙々と再構築します。ビューアには有り難い挙動ですが、与えられたバイトをそのまま処理せねばならないものにとっては問題です。ネイティブロードの後、コンポーネントはFPDF_DocumentHasValidCrossReferenceTableを尋ねます。plmCompatibleモードでは、再構築はplsLoadedWithRecoveryとplicNativeCrossReferenceRebuild警告になります。plmStrictモードでは、コンポーネントは文書をアンロードし、plsRejectedを設定し、plicStrictModeRejectedを加え、「Strict PDF load rejected the document」付きのEPdfErrorをraiseします。厳格モードは監査エラーも拒否し、TPdfLoadOptions.Default(plmStrict)はRequireFinalEndOfFileMarkerを有効にします。最後の%%EOFの欠落や、その後ろのデータ(§7.5.5)が、警告からエラーへ昇格するわけです。xref監査は、PDFium VCLでオブジェクトストリームとxrefストリームを検証するのオブジェクトレベル検査を補完します

function AcceptForArchive(const FileName: string; out Reason: string): Boolean;
var
  Pdf: TPdf;
  Report: TPdfLoadReport;
  I: Integer;
begin
  Result := False;
  Reason := '';
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := FileName;
    try
      Pdf.LoadDocument(TPdfLoadOptions.Default(plmStrict), Report);
      Result := True;               // xref有効、監査エラーなし
    except
      on E: EPdfError do
      begin
        Reason := E.Message;
        for I := 0 to High(Report.Issues) do
          if Report.Issues[I].Severity = plisError then
            Reason := Reason + sLineBreak + Format('  at offset %d: %s',
              [Report.Issues[I].Offset, Report.Issues[I].MessageText]);
      end;
    end;
  finally
    Pdf.Free;
  end;
end;

TPdf.LastLoadReportが真実を語らなくなる場所

TPdf.LastLoadReportが完全になるのは、オプションを取るLoadDocumentオーバーロードの後だけです。バイト監査を走らせるのはそれらのオーバーロードだけだからです。成功したActive := Trueはバイト監査なしのcompatibleモードレポートを書くので、AuditAttemptedはFalseのままです。PDFiumPas v3.122.1より前、失敗したものは何も書かず、共有インスタンスではLastLoadReportが前のファイルを述べ続けました。しばしば安心感のあるplsLoaded付きで。v3.122.1以降、失敗したロードはすべてレポートを置き換えます。raiseせずにコンポーネントを非アクティブのままにする失敗したActive := Trueも、失敗した素のLoadDocumentやLoadCustomDocumentの呼び出しも、エラーテキスト付きのplsFailedを記録します。これも監査なしです。実務で効いてくる隙間がもう2つあります。オプション検証とCheckInactiveはレポートの初期化より前に走るので、負のAuditByteLimitや、すでにアクティブなインスタンスは、レポートを出さずにraiseします。そしてNativeErrorCodeに意味があるのは、PDFiumが実際にパースを試みたときだけです。存在しないファイルでは、ラッパーはPDFiumが走る前にraiseするので、代わりにErrorMessageと例外テキストを記録してください

実務の規則は短いです。Active := Trueは、非アクティブなコンポーネントが許容できる結果である、デザイナに束縛されたビューアのために取っておく。それ以外の場所、とりわけバッチとサーバーのコードでは、文書ごとにTPdfを1個作り、LoadDocument(Options, Report)を呼び、raiseされる例外をcatchして、Report.Status、NativeErrorCode、エラーレベルのIssuesをファイル名と一緒に記録します。費用は呼び出し箇所あたり数行で、すべての失敗が本当の原因付きで、正しいファイルに帰着します

ロードレポートAPI、厳格モード、バイトレベル監査は、Delphi、C++Builder、Lazarus向けPDFium Componentに搭載されています。レンダリング、テキスト抽出、フォーム入力、PDF/A検証とともにです