Artykuł techniczny

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

Piszesz mały walidator. Otwiera PDF, wyszukuje koniec, znajduje startxref, odczytuje offset i spodziewa się wylądować na słowie kluczowym xref ze stałą szerokością tabeli odnośników poniżej. Z tej tabeli zbiera offsety obiektów, po czym skanuje wstecz w poszukiwaniu słowa kluczowego trailer, aby poznać /Root i /Size. Działa to perfekcyjnie na każdym pliku, który wygenerowałeś, aby to przetestować. Potem dociera plik stworzony przez obecną wersję programu Word lub przez bibliotekę celującą w PDF 1.5, a walidator zgłasza go jako uszkodzony. Nie ma słowa kluczowego xref w miejscu offsetu, nie ma w ogóle słownika trailer, a zbudowana przez walidator tabela obiektów jest niemal pusta. Plik jest prawidłowy (valid). Walidator odczytuje go przez obiektyw sprzed piętnastu lat

To najczęstszy powód, dla którego sprawdzanie PDF na poziomie bajtów, napisane dla klasycznego układu, zawodzi w nowoczesnych dokumentach. Struktura, od której zależy, tabeli odnośników (cross-reference table) w czystym tekście (plaintext) i słowa kluczowego trailer, stała się opcjonalna w formacie PDF 1.5 i często jest nieobecna. Zastąpiły ją dwie funkcje: strumień odnośników (cross-reference stream) i skompresowany strumień obiektów (compressed object stream). Obie są opisane w normie ISO 32000-1, a walidator, który o nich nie wie, postrzega prawidłowy plik jako stertę brakujących obiektów

Co zmienił format PDF 1.5 w ogonie (tail) pliku

ISO 32000-1 §7.5.8 definiuje strumień odnośników, a §7.5.7 definiuje strumień obiektów typu /ObjStm. Razem pozwalają programowi zapisującemu pominąć dwie struktury, na których polega klasyczny parser. Plik PDF 1.5 może w ogóle nie kończyć się tabelą xref. Zamiast tego obiekt, na który wskazuje startxref, jest zwykłym obiektem strumienia, którego słownik niesie /Type /XRef, a strumień ten przechowuje dane odnośników w kompaktowej formie binarnej. Nie ma też słowa kluczowego trailer, ponieważ zwiastunem (trailer) jest teraz własny słownik strumienia. Klucze, na które polował klasyczny parser: /Root, /Size i /ID, żyją wewnątrz tego słownika

Druga zmiana przenosi same obiekty. Zamiast zapisywać każdy obiekt pośredni (indirect object) z jego własnym offsetem bajtowym, zapisujący może spakować wiele małych obiektów, słowników stron, słowników adnotacji, drzewa struktury, do jednego strumienia obiektów i skompresować cały kontener za pomocą Flate. Poszczególne obiekty nie mają już offsetu bajtowego w pliku. Mają pozycję wewnątrz skompresowanego bloba (blob). Walidator skanujący surowe bajty w poszukiwaniu 1 0 obj nigdy ich nie znajdzie, ponieważ ten tekst istnieje tylko po inflacji (rozpakowaniu). Dla klasycznego parsera połowa dokumentu po prostu zniknęła

Klucze zwiastuna są w czystym tekście, nawet w skompresowanym pliku

Uspokajające jest to, że odczytanie zwiastuna (trailer) strumienia odnośników nie wymaga nadmuchiwania (inflating - rozpakowywania) niczego. Obiekt strumienia jest zapisywany jako słownik, po którym następuje słowo kluczowe stream, a następnie skompresowane bajty. Słownik jest w czystym tekście. Więc kiedy startxref wskazuje na strumień odnośników, bajty bezpośrednio za numerem obiektu wyglądają jak zwykły słownik, a /Root, /Size i /ID siedzą tam jako czysty tekst (in the clear), przed słowem kluczowym stream i rozpoczęciem danych Flate

Oznacza to, że walidator może poznać trzy fakty, których najbardziej potrzebuje: gdzie jest katalog, ile obiektów posiada plik, i identyfikator pliku, analizując (parsing) tylko słownik strumienia. Nie musi dekompresować danych odnośników i nie musi interpretować wpisów binarnych w jego wnętrzu. Pracą, która pokonuje naiwny parser, nie jest odczytywanie zwiastuna; jest nią znajdowanie obiektów. To dwa możliwe do oddzielenia problemy, a rozwiązanie pierwszego jest tanie

Strumienie obiektów: nagłówek, potem blok (blob) Flate

Strumień obiektów to kontener. Jego słownik przenosi /Type /ObjStm, wpis /N podający liczbę obiektów spakowanych w środku oraz wpis /First podający offset w bajtach, w obrębie rozpakowanych (inflated) danych, gdzie zaczyna się ciało pierwszego obiektu. Skompresowany ładunek po rozpakowaniu zaczyna się małym nagłówkiem złożonym z /N par liczb całkowitych. Każda para to numer obiektu i przesunięcie (offset) ciała tego obiektu względem /First. Po nagłówku następują same ciała (bodies) obiektów, połączone szeregowo (concatenated)

Rozwijanie go (expanding) jest mechaniczne, gdy tylko bajty zostaną nadmuchane (inflated - rozpakowane). Odczytujesz słownik, aby uzyskać /N i /First, rozpakowujesz strumień dekoderem Flate, przechodzisz przez wiodące pary /N, aby dowiedzieć się, jaki numer obiektu znajduje się pod danym przesunięciem, a następnie podnosisz (lift) każde ciało na zewnątrz, tak jakby był to zwykły obiekt pośredni (indirect object). Jedyną prawdziwą zależnością jest dekoder Flate, a ten już masz: Delphi dostarcza System.ZLib, a Free Pascal dostarcza jednostkę zstream, z których obie otaczają (wrap) zlib i rozpakowują surowy strumień Flate bez żadnego kodu stron trzecich. Procedura dołączająca każdy wyodrębniony obiekt do tabeli obiektów walidatora sprawia, że reszta walidatora (część analizująca /Root i sprawdzająca drzewo stron) zachowuje się dokładnie tak samo, jak w klasycznym pliku

Czego nie musisz implementować

Łatwo jest przecenić rozmiar pracy. Odczytanie kluczy zwiastuna ze skompresowanego pliku nie wymaga dekodowania wpisów binarnych strumienia odnośników. Strumień odnośników z §7.5.8 używa trzech typów wpisów, a wpis typu 2 (ten, który mówi ten obiekt znajduje się wewnątrz strumienia obiektów N pod indeksem i), jest tym, co zdekodowałbyś w celu zbudowania pełnej mapy przesunięć. Potrzebujesz tej mapy, aby rozwiązać arbitralne obiekty po numerze. Nie potrzebujesz jej do odczytania /Root, /Size i /ID, które znajdują się w słowniku typu plaintext (czysty tekst), i nie musisz jej używać, by rozwinąć (expand) strumienie obiektów, ponieważ każdy /ObjStm sam anonsuje swoją zawartość przez /N i /First

Nie musisz także obsługiwać funkcji predyktorów PNG i TIFF, które strumień odnośników może zastosować przez swoje /DecodeParms tylko po to, aby uzyskać klucze zwiastuna. Predyktory filtrują binarne wiersze odnośników (cross-reference rows), aby lepiej się kompresowały; nie mają one nic wspólnego ze słownikiem, który poprzedza strumień. Minimalna aktualizacja, która sprawia, że klasyczny walidator rozpoznaje nowoczesne PDF-y, jest zatem niewielka: kiedy startxref trafia na strumień zamiast na słowo kluczowe xref, przeparsuj słownik strumienia w poszukiwaniu kluczy zwiastuna i rozwiń wszystkie obiekty /ObjStm, które napotkasz, aby ich zawartość weszła do tabeli obiektów. Dekodowanie wpisów typu 2 i predyktorów to oddzielne, większe zadanie, które możesz odłożyć, dopóki nie będziesz naprawdę potrzebował losowego (random) rozwiązywania obiektów

Dlaczego sprawdzenie zgodności musi najpierw rozwinąć strumienie

Przestaje to być teoretyczne w momencie, gdy uruchamiasz sprawdzanie profilu (profile check). Walidator PDF/A lub PDF/X inspektuje określone obiekty: katalog dokumentu pod kątem tablicy /OutputIntents, strumień /Metadata dla pakietu XMP z odpowiednim identyfikatorem, każdy deskryptor czcionki pod kątem osadzonego pliku czcionki, zwiastun (trailer) dla /ID. W skompresowanym pliku większość z tych obiektów znajduje się wewnątrz strumieni obiektów. Walidator, który nie rozpakował (expanded) strumieni obiektów, nie może zobaczyć kluczy katalogu, nie może znaleźć metadanych i nie może wyliczyć (enumerate) czcionek. Zgłosi on idealnie zgodny dokument jako pozbawiony intencji wyjściowej, brakującego XMP i brakującej połowy jego struktury, ponieważ dowody, których potrzebuje, wciąż siedzą w bloku (blob) Flate, którego nigdy nie rozpakował

Kolejność ma znaczenie. Rozpakowanie musi nastąpić przed uruchomieniem sprawdzeń, a nie równolegle z nimi, ponieważ każde sprawdzenie zakłada, że może dotrzeć do obiektu po jego numerze. Jeśli podłączysz (wire) sprawdzanie profilu bezpośrednio do surowego skanowania bajtów, dziedziczy ono ślepotę klasycznego parsera i generuje fałszywe naruszenia (false violations) dokładnie w przypadku tych nowoczesnych plików, które z największym prawdopodobieństwem są dobrze sformowane, ponieważ w pierwszej kolejności wyszły z łańcuchów narzędzi na tyle nowych, by zapisywać strumienie odnośników

Pozwolenie PDFium na analizowanie (parsing) za ciebie

Komponent PDFium analizuje strumienie odnośników i strumienie obiektów w ramach ładowania dokumentu, co jest praktycznym sposobem na uniknięcie ręcznego wdrażania (hand-rolling) kroku rozpakowywania (inflate) i rozwijania (expand). Kiedy ł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ęty dokument. ValidatePdfA zwraca rekord TPdfAValidationResult, którego pole Conformance ma wartość TPdfAConformance (np. pac1b lub pacNone), a pole Issues jest zbiorem zidentyfikowanych konkretnych problemów. Funkcja IsCompliant zwraca true tylko wtedy, gdy poziom zgodności (conformance level) został wykryty, a zbiór błędów jest pusty. Ponieważ obiekty zostały rozwinięte (expanded) podczas ładowania, tablica /OutputIntents lub wbudowana czcionka, która żyła wewnątrz strumienia obiektów, zostaje znaleziona, a nie zgłoszona jako brakująca

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 ValidatePdfX, która zwraca TPdfXValidationResult o tym samym kształcie. Sens przekierowywania (routing) tego przez PDFium polega na tym, że strukturalna dekompresja opisana wyżej zachodzi raz, poprawnie, wewnątrz programu ładującego (loader), więc twój kod walidacyjny nigdy nie widzi różnicy między klasycznym plikiem a plikiem całkowicie skompresowanym. Oba docierają do walidatora jako zbiór połączonych i rozwiązanych obiektów

function PdfXConformanceName(C: TPdfXConformance): string;
begin
  case C of
    pxc1a: Result := 'PDF/X-1a';
    pxc3 : Result := 'PDF/X-3';
    pxc4 : Result := 'PDF/X-4';
  else
    Result := 'none';
  end;
end;

var
  Pdf: TPdf;
  R  : TPdfXValidationResult;
  Issue: TPdfXValidationIssue;
  IssueCount: Integer;
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: ', PdfXConformanceName(R.Conformance))
    else
    begin
      IssueCount := 0;
      for Issue in R.Issues do   // Issues is a set: count its members
        Inc(IssueCount);
      Writeln('Not conformant; issue count = ', IssueCount);
    end;
  finally
    Pdf.Free;
  end;
end;

Jeśli bajty są już w pamięci, a nie na dysku, ta sama sekwencja ładuj-następnie-sprawdź (load-then-validate) działa przez przeciążenie LoadDocument(const Data: TBytes), które pobiera surową treść pliku i analizuje jego strumienie odnośników i strumienie obiektów w taki sam sposób, jak robi to ze ścieżką pliku. Wniosek dla ręcznie pisanych walidatorów to zasada strukturalna, a nie API: przeczytaj klucze zwiastuna ze słownika strumienia w czystym tekście, rozwiń każde /ObjStm dekoderem Flate zanim przejdziesz przez (walk) dokument, i traktuj dekodowanie binarnych wpisów odnośników jako większe, opcjonalne zadanie, jakim w rzeczywistości jest

Gdy struktura zostanie rozwinięta, walidator może poprowadzić po niej resztę przepływu pracy. Aby zapoznać się z uprzężą (harness) wiersza poleceń preflight, która raportuje zgodność w całym folderze danych wejściowych, zobacz nasz przewodnik tworzenia wsadowego narzędzia CLI raportów preflight. Kiedy walidacja jest bramą poprzedzającą podział dużego dokumentu, techniki opisane w naszym przewodniku na temat dzielenia dokumentów PDF na wiele plików łączą się naturalnie z pokazanym tutaj wzorcem ładowania i sprawdzania (load-and-check). Oba te elementy bazują na obszarze (surface) ładowania i walidacji PDFium Component dla Delphi i C++Buildera