Технічна стаття

Мовчазні збої завантаження PDF у Delphi: звіт PDFium

У PDFium Component для Delphi і Lazarus присвоєння TPdf.Active := True ніколи не підіймає, коли PDF провалює завантаження: TPdf.SetActive ловить кожен виняток і лишає компонент неактивним. Щоб побачити справжню помилку, викличте натомість TPdf.LoadDocument(Options, Report). Той перевантажений виклик повторно підіймає оригінальний виняток і заповнює TPdfLoadReport статусом завантаження, нативним кодом помилки PDFium і тим, чи довелося перебудовувати таблицю cross-reference

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

Чому TPdf.Active := True не підіймає, коли PDF провалює завантаження?

TPdf.SetActive загортає LoadDocument у try..except, що ковтає кожен клас винятків і просто лишає компонент неактивним. Ковтання навмисне: той самий сетер працює, коли дизайнер форм клацає Active в IDE, і поганий шлях не мусить валити IDE. У run time TPdf.Active просто звітує, чи існує нативний хендл документа, тож після проваленого завантаження він читається False, і більше нічого не відбувається. Все, що було піднято, зникло — чи то EPdfError від парсера, чи помилка потоку, чи EAccessViolation від напівприв'язаної pdfium.dll. Детальні DLL-повідомлення, описані в статті про діагностику збоїв завантаження pdfium.dll у Delphi, доходять до вашого хендлера лише через виклик, який їх не ковтає

Два шляхи завантаження в PDFium Component: присвоєння Active true ковтає кожен виняток у сетері і відкладає збій до першого захищеного виклику, де CheckActive підіймає EPdfError про неактивний компонент, тоді як LoadDocument з TPdfLoadOptions і TPdfLoadReport аудитує заголовок, startxref, xref і маркер кінця файлу, а потім повторно підіймає оригінальний виняток зі справжньою причиною при ньому
Ковтання навмисне, бо дизайнер IDE ділить той сетер; batch-коду потрібен перевантажений виклик, що підіймає, звітує і розповідає справжню історію файлу
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, називаючи компонент, але не файл і не причину. Тестування Pdf.Active одразу після присвоєння перетворює краш із чужою атрибуцією на чесний запис «failed». До PDFiumPas v3.122.1 причина втрачалася в той момент; від v3.122.1 провалене присвоєння замінює LastLoadReport звітом plsFailed, що несе текст помилки, тож причина виживає. Сам об'єкт винятку і байтовий аудит досі потребують іншої точки входу

Чому повторне використання одного TPdf провалюється від другого файлу?

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

Хронологія спільного PDFium TPdf, що провалюється від другого файлу: після першого завантаження інстанс лишається активним, наступне присвоєння FileName підіймає в CheckInactive до будь-якої спроби завантаження, а batch-петля логує помилку під новим іменем файлу, поки старий документ досі відкритий, — пастка за 7 хибних збоїв у 13-файловій партії
SetFileName охороняється CheckInactive, тож спільний інстанс відкидає файл два ще до спроби; ізолюйте кожен документ власним TPdf, і лог знову збігатиметься з реальністю

Що дає TPdf.LoadDocument з TPdfLoadReport?

TPdf.LoadDocument(const Options: TPdfLoadOptions; out Report: TPdfLoadReport) підіймає справжній виняток і ще розповідає, що сталося, у структурній формі. Файловий перевантажений виклик завантажує FileName; побратими беруть TBytes чи вказівник і розмір, а LoadCustomDocument(AStream, AOwnsStream, Options, Report) покриває потоки. Кожен валідує опції, перевіряє, що інстанс неактивний, ганяє байтовий аудит заголовка, startxref, секцій xref і маркера %%EOF, а потім виконує нативне завантаження. Аудит обмежений того сорту межами, які розбирає стаття про ресурсні бюджети парсера для недовірених PDF: TPdfLoadOptions.Default ставить AuditByteLimit у 256 МіБ, MaxIssues у 256, MaxXrefSections у 1024 і MaxXrefEntries у 4 000 000. При провалі метод ставить Report.Status := plsFailed і повторно підіймає; оскільки Report пишеться на місці, його вміст переживає виняток, а копія зберігається в TPdf.LastLoadReport

Поля звіту відповідають на питання, які batch-лог справді потребує. 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 і список знахідок відповідають на те, що потребує batch-лог; байтовий аудит додає лише перевантажений виклик з опціями, а від 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-потоків з 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 кожне провалене завантаження замінює звіт: провалене Active := True, яке досі лишає компонент неактивним без підйому, і провалений звичайний виклик LoadDocument чи LoadCustomDocument записують plsFailed з текстом помилки, знову без аудиту. Ще дві прогалини важать на практиці. Валідація опцій і CheckInactive працюють до ініціалізації звіту, тож від'ємний AuditByteLimit чи вже активний інстанс підіймають, не виробляючи звіту. І NativeErrorCode осмислений лише коли PDFium справді пробував розбір; для відсутнього файлу обгортка підіймає до запуску PDFium, тож логуйте ErrorMessage і текст винятку замість нього

Практичне правило коротке. Тримайте Active := True для переглядачів, прив'язаних до дизайнера, де неактивний компонент — прийнятний наслідок. Усюди інше, і передусім у batch- та серверному коді, створюйте один TPdf на документ, викликайте LoadDocument(Options, Report), ловіть виняток, який він підіймає, і логуйте Report.Status, NativeErrorCode і помилкові Issues разом з іменем файлу. Ціна — кілька рядків на місце виклику, і кожен збій отримує атрибуцію правильному файлу зі справжньою причиною

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