Artykuł techniczny

Pułapki kolejności stron PDF: struktura fizyczna a logiczna

Klasyczny objaw wygląda jak błąd o jedną pozycję: procedura kopiująca strony prosi o stronę 1 trzystronicowego dokumentu i za każdym razem dostaje stronę 2. Logika indeksowania wygląda dobrze. Wywołanie używa logicznego indeksu liczonego od zera, arytmetyka jest poprawna, warunki brzegowe w porządku. A mimo to za każdym razem wychodzi zła strona

Przyczyna wcale nie leży w kodzie kopiującym. Leży w sposobie, w jaki loader buduje swoją tablicę stron, i to jedna z najłatwiejszych pułapek parsowania PDF, w które można wpaść

Koncepcja kolejności stron w pliku PDF: różnica między kolejnością fizyczną a logiczną
Kolejność stron PDF: tablica /Kids w drzewie Pages definiuje sekwencję logiczną, niezależnie od tego, jak obiekty są numerowane lub przechowywane w pliku

Dwa porządki, jedno źródło nieporozumień

Plik PDF to zbiór obiektów pośrednich, z których każdy jest identyfikowany numerem obiektu. Struktura pliku nie nakłada na te numery żadnego obowiązku odzwierciedlania kolejności czytania. Obiekt 1 może zawierać stronę 2; obiekt 20 może zawierać stronę 1. Tym, co faktycznie definiuje kolejność czytania, jest drzewo stron: hierarchia słowników /Pages, których tablice /Kids wymieniają odwołania do stron w kolejności, w jakiej przeglądarka powinna je wyświetlać (ISO 32000-1 §7.7.3)

Oto drzewo stron, które odsłania tę pułapkę:

{ Korzeń drzewa Pages, obiekt 16 }
16 0 obj
<<
  /Type /Pages
  /Count 3
  /Kids [20 0 R   { strona logiczna 1 }
         1 0 R    { strona logiczna 2 }
         4 0 R]   { strona logiczna 3 }
>>
endobj

W tym pliku obiekt 1 i obiekt 4 znalazły się w strumieniu bajtów przed obiektem 20. Każdy parser, który przechodzi przez obiekty pośrednie w kolejności pliku i wpisuje je do PageArr w miarę napotykania słowników typu strona, skończy z obiektem 1 na indeksie 0, obiektem 4 na indeksie 1 i obiektem 20 na indeksie 2. Strona logiczna 1 siedzi wtedy pod PageArr[2]. Żądanie indeksu strony 0 pobiera zamiast niej stronę logiczną 2

Diagram kontrastujący porządek skanu obiektów z logiką tablicy Kids, gdy biblioteka PDF Delphi buduje swoją tablicę stron
Ładowanie skanem obiektów stempluje PageArr pozycją w pliku, podczas gdy sam /Kids definiuje kolejność stron PDF, przesuwając każde żądanie indeksowane o jeden slot

Potwierdzenie hipotezy

Zanim dotkniesz jakiegokolwiek kodu, udowodnij rozbieżność, zamiast ją zakładać. Narzędzie wiersza poleceń qpdf robi to w prosty sposób:

{ powłoka }
qpdf --show-pages input.pdf
{ Wynik ujawnia kolejność Kids: 20 0 R, potem 1 0 R, potem 4 0 R }

qpdf --show-object="16 0 R" input.pdf
{ Pokazuje słownik Pages z /Kids w kolejności czytania }

Wyodrębnienie każdej strony osobno i porównanie rozmiarów plików potwierdza to mapowanie: to, co produkuje skanujący obiekty PageArr[0], jest treścią należącą do logicznej strony 2, a PageArr[2] trzyma logiczną stronę 1. To cykliczne przesunięcie jest dymiącym pistoletem. Wyjaśnia też, dlaczego objaw pojawia się w wielu różnych dokumentach źródłowych: wywołuje go każdy plik PDF, w którym obiekty strony mają akurat niższe numery obiektów niż wcześniejsza strona logiczna

Oś czasu diagnozy pokazująca, jak qpdf ujawnił złą kolejność stron PDF za procedurą kopiowania HotPDF
Weryfikacja zamienia podejrzenie w dowód: sekwencja drzewa stron rozchodzi się z fizyczną kolejnością obiektów w tym dokumencie

Jest prosty powód, dla którego pliki PDF trafiają w taki stan. Zapis przyrostowy zachowuje numery obiektów, które zmienia, ale wszystko, co dodaje — na przykład stronę tytułową wstawioną na przód — dostaje nowy numer powyżej wszystkiego, co już jest w pliku, a jego odwołanie trafia na początek tablicy Kids. Narzędzia scalające lub reorganizujące dokumenty renumerują obiekty w dowolnej dla siebie wygodnej kolejności. Niektóre generatory po prostu zapisują strony w kolejności wygodnej dla strumieniowania treści, a nie w logicznej sekwencji stron. Format PDF nie wymaga od żadnego z nich niczego innego

Poprawne podejście: podążanie za tablicą Kids

Listę stron buduj przez przejście łańcucha /Kids od korzenia katalogu, a nie przez skanowanie obiektów pośrednich. Przejście musi być rekurencyjne, bo duży dokument zwykle rozdziela strony pośrednimi węzłami /Pages, i powinno zabezpieczać się przed zniekształconym drzewem, które wskazuje z powrotem na własnego przodka. W szkicu poniżej TPdfDict i TPdfArray oznaczają dowolne typy słownika i tablicy, jakich używa twój parser:

type
  TPageList = array of TPdfDict;

procedure CollectPages(Node: TPdfDict; var Pages: TPageList;
  Depth: Integer = 0);
var
  Kids: TPdfArray;
  Child: TPdfDict;
  I: Integer;
begin
  if Depth > 64 then  // cykl albo patologicznie głębokie drzewo
    raise EPdfError.Create('Page tree too deep or cyclic');
  Kids := Node.GetArray('Kids');
  if Kids = nil then
    Exit;
  for I := 0 to Kids.Count - 1 do
  begin
    Child := Kids.ResolveDict(I);  // podążaj za odwołaniem pośrednim
    if Child = nil then
      Continue;
    if Child.GetName('Type') = 'Pages' then
      CollectPages(Child, Pages, Depth + 1)  // węzeł pośredni
    else
    begin
      SetLength(Pages, Length(Pages) + 1);   // liść: następna strona w kolejności wyświetlania
      Pages[High(Pages)] := Child;
    end;
  end;
end;

// Użycie: start od wpisu /Pages katalogu
SetLength(Pages, 0);
CollectPages(Catalog.ResolveDict('Pages'), Pages);
if Length(Pages) <> Catalog.ResolveDict('Pages').GetInteger('Count') then
  LogWarning('Page tree /Count disagrees with the pages actually found');

Dwa szczegóły są celowe. Liść bez wpisu /Type nadal jest traktowany jako strona, bo niektóre pisarki go pomijają, a przeglądarki i tak otwierają takie pliki. A końcowe porównanie /Count nic nie kosztuje, a łapie obcięte lub ręcznie edytowane drzewa, zanim wołający zaindeksuje w nie

Jeśli wczytujesz dokumenty przez HotPDF, ta kolejność jest już rozstrzygnięta w momencie wczytywania dokumentu: indeksy stron podążają za kolejnością wyświetlania drzewa stron, włącznie z zagnieżdżonymi węzłami /Pages, więc indeks 0 to zawsze pierwsza strona, którą pokazuje przeglądarka. Przejście z wcześniejszego szkicu ma znaczenie, gdy własny kod parsuje strukturę PDF bezpośrednio

Kopiowanie po indeksie logicznym z CopyPageFromDocument

Ponieważ HotPDF rozstrzyga kolejność stron w czasie wczytywania, logiczne indeksy stron znaczą to, czego oczekują wołający. Wyższego poziomu CopyPageFromDocument przyjmuje logiczny indeks liczony od zera i kopiuje właściwą stronę do dokumentu docelowego:

var
  Source, Dest: THotPDF;
begin
  Source := THotPDF.Create(nil);
  Dest   := THotPDF.Create(nil);
  try
    Source.LoadFromFile('source.pdf');

    Dest.FileName := 'extracted.pdf';
    Dest.BeginDoc;

    { Kopiuje logiczną stronę 0 (pierwsza strona widziana przez użytkownika) }
    Dest.CopyPageFromDocument(Source, 0, 0);

    Dest.EndDoc;
  finally
    Source.Free;
    Dest.Free;
  end;
end;

Ponieważ indeks jest indeksem kolejności wyświetlania, CopyPageFromDocument zachowuje się poprawnie nawet wobec dokumentów, w których kolejność fizyczna i logiczna się rozjeżdżają. Do operacji wsadowych InsertPagesFromDocument przyjmuje tablicę indeksów logicznych i kopiuje je w jednym przebiegu

Co to mówi o parsowaniu PDF

Specyfikacja PDF jest jednoznaczna: logiczna kolejność stron jest definiowana przez tablicę /Kids drzewa stron, a nie przez numery obiektów czy przesunięcia bajtowe (ISO 32000-1 §7.7.3.2). Każdy parser, który jako skrót używa innego porządkowania, da poprawne wyniki dla większości napotkanych dokumentów, ponieważ większość generatorów zapisuje strony w naturalnej kolejności i przypisuje sekwencyjne numery obiektów. Pomyłka ukrywa się, dopóki ktoś nie wczyta pliku PDF, który był edytowany przyrostowo, przeorganizowany przez inne narzędzie, albo wygenerowany przez oprogramowanie, które wybrało inny układ

Testowanie wyłącznie na plikach PDF wygenerowanych we własnym zakresie całkowicie pomija tę klasę problemów. Obsługa kolejności stron potrzebuje korpusu testowych dokumentów z różnorodnych źródeł: zapisów przyrostowych, zeskanowanych dokumentów ze wstawionymi stronami tytułowymi, plików PDF wyprodukowanych przez narzędzia, które linearyzują lub optymalizują graf obiektów inaczej. Każdy dokument, którego kolejność fizyczna i logiczna się rozjeżdżają, zasługuje na stałe miejsce w tym korpusie

Strona HotPDF Delphi Component obejmuje pełne API dla operacji na stronach, w tym CopyPageFromDocument, InsertPagesFromDocument i MovePage