Artykuł techniczny

Ciche błędy ładowania PDF w Delphi: użyj raportu PDFium

W PDFium Component dla Delphi i Lazarusa przypisanie TPdf.Active := True nigdy nie podnosi wyjątku, gdy PDF nie chce się załadować: TPdf.SetActive łapie każdy wyjątek i zostawia komponent nieaktywny. Żeby zobaczyć prawdziwy błąd, wołaj zamiast tego TPdf.LoadDocument(Options, Report). To przeciążenie ponownie wyrzuca oryginalny wyjątek i wypełnia TPdfLoadReport statusem ładowania, natywnym kodem błędu PDFium oraz informacją, czy tabelę odwołań krzyżowych trzeba było przebudować

Problem zwykle wypływa w kodzie wsadowym. Zadanie ekstrakcji tabel przechodzi przez folder 13 prawdziwych PDF-ów z jednym współdzielonym TPdf, a 7 z nich wraca jako porażki. Żaden z tych 7 plików nie jest faktycznie zepsuty. Bloki except wokół ładowania nigdy nie odpalają, log wini złe nazwy plików, a pierwszy widoczny błąd to goły EPdfError o nieaktywnym komponencie, wyrzucony z odczytu właściwości kilka linii po ładowaniu, które faktycznie padło. Dwa osobne zachowania składają się na ten obraz i oba działają zgodnie z projektem

Dlaczego TPdf.Active := True nie podnosi wyjątku, gdy PDF nie chce się załadować?

TPdf.SetActive owija LoadDocument w try..except, które połyka każdą klasę wyjątku i po prostu zostawia komponent nieaktywny. Połknięcie jest celowe: ten sam setter biegnie, gdy projektant formularza przełącza Active w IDE, a zła ścieżka nie może rozwalić IDE. W czasie biegu TPdf.Active po prostu raportuje, czy istnieje natywny uchwyt dokumentu, więc po nieudanym ładowaniu czyta się jako False i nic więcej się nie dzieje. Cokolwiek zostało wyrzucone, przepadło — czy to EPdfError od parsera, błąd strumienia, czy EAccessViolation od półpodpiętego pdfium.dll. Szczegółowe komunikaty DLL opisane w diagnozowaniu awarii ładowania pdfium.dll w Delphi docierają do twojej obsługi tylko przez wywołanie, które ich nie połyka

Dwie ścieżki ładowania w PDFium Component: przypisanie Active true połyka każdy wyjątek w setterze i odsuwa porażkę do pierwszego strzeżonego wywołania, gdzie CheckActive wyrzuca EPdfError o nieaktywnym komponencie, podczas gdy LoadDocument z TPdfLoadOptions i TPdfLoadReport audytuje nagłówek, startxref, xref i znacznik końca pliku, po czym ponownie wyrzuca oryginalny wyjątek z prawdziwą przyczyną
Połknięcie jest celowe, bo projektant IDE dzieli setter; kod wsadowy potrzebuje przeciążenia, które wyrzuca, raportuje i opowiada prawdziwą historię pliku
Pdf.FileName := FileName;
try
  Pdf.Active := True;       // SetActive połyka każdy wyjątek ładowania
except
  on E: Exception do
    Log.Add(FileName + ': ' + E.Message);   // nigdy się nie wykonuje
end;
// Porażka wypływa zamiast tego tutaj, jako ogólny EPdfError:
// 'Cannot perform this operation on an inactive Pdf1 component'
Log.Add(Format('%s: %d pages', [FileName, Pdf.PageCount]));

// Minimalna poprawka istniejącego kodu: testuj Active zaraz po przypisaniu;
// od v3.122.1 LastLoadReport trzyma tekst połkniętego błędu
Pdf.Active := True;
if not Pdf.Active then
  Log.Add(FileName + ': load failed: ' + Pdf.LastLoadReport.ErrorMessage);

Porażka w końcu wychodzi na pierwszym strzeżonym wywołaniu. TPdf.PageCount, jak większość właściwości dokumentu, zaczyna od CheckActive, które wyrzuca EPdfError wymieniający komponent, ale nie plik i nie przyczynę. Testowanie Pdf.Active zaraz po przypisaniu zamienia źle przypisany crash w uczciwy wpis "padło". Przed PDFiumPas v3.122.1 przyczyna ginęła w tym punkcie; od v3.122.1 nieudane przypisanie zastępuje LastLoadReport raportem plsFailed niosącym tekst błędu, więc przyczyna przeżywa. Sam obiekt wyjątku i audyt na poziomie bajtów nadal wymagają innego wejścia

Dlaczego ponowne użycie jednego TPdf pada od drugiego pliku?

TPdf.FileName można przypisać tylko, gdy komponent jest nieaktywny, więc współdzielona instancja odrzuca drugi plik, zanim w ogóle spróbuje go załadować. TPdf.SetFileName zaczyna od CheckInactive, a ta sama straż chroni Password i FormFill. Po pierwszym udanym ładowaniu instancja zostaje aktywna, następne przypisanie wyrzuca, a jeśli pętla wsadowa złapie ten wyjątek i idzie dalej, błąd ląduje pod nową nazwą pliku, podczas gdy stary dokument wciąż jest otwarty. Zmieszane z połkniętymi porażkami ładowania, log przestaje pasować do rzeczywistości. W reprodukcji na 13 plikach współdzielona instancja zgłaszała 7 porażek, podczas gdy świeży TPdf.Create(nil) na dokument otwierał wszystkie 13. Ustawianie Active := False między plikami też działa, ale jedna instancja na dokument trzyma każdy plik odizolowany z samej konstrukcji

Oś czasu współdzielonego TPdf w PDFium padającego od drugiego pliku: po pierwszym ładowaniu instancja zostaje aktywna, następne przypisanie FileName wyrzuca w CheckInactive przed jakąkolwiek próbą ładowania, a pętla wsadowa loguje błąd pod nową nazwą pliku, podczas gdy stary dokument wciąż jest otwarty — pułapka za 7 fałszywych porażek w paczce 13 plików
SetFileName strzeże przez CheckInactive, więc współdzielona instancja odrzuca plik numer dwa, zanim go spróbuje; odizoluj każdy dokument własnym TPdf, a log znów zacznie pasować do rzeczywistości

Co daje TPdf.LoadDocument z TPdfLoadReport?

TPdf.LoadDocument(const Options: TPdfLoadOptions; out Report: TPdfLoadReport) wyrzuca prawdziwy wyjątek i mówi ci też, co się stało, w postaci ustrukturyzowanej. Przeciążenie plikowe ładuje FileName; przeciążenia siostrzane biorą TBytes albo wskaźnik i rozmiar, a LoadCustomDocument(AStream, AOwnsStream, Options, Report) pokrywa strumienie. Każde waliduje opcje, sprawdza, że instancja jest nieaktywna, biegnie audyt bajtowy nagłówka, startxref, sekcji xref i znacznika %%EOF, po czym wykonuje ładowanie natywne. Audyt jest ograniczony tym samym rodzajem limitów, o których mowa w budżetach zasobów parsera dla niezaufanych PDF-ów: TPdfLoadOptions.Default ustawia AuditByteLimit na 256 MiB, MaxIssues na 256, MaxXrefSections na 1024 i MaxXrefEntries na 4 000 000. Przy porażce metoda ustawia Report.Status := plsFailed i wyrzuca ponownie; ponieważ Report jest pisany w miejscu, jego treść przeżywa wyjątek, a kopia ląduje w TPdf.LastLoadReport

Pola raportu odpowiadają na pytania, których faktycznie potrzebuje log wsadowy. Status to jedno z plsNotAttempted, plsLoaded, plsLoadedWithRecovery, plsRejected albo plsFailed. NativeErrorCode trzyma FPDF_GetLastError, więc FPDF_ERR_PASSWORD (4) oddziela brakujące albo złe hasło od uszkodzonego pliku zgłaszanego jako FPDF_ERR_FORMAT (3). UsedRecovery, CrossReferenceTableValid i RecoveryRoute mówią, czy PDFium musiał przebudować tabelę xref, a Issues wypisuje każde ustalenie audytu z Code, Severity, Offset, ObjectNumber i MessageText, z ustawionym IssuesTruncated, gdy MaxIssues skróciło listę

Pipeline LoadDocument w PDFium Component i jego TPdfLoadReport: walidacja opcji i kontrola nieaktywności wyrzucają, zanim istnieje jakikolwiek raport, audyt bajtowy chodzi po nagłówku, startxref, sekcjach xref i znaczniku końca pliku, ładowanie natywne zapisuje FPDF_GetLastError, a wyniki rozdzielają się na załadowano, załadowano z odzyskiwaniem po przebudowie xref, odrzucenie trybem strict albo porażkę
Status, NativeErrorCode i lista ustaleń odpowiadają na to, czego potrzebuje log wsadowy; audyt bajtowy dodaje tylko przeciążenie z opcjami, a od v3.122.1 nieudane Active := True nadal zapisuje plsFailed w 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);          // jedna instancja na dokument
    try
      Pdf.FileName := Files[I];
      try
        Pdf.LoadDocument(Options, Report);
      except
        on E: Exception do
        begin
          // Report jest wypełniony, choć LoadDocument wyrzucił
          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;

Kiedy ładować z plmStrict?

Używaj plmStrict, kiedy po cichu naprawiony plik jest gorszy niż odrzucony — na przykład w przyjmowaniu archiwów, obrobie dowodów albo pipeline podpisującym. PDFium po cichu odtwarza zepsutą tabelę odwołań krzyżowych (ISO 32000-1 §7.5.4), skanując plik w poszukiwaniu obiektów, co jest świetne dla przeglądarki i problemem dla wszystkiego, co musi przetworzyć dokładnie te bajty, które dostało. Po ładowaniu natywnym komponent pyta FPDF_DocumentHasValidCrossReferenceTable. W trybie plmCompatible przebudowa daje plsLoadedWithRecovery plus ostrzeżenie plicNativeCrossReferenceRebuild. W trybie plmStrict komponent zrzuca dokument, ustawia plsRejected, dodaje plicStrictModeRejected i wyrzuca EPdfError z komunikatem "Strict PDF load rejected the document". Tryb strict odrzuca też każdy błąd audytu, a TPdfLoadOptions.Default(plmStrict) włącza RequireFinalEndOfFileMarker, które awansuje brakujący %%EOF albo dane po ostatnim (§7.5.5) z ostrzeżenia na błąd. Audyt xref uzupełnia kontrole na poziomie obiektów opisane w walidacji strumieni obiektów i xref z 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;               // poprawny xref, zero błędów audytu
    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;

Gdzie TPdf.LastLoadReport przestaje mówić prawdę?

TPdf.LastLoadReport jest pełny tylko po przeciążeniu LoadDocument przyjmującym opcje, bo tylko te przeciążenia biegną audyt bajtowy. Udane Active := True zapisuje raport w trybie zgodnym bez audytu bajtowego, więc AuditAttempted zostaje False. Przed PDFiumPas v3.122.1 nieudane nie zapisywało nic, co znaczyło, że na współdzielonej instancji LastLoadReport wciąż opisywał poprzedni plik, często z uspokajającym plsLoaded. Od v3.122.1 każde nieudane ładowanie zastępuje raport: nieudane Active := True, które nadal zostawia komponent nieaktywny bez wyrzucania, i nieudane gołe wołanie LoadDocument albo LoadCustomDocument zapisują plsFailed z tekstem błędu, znów bez audytu. Dwie kolejne luki mają znaczenie w praktyce. Walidacja opcji i CheckInactive biegną, zanim raport zostanie zainicjowany, więc ujemny AuditByteLimit albo już aktywna instancja wyrzucają bez produkowania raportu. A NativeErrorCode ma znaczenie tylko, gdy PDFium faktycznie spróbował parsowania; dla brakującego pliku wrapper wyrzuca, zanim PDFium ruszy, więc loguj zamiast tego ErrorMessage i tekst wyjątku

Praktyczna reguła jest krótka. Trzymaj Active := True dla przeglądarek związanych z projektantem, gdzie nieaktywny komponent to akceptowalny wynik. Wszędzie indziej, a przede wszystkim w kodzie wsadowym i serwerowym, stwórz jeden TPdf na dokument, wołaj LoadDocument(Options, Report), łap wyrzucany wyjątek i loguj Report.Status, NativeErrorCode i ustalenia Issues na poziomie błędu razem z nazwą pliku. Koszt to kilka linii na miejsce wywołania, a każda porażka zostaje przypisana do właściwego pliku z prawdziwą przyczyną

API raportu ładowania, tryb strict i audyt na poziomie bajtów płyną z PDFium Component dla Delphi, C++Buildera i Lazarusa, razem z renderowaniem, ekstrakcją tekstu, wypełnianiem formularzy i walidacją PDF/A