Technical Article

Walidacja skompresowanych plików PDF: Strumienie obiektów i XRef

Piszesz prosty walidator. Otwiera on plik PDF, przechodzi na koniec, znajduje wpis startxref, odczytuje przesunięcie i oczekuje, że trafi na słowo kluczowe xref ze znajdującą się pod nim tabelą powiązań o stałej szerokości kolumn. Z tej tabeli zbiera przesunięcia obiektów, a następnie skanuje wstecz w poszukiwaniu słowa kluczowego trailer, aby poznać parametry /Root i /Size. Działa to idealnie dla każdego wygenerowanego testowo pliku. Nagle pojawia się plik utworzony przez nową wersję programu Word lub bibliotekę przeznaczoną dla PDF 1.5 i walidator uznaje go za uszkodzony. Nie ma słowa kluczowego xref w miejscu wskazanym przez przesunięcie, brak słownika trailer w jakimkolwiek miejscu, a zbudowana przez walidator tabela obiektów jest prawie pusta. Plik jest jednak poprawny. To walidator analizuje go przez pryzmat piętnastoletnich założeń.

To najczęstsza przyczyna, dla której niskopoziomowe weryfikatory PDF napisane z myślą o klasycznym układzie zawodzą na współczesnych dokumentach. Struktura, od której zależą — tekstowa tabela powiązań (cross-reference) oraz słowo kluczowe trailer — stała się opcjonalna w wersji PDF 1.5 i często jej nie ma. Została zastąpiona przez dwie funkcje: strumień powiązań (cross-reference stream) oraz skompresowany strumień obiektów (compressed object stream). Obie są zdefiniowane w ISO 32000-1, a walidator, który ich nie obsługuje, widzi poprawny plik jako zbiór brakujących obiektów.

Co wersja PDF 1.5 zmieniła na końcu pliku

Norma ISO 32000-1 §7.5.8 definiuje strumień powiązań, a §7.5.7 definiuje strumień obiektów typu /ObjStm. Wspólnie pozwalają one modułowi zapisu na pominięcie obu struktur, na których opiera się klasyczny parser. Plik zgodny z PDF 1.5 może w ogóle nie zawierać tabeli xref. Zamiast tego obiekt, na który wskazuje startxref, to zwykły strumień, którego słownik niesie wpis /Type /XRef, a sam strumień przechowuje dane powiązań w zwartej postaci binarnej. Nie ma tu również słowa kluczowego trailer, ponieważ zwiastun (trailer) jest teraz po prostu słownikiem tego strumienia. Klucze, których szukał klasyczny parser — /Root, /Size i /ID — znajdują się w tym słowniku.

Druga zmiana dotyczy samych obiektów. Zamiast zapisywać każdy obiekt pośredni pod jego własnym przesunięciem bajtowym, moduł zapisu może spakować wiele małych obiektów — słowniki stron, słowniki adnotacji, drzewo struktury — w pojedynczy strumień obiektów i skompresować cały kontener algorytmem Flate. Poszczególne obiekty nie mają już przesunięcia bajtowego w pliku. Mają pozycję wewnątrz skompresowanego bloku. Walidator skanujący surowe bajty w poszukiwaniu 1 0 obj nigdy ich nie odnajdzie, ponieważ ten tekst powstaje dopiero po dekompresji. Dla klasycznego parsera połowa dokumentu po prostu zniknęła.

Klucze zwiastuna są tekstem jawnym nawet w skompresowanym pliku

Pocieszające jest to, że odczytanie zwiastuna (trailer) strumienia powiązań nie wymaga dekompresji czegokolwiek. Obiekt strumienia jest zapisywany jako słownik, po którym następuje słowo kluczowe stream, a następnie skompresowane bajty. Słownik jest zapisany tekstem jawnym. Gdy więc wskaźnik startxref prowadzi do strumienia powiązań, bajty bezpośrednio za numerem obiektu wyglądają jak zwykły słownik, a klucze /Root, /Size i /ID znajdują się tam w czytelnej postaci — przed słowem kluczowym stream i danymi skompresowanymi Flate.

Oznacza to, że walidator może poznać trzy najważniejsze fakty — lokalizację katalogu głównego, deklarowaną liczbę obiektów oraz identyfikator pliku — analizując wyłącznie słownik strumienia. Nie musi dekompresować danych powiązań ani interpretować znajdujących się tam wpisów binarnych. Zadaniem, które przerasta prosty parser, nie jest odczytanie zwiastuna; jest nim odnalezienie obiektów. To dwa odrębne problemy i rozwiązanie pierwszego z nich jest mało kosztowne.

Strumienie obiektów: nagłówek, a po nim blok Flate

Strumień obiektów to kontener. Jego słownik zawiera wpisy /Type /ObjStm, /N podający liczbę spakowanych w nim obiektów oraz /First określający przesunięcie bajtowe wewnątrz rozpakowanych danych, w którym zaczyna się treść pierwszego obiektu. Skompresowany ładunek po rozpakowaniu rozpoczyna się od małego nagłówka złożonego z /N par liczb całkowitych. Każda para to numer obiektu oraz przesunięcie treści tego obiektu względem /First. Za nagłówkiem znajdują się połączone treści samych obiektów.

Rozpakowanie takiego strumienia jest powtarzalne, gdy bajty zostaną zdekompresowane. Odczytujesz słownik, by pobrać /N i /First, dekompresujesz strumień dekoderem Flate, przechodzisz przez wiodące pary /N, aby dowiedzieć się, który numer obiektu znajduje się pod jakim przesunięciem, a następnie wyciągasz każdą treść, jakby była zwykłym obiektem pośrednim. Jedyną rzeczywistą zależnością jest dekoder Flate, a ten masz już do dyspozycji: Delphi dostarcza moduł System.ZLib, a Free Pascal jednostkę zstream — oba owijają bibliotekę zlib i dekompresują surowy strumień Flate bez zewnętrznego kodu. Procedura dopisująca każdy wyodrębniony obiekt do tabeli obiektów walidatora sprawia, że reszta kodu przechodząca przez /Root i sprawdzająca drzewo stron zachowuje się dokładnie tak samo jak dla klasycznego pliku.

Czego nie musisz implementować

Łatwo jest przecenić nakład pracy. Odczytanie kluczy zwiastuna ze skompresowanego pliku nie wymaga dekodowania binarnych wpisów strumienia powiązań. Strumień powiązań opisany w §7.5.8 używa trzech typów wpisów, a wpis typu 2 — mówiący, że dany obiekt znajduje się w strumieniu obiektów N pod indeksem i — to ten, który należałoby zdekodować w celu zbudowania pełnej mapy przesunięć. Taka mapa jest potrzebna do znajdowania dowolnych obiektów po ich numerach. Nie jest jednak wymagana do odczytu kluczy /Root, /Size i /ID, które znajdują się w tekstowym słowniku, ani do rozwijania strumieni obiektów, ponieważ każdy /ObjStm informuje o swojej zawartości poprzez /N i /First.

Nie musisz również obsługiwać funkcji predyktorów PNG i TIFF, które strumień powiązań może stosować za pośrednictwem wpisu /DecodeParms, tylko po to, by odczytać klucze zwiastuna. Predyktory filtrują binarne wiersze tabeli powiązań w celu uzyskania lepszej kompresji; nie mają nic wspólnego ze słownikiem poprzedzającym strumień. Minimalna aktualizacja dostosowująca klasyczny walidator do współczesnego formatu PDF jest więc niewielka: gdy wskaźnik startxref prowadzi do strumienia zamiast słowa kluczowego xref, parsujesz słownik strumienia w poszukiwaniu kluczy zwiastuna i rozpakowujesz napotkane obiekty /ObjStm, aby ich zawartość trafiła do tabeli obiektów. Dekodowanie wpisów typu 2 i predyktorów to osobne, większe zadanie, które można odłożyć do momentu, gdy rzeczywiście będziesz potrzebować losowego dostępu do obiektów.

Dlaczego weryfikacja zgodności musi najpierw rozwinąć strumienie

Kwestia ta przestaje być czystą teorią w momencie uruchomienia weryfikacji zgodności profilu. Walidator PDF/A lub PDF/X bada konkretne obiekty: katalog dokumentu pod kątem tablicy /OutputIntents, strumień /Metadata w poszukiwaniu pakietu XMP z odpowiednim identyfikatorem, każdy deskryptor czcionki pod kątem osadzonego pliku czcionki oraz zwiastun dla klucza /ID. W skompresowanym pliku większość tych obiektów znajduje się wewnątrz strumieni obiektów. Walidator, który nie rozwinął strumieni obiektów, nie widzi kluczy katalogu, nie odnajdzie metadanych ani nie wylistuje czcionek. Zgłosi całkowicie poprawny dokument jako pozbawiony intencji wyjściowych (output intent), pozbawiony XMP i połowy swojej struktury, ponieważ dowody, których szuka, nadal tkwią w bloku Flate, którego nigdy nie rozpakował.

Kolejność ma znaczenie. Rozpakowanie musi nastąpić przed przeprowadzeniem testów, a nie w ich trakcie, ponieważ każdy test zakłada, że może sięgnąć do obiektu po jego numeru. Jeśli podłączysz weryfikację profilu bezpośrednio do skanowania surowych bajtów, odziedziczy ona ślepotę klasycznego parsera i wykaże fałszywe błędy w dokładnie tych nowych plikach, które najprawdopodobniej są poprawne, ponieważ powstały w narzędziach na tyle świeżych, by w ogóle zapisywać strumienie powiązań.

Pozwól PDFium na wykonanie parsowania za Ciebie

Komponent PDFium parsuje strumienie powiązań i strumienie obiektów podczas ładowania dokumentu, co stanowi praktyczny sposób na uniknięcie ręcznego pisania procedur dekompresji i rozwijania. Gdy ładujesz plik za pomocą komponentu TPdf, obiekty spakowane w kontenerach /ObjStm są już rozwiązane, a punkty wejścia walidacji widzą w pełni rozwiniętą strukturę dokumentu. Funkcja ValidatePdfA zwraca rekord TPdfAValidationResult, którego pole Conformance to wartość TPdfAConformance (np. pac1b lub pacNone), pole Issues to zbiór konkretnych wykrytych problemów, a metoda IsCompliant zwraca wartość true tylko wtedy, gdy wykryto poziom zgodności, a zbiór problemów jest pusty. Ponieważ obiekty zostały rozwinięte podczas ładowania, tablica /OutputIntents czy osadzona czcionka leżące wewnątrz strumienia obiektów zostaną odnalezione, a nie zgłoszone jako brakujące.

uses
  PDFium, FPdfPdfa;

function CheckPdfA(const FileName: string): TPdfAValidationResult;
var
  Pdf: TPdf;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := FileName;
    Pdf.Active := True;            // parses xref/object streams on load
    Result := Pdf.ValidatePdfA;    // sees the expanded object table
  finally
    Pdf.Free;
  end;
end;

To samo dotyczy funkcji ValidatePdfX, która zwraca rekord TPdfXValidationResult o tej samej strukturze. Korzyść z kierowania zadań przez PDFium polega na tym, że opisywana wyżej dekompresja strukturalna odbywa się raz, poprawnie, wewnątrz modułu ładującego, więc Twój kod weryfikacyjny nigdy nie widzi różnicy między klasycznym plikiem a plikiem skompresowanym. Oba trafiają do walidatora jako gotowy zestaw obiektów.

var
  Pdf: TPdf;
  R  : TPdfXValidationResult;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := 'Press_Ready.pdf';
    Pdf.Active := True;
    R := Pdf.ValidatePdfX;
    if R.IsCompliant then
      Writeln('PDF/X conformance: ', Ord(R.Conformance))
    else
      Writeln('Not conformant; issue count = ', SizeOf(R.Issues));
  finally
    Pdf.Free;
  end;
end;

Jeśli bajty znajdują się już w pamięci, a nie na dysku, ta sama sekwencja załaduj-i-zweryfikuj działa poprzez przeciążenie LoadDocument(const Data: TBytes), które przyjmuje surową zawartość pliku i parsuje jego strumienie powiązań i obiektów w ten sam sposób co ścieżka pliku. Wnioskiem dla ręcznie pisanego walidatora jest reguła strukturalna, a nie samo API: odczytuj klucze zwiastuna ze słownika strumienia otwartym tekstem, rozwiń każdy /ObjStm dekoderem Flate przed przejściem dokumentu i potraktuj dekodowanie binarnych wpisów powiązań jako opcjonalne, większe zadanie.

Po rozwinięciu struktury walidator może przeprowadzić resztę procesu na dokumencie. Dla konsolowych narzędzi raportowania zgodności w całym folderze wejściowym, zobacz nasz przewodnik po budowie konsoli raportowania preflight. Gdy weryfikacja stanowi bramkę przed podziałem dużego dokumentu, techniki opisane w naszym przewodniku po dzieleniu dokumentów PDF na wiele plików naturalnie łączą się z zaprezentowanym tutaj wzorcem załaduj-i-sprawdź. Obie te metody bazują na mechanizmach ładowania i walidacji pakietu PDFium Component dla Delphi i C++Builder.