Artykuł techniczny

Plik PDF bez słownika Pages: Implikacje dla parsowania

Słownik dokumentu (PDF Catalog dictionary) posiada dokładnie jeden wymagany klucz nawigacyjny: /Pages. Ten klucz musi wskazywać na obiekt pośredni (indirect object) typu /Pages, który z kolei przechowuje tablicę /Kids oraz całkowitą liczbę stron w /Count. Usuń ten wskaźnik, a żaden czytnik zgodny ze standardem nie zdoła zlokalizować ani jednej strony w pliku. Standard ISO 32000-1 §7.7.2 jest w tym względzie jednoznaczny: Katalog musi posiadać wpis /Pages, a wskazywany obiekt musi być typu /Pages. Pliki, które naruszają ten wymóg, nie są po prostu niezgodne ze standardem; są uszkodzone strukturalnie w sposób, z którym większość parserów radzi sobie słabo

Co właściwie mówi specyfikacja

Minimalny, zgodny ze standardem plik PDF składa się z co najmniej trzech obiektów. Obiekt 1 to Katalog (Catalog), obiekt 2 to korzeń stron (Pages root), a obiekty od 3 wzwyż to poszczególne słowniki Stron (Page dictionaries). Katalog wskazuje na korzeń Pages; korzeń Pages wymienia swoich potomków w /Kids; każda Strona zawiera referencję zwrotną w /Parent. Cały ten łańcuch jest z założenia dwukierunkowy, więc parser może zacząć od dowolnego końca i przejść do dowolnej strony w czasie O(log n) dla wyważonych drzew

% Minimalna zgodna struktura (ISO 32000-1 §7.7.2)
1 0 obj
<< /Type /Catalog /Pages 2 0 R >>
endobj

2 0 obj
<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 >>
endobj

3 0 obj
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 5 0 R /Resources << >> >>
endobj

4 0 obj
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 6 0 R /Resources << >> >>
endobj

Drzewo stron (Pages tree) może być zagnieżdżone. Dokument liczący tysiące stron zazwyczaj grupuje je w węzły pośrednie (intermediate node objects), które również posiadają typ /Pages, każdy z własną tablicą /Kids oraz wartością /Count odzwierciedlającą przypisane do niego poddrzewo. Wartość /Count węzła głównego jest zawsze równa całkowitej liczbie stron. To właśnie tę liczbę czytniki wyświetlają w polu numeru strony, zanim jeszcze przetworzą chociażby jedną stronę, ponieważ odczytanie jednej liczby całkowitej z obiektu numer 2 jest znacznie tańsze niż przejście przez całe drzewo

Jak wygląda plik bez drzewa Pages

Pliki pozbawione słownika Pages zazwyczaj pochodzą z generatorów PDF, które zapisują obiekty stron bezpośrednio, bez łączenia ich w drzewo, albo powstają na skutek uszkodzenia usuwającego węzeł główny i pozostawiającego nietknięte obiekty typu Page (jako liście). Katalog w takim pliku albo w ogóle nie posiada klucza /Pages, albo zawiera referencję do obiektu, który już nie istnieje w tabeli odsyłaczy (cross-reference table)

% Niezgodny plik: Katalog bez referencji /Pages
1 0 obj
<< /Type /Catalog >>
endobj

% Obiekty stron istnieją, ale są nieosiągalne z Katalogu
5 0 obj
<< /Type /Page /MediaBox [0 0 612 792] /Contents 6 0 R /Resources << >> >>
endobj

15 0 obj
<< /Type /Page /MediaBox [0 0 612 792] /Contents 16 0 R /Resources << >> >>
endobj

25 0 obj
<< /Type /Page /MediaBox [0 0 612 792] /Contents 26 0 R /Resources << >> >>
endobj

Parser zgodny ze standardem odczyta Katalog, spróbuje rozwiązać referencję /Pages, niczego nie znajdzie (lub natrafi na martwy odnośnik) i albo zgłosi błąd, albo zaraportuje zero stron. Czego bezwzględnie nie wolno mu zrobić, to kontynuować tak, jakby plik miał zero stron, i po cichu odnieść sukces; prowadzi to do wygenerowania pustego wyjścia (blank output), które wygląda poprawnie dla zautomatyzowanych narzędzi, ale jest oczywiście błędne dla każdego człowieka, który go otworzy

Dlaczego parsery ulegają awarii

Większość parserów PDF alokuje swoją wewnętrzną tablicę stron w czasie ładowania na podstawie wartości /Count z korzenia Pages. Gdy tego korzenia brakuje, parser albo odczytuje zero, niczego nie alokuje, a następnie korzysta ze wskaźnika null przy pierwszej próbie odpytania przez jakikolwiek kod o stronę 1, albo odczytuje śmieci i alokuje skrajnie błędny bufor. Żaden z tych wyników nie jest elegancki. Naruszenie ochrony pamięci (access violation) pod adresem 0x008E5D78, które pojawia się w logach awarii (crash logs) z przetwarzania takiego pliku, to dokładnie to: dereferencja wskaźnika null wewnątrz ścieżki dostępu do stron, wywołana brakiem struktury, co do której parser założył, że zawsze tam będzie

Założenie projektowe leżące u podstaw jest sensowne. Zdecydowana większość istniejących plików PDF posiada słownik Pages. Parsery, które pomijają sprawdzanie jego istnienia, by zaoszczędzić kilka instrukcji, nie są lekkomyślne; optymalizują pod kątem najbardziej powszechnego przypadku. Pliki, które obnażają słabość tej optymalizacji, są na tyle rzadkie, że kod produkcyjny może nigdy nie trafić na żaden z nich, aż do momentu gdy to nastąpi. Wtedy awaria jest zarazem łatwa do powtórzenia i całkowicie niezrozumiała dla inżyniera, jeśli nie zapoznał się z sekcją §7.7.2 specyfikacji

Naprawa bez drzewa Pages

Jeśli parser musi obsłużyć tego typu pliki (zamiast je odrzucać), procedura naprawcza podąża sprawdzoną ścieżką: należy przeskanować każdy obiekt pośredni w tabeli odsyłaczy, zebrać te z /Type /Page, a następnie posortować je według numeru obiektu. Kolejność numerów obiektów nie daje gwarancji pełnej zgodności z kolejnością czytania określoną w specyfikacji, ale w praktyce generatory pomijające drzewo Pages na ogół emitują strony sekwencyjnie, więc bazowanie na ich identyfikatorach (ID obiektu) najczęściej bywa poprawne

Samo sprawdzenie istnienia struktury stron nie kosztuje wiele. Zanim mechanizm podąży za wskaźnikiem /Pages w Katalogu, powinien potwierdzić, że wskaźnik istnieje, że rozwiązuje się do prawdziwego obiektu oraz że klucz /Type rozwiązanego obiektu ma wartość /Pages. Jeśli którykolwiek z tych trzech warunków nie zostanie spełniony, następuje powrót na ścieżkę skanowania liniowego (linear scan). Skanowanie liniowe jest wolniejsze niż przechodzenie po wyważonym drzewie przy dużych dokumentach, ponieważ wymaga odczytywania nagłówków wszystkich obiektów zamiast podążania ścieżką dziedziczenia, ale działa. Gdy plik i tak jest uszkodzony, poprawność merytoryczna staje się ważniejsza niż maksymalna szybkość

Jednym ze skrajnych przypadków przyporządkowania, którego skanowanie liniowe nie rozwiązuje automatycznie, jest sekwencja (kolejność) samych stron. Przy braku tablicy /Kids określającej ich obiektywny naturalny ciąg leksykalny, "poprawna" kolejność stron pozostaje z punktu widzenia specyfikacji niedookreślona. Ustawienie ich w rzędzie według identyfikatorów z numerów to pragmatyczna postawa domyślna; jeżeli plik jest na tyle kluczowy do zaawansowanego procesowania, warto wdrożyć analizę dodatkową – chociażby zbadanie, czy obiekty Stron posiadają klucz /StructParents, czy może wewnątrz rzutują referencje w adnotacjach (annotations), tak aby ostatecznie uzyskać spójną z ułożenia sekwencję, co jest zdecydowanie warte tego nadprogramowego wysiłku technicznego

Implikacje dla generatorów PDF

Dla każdego, kto pisze swój własny generator PDF, a nie silnik parsera (parser), wniosek pozostaje wyjątkowo zwięzły: zawsze emituj katalog z głównym drzewem Pages przed zamknięciem strumienia do pliku. Katalog (Catalog) bez wpisu /Pages pod żadnym pozorem nie stanowi ważnego poprawnego dokumentu PDF (w ramach żadnej ze znanych nam specyfikacji formatu). Generatory piszące pliki, które budują strony na gorąco ("w locie") a samo drzewo spinają u schyłku przy akcji finalizowania, robią to znakomicie – pod warunkiem jednak, że sama finalizacja ostatecznie dojdzie do skutku. Klasycznym błędem podczas zapisu strumieniowego (streaming) jest niespodziewany błąd lub wcześniejsze zakończenie bloku wykonywania kodu bez zapisu trailera. Otrzymujemy wtedy potworka: plik otwierający się w czytnikach posiadających tolerancję, ale definitywnie crashujący proces w pozostałych programach odmawiających analizy ze względu na niekompletny rygor PDF

Standardy takie jak PDF/A i PDF/UA narzucają zdecydowanie obszerniejsze obostrzenia na drzewo stron (pages tree) względem samej bazy podstawowej formatu, a mimo to absolutnie w żadnym przypadku nie luzują twardego rygoru odnośnie wymaganego parametru /Pages. Zautomatyzowany skaner analizujący standard ISO 19005 lub ISO 14289 natychmiastowo zarejestruje błąd specyfikacji nadrzędnej (brak klucza), na długo przed tym, zanim proces uruchomi obwarowane testy dedykowane merytorycznym zapisom profilu wdrożeniowego