Техническая статья

Тихие провалы загрузки PDF в Delphi: нужен load report

В PDFium Component для Delphi и Lazarus присваивание TPdf.Active := True никогда не поднимает исключение, когда PDF не грузится: TPdf.SetActive ловит все исключения подряд и оставляет компонент неактивным. Чтобы увидеть настоящую ошибку, зовите вместо этого TPdf.LoadDocument(Options, Report). Та перегрузка перебрасывает исходное исключение дальше и заполняет TPdfLoadReport статусом загрузки, нативным кодом ошибки PDFium и тем, пришлось ли перестраивать таблицу cross-reference

Проблема обычно всплывает в батч-коде. Задача извлечения таблиц обходит папку из 13 настоящих PDF с одним общим TPdf, и 7 из них возвращаются как провалы. Ни один из тех 7 файлов на деле не битый. Блоки except вокруг загрузки не срабатывают ни разу, лог обвиняет не те имена файлов, а первая видимая ошибка — голый EPdfError про неактивный компонент, поднятый чтением свойства несколькими строками после той загрузки, что провалилась на самом деле. Два отдельных поведения складываются в ту картину, и оба работают как задумано

Почему TPdf.Active := True не поднимает исключение, когда PDF не грузится?

TPdf.SetActive оборачивает LoadDocument в try..except, который глотает любой класс исключений и просто оставляет компонент неактивным. Проглатывание нарочно: тот же сеттер срабатывает, когда дизайнер форм щёлкает Active в IDE, а битый путь не должен ронять IDE. В рантайме TPdf.Active просто рапортует, существует ли нативный хэндл документа, так что после провалившейся загрузки он читается как False, и ничего больше не происходит. Что бы ни поднялось, оно ушло — был ли то EPdfError от парсера, ошибка stream или EAccessViolation от полупривязанного pdfium.dll. Детальные DLL-сообщения, описанные в статье о диагностике провалов загрузки pdfium.dll в Delphi, доходят до вашего хэндлера только через вызов, который их не глотает

Два пути загрузки в PDFium Component: присваивание Active true глотает любое исключение в сеттере и откладывает провал до первого охраняемого вызова, где CheckActive поднимает EPdfError про неактивный компонент, тогда как LoadDocument с TPdfLoadOptions и TPdfLoadReport проводит аудит заголовка, startxref, xref и маркера конца файла, а затем перебрасывает исходное исключение с настоящей причиной при себе
Проглатывание нарочно, потому что сеттер делит с дизайнером IDE; батч-коду нужна перегрузка, которая поднимает, репортит и рассказывает настоящую историю файла
Pdf.FileName := FileName;
try
  Pdf.Active := True;       // SetActive глотает любое исключение загрузки
except
  on E: Exception do
    Log.Add(FileName + ': ' + E.Message);   // никогда не выполнится
end;
// Провал всплывает здесь вместо этого, в виде generic 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, называющий компонент, но не файл и не причину. Проверка Pdf.Active сразу после присваивания превращает ошибочно приписанный краш в честную запись «не загрузился». До PDFiumPas v3.122.1 причина терялась в этой точке; с v3.122.1 провалившееся присваивание заменяет LastLoadReport репортом plsFailed, несущим текст ошибки, поэтому причина выживает. Сам объект исключения и байтовый аудит всё ещё требуют другого входа

Почему переиспользование одного TPdf падает со второго файла?

TPdf.FileName можно присваивать, только пока компонент неактивен, поэтому общий экземпляр отвергает второй файл ещё до всякой попытки его загрузить. TPdf.SetFileName начинается с CheckInactive, и тот же предохранитель стоит на Password и FormFill. После первой успешной загрузки экземпляр остаётся активным, следующее присваивание поднимает исключение, и если батч-цикл его ловит и едет дальше, ошибка ложится под новое имя файла, пока старый документ ещё открыт. В смеси с проглоченными провалами загрузки лог перестаёт совпадать с реальностью. В репродукции на 13 файлов общий экземпляр отчитал 7 провалов, тогда как свежий TPdf.Create(nil) на каждый документ открыл все 13. Установка Active := False между файлами тоже работает, но один экземпляр на документ изолирует каждый файл сам по себе, по построению

Хронология общего TPdf в PDFium, падающего со второго файла: после первой загрузки экземпляр остаётся активным, следующее присваивание FileName поднимает исключение в CheckInactive до всякой попытки загрузки, а батч-цикл пишет ошибку под новым именем файла, пока старый документ ещё открыт, — ловушка за семью ложными провалами в батче из 13 файлов
SetFileName стоит на страже через CheckInactive, так что общий экземпляр отвергает второй файл, не попробовав его; изолируйте каждый документ собственным TPdf, и лог снова совпадёт с реальностью

Что даёт TPdf.LoadDocument с TPdfLoadReport?

TPdf.LoadDocument(const Options: TPdfLoadOptions; out Report: TPdfLoadReport) поднимает настоящее исключение и вдобавок рассказывает структурно, что произошло. Файловая перегрузка грузит FileName; родственные перегрузки берут TBytes или указатель с размером, а LoadCustomDocument(AStream, AOwnsStream, Options, Report) покрывает stream'ы. Каждая валидирует опции, проверяет, что экземпляр неактивен, гоняет байтовый аудит заголовка, startxref, секций xref и маркера %%EOF, затем исполняет нативную загрузку. Аудит ограничен лимитами того же рода, что обсуждались в статье о ресурсных бюджетах парсера для недоверенных PDF: TPdfLoadOptions.Default ставит AuditByteLimit в 256 МиБ, MaxIssues в 256, MaxXrefSections в 1024 и MaxXrefEntries в 4 000 000. При провале метод ставит Report.Status := plsFailed и перебрасывает исключение; поскольку 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, с взведённым IssuesTruncated, когда MaxIssues обрезал список

Конвейер LoadDocument в PDFium Component и его TPdfLoadReport: валидация опций и проверка неактивности поднимают исключение до всякого репорта, байтовый аудит проходит заголовок, startxref, секции xref и маркер конца файла, нативная загрузка записывает FPDF_GetLastError, а исходы ветвятся на loaded, loaded with recovery после перестройки xref, строгое отвержение или провал
Status, NativeErrorCode и список находок отвечают на то, что нужно батч-логу; байтовый аудит добавляет только перегрузка с опциями, а с v3.122.1 провалившееся Active := True всё равно записывает plsFailed в LastLoadReport
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);          // один экземпляр на документ
    try
      Pdf.FileName := Files[I];
      try
        Pdf.LoadDocument(Options, Report);
      except
        on E: Exception do
        begin
          // Report заполнен, хотя LoadDocument поднял исключение
          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 тихо реконструирует битую таблицу cross-reference (ISO 32000-1 §7.5.4), сканируя файл в поисках объектов, — для вьюера прекрасно, а для всего, что обязано обработать ровно те байты, что ему дали, — проблема. После нативной загрузки компонент спрашивает FPDF_DocumentHasValidCrossReferenceTable. В режиме plmCompatible перестройка даёт plsLoadedWithRecovery плюс предупреждение plicNativeCrossReferenceRebuild. В режиме plmStrict компонент выгружает документ, ставит plsRejected, добавляет plicStrictModeRejected и поднимает EPdfError с «Strict PDF load rejected the document». Строгий режим также отвергает любую ошибку аудита, а TPdfLoadOptions.Default(plmStrict) взводит RequireFinalEndOfFileMarker, повышающий отсутствующий %%EOF или данные после последнего (§7.5.5) с предупреждения до ошибки. Аудит xref дополняет проверки на уровне объектов из статьи о валидации object- и xref-streams с PDFium VCL

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 каждый провалившийся load заменяет репорт: провалившееся Active := True, которое по-прежнему оставляет компонент неактивным без исключения, и провалившийся вызов простого LoadDocument или LoadCustomDocument записывают plsFailed с текстом ошибки, тоже без аудита. Ещё две дыры важны на практике. Валидация опций и CheckInactive исполняются до инициализации репорта, поэтому отрицательный AuditByteLimit или уже активный экземпляр поднимают исключение, не произведя репорта. И NativeErrorCode осмыслен, только когда PDFium реально попытался парсить: для отсутствующего файла обёртка поднимает исключение до запуска PDFium, так что пишите в лог ErrorMessage и текст исключения

Практическое правило короткое. Держите Active := True для вьюеров, привязанных к дизайнеру, где неактивный компонент — приемлемый исход. Везде остальном, и превыше всего в батч- и серверном коде, создавайте по одному TPdf на документ, зовите LoadDocument(Options, Report), ловите поднятое исключение и пишите в лог Report.Status, NativeErrorCode и ошибки уровня error из Issues вместе с именем файла. Цена — пара строк на место вызова, и каждый провал оказывается приписан правильному файлу с его настоящей причиной

API load report, строгий режим и байтовый аудит едут вместе с PDFium Component для Delphi, C++Builder и Lazarus, рядом с рендерингом, извлечением текста, заполнением форм и валидацией PDF/A