Artykuł techniczny

Wczytywanie hybrydowych PDF z Worda i Excela w Delphi

Otwórz plik PDF wygenerowany przez Microsoft Word lub Excel, przewiń go strona po stronie i nic nie wygląda nietypowo. Wczytaj go do programu Delphi, odczytaj liczbę stron i liczba jest prawidłowa. Następnie zapisz go ponownie z włączonym szyfrowaniem, a zadanie kończy się niepowodzeniem z błędem EListError lub plik wyjściowy otwiera się z ostrzeżeniem o uszkodzonych odsyłaczach. Plik nigdy nie był uszkodzony. Jest to plik hybrydowy (hybrid-reference) i właśnie ta struktura, która pozwala piętnastoletniej przeglądarce go otworzyć, jest tą, która pokonuje program wczytujący, który przestaje czytać zbyt wcześnie

To jeden z najczęstszych sposobów, w jaki potok obróbki PDF, który przeszedł każdy wewnętrzny test, spotyka plik, którego nie potrafi przetworzyć w pełnym cyklu (round-trip). Wszystkie dane wejściowe były generowane wewnętrznie, więc nigdy nie były hybrydowe. Pierwszy plik hybrydowy pojawia się w dniu, w którym klient przekazuje fakturę wyeksportowaną z arkusza kalkulacyjnego

Co Word i Excel faktycznie zapisują

ISO 32000-1 opisuje układ hybrydowych odsyłaczy w §7.5.8.4. Aplikacja, która chce korzystać z funkcji PDF 1.5, takich jak strumienie obiektów (object streams), jednocześnie pozwalając czytnikowi PDF 1.4 otworzyć plik, zapisuje informacje o odsyłaczach dwukrotnie. Istnieje klasyczna tabela odsyłaczy (cross-reference table), wiersze ASCII o stałej szerokości, które kończyły każdy PDF aż do wersji 1.4, oraz istnieje strumień odsyłaczy (cross-reference stream), który indeksuje resztę. Zwiastun (trailer) klasycznej sekcji zawiera wpis /XRefStm, którego wartość jest przesunięciem bajtowym tego strumienia

Ten podział pracy jest celowy. Obiekty, do których stary czytnik musi mieć dostęp, wśród nich katalog i drzewo stron, są adresowalne z klasycznej tabeli. Obiekty, które zostały złożone w skompresowane strumienie obiektów, są oznaczone jako wolne (free) w klasycznej tabeli, wpisem typu f, więc czytnik 1.4 przeskakuje wprost za nie i nigdy nie potyka się o strukturę, której nie potrafi przetworzyć. Ich rzeczywiste położenia znajdują się tylko w strumieniu odsyłaczy. Sygnaturą takiego pliku jest jego koniec: krótka klasyczna sekcja, często niebędąca niczym więcej niż xref, po którym następuje nagłówek podsekcji 0 0, którego zwiastun wskazuje na /XRefStm, gdzie znajdują się rzeczywiste dane do odzyskania

Dlaczego prawidłowa liczba stron niczego nie dowodzi

Ponieważ katalog i drzewo stron są celowo osiągalne z klasycznej tabeli, program wczytujący, który czyta tylko tę tabelę, znajduje /Root, przechodzi drzewo stron i zgłasza prawidłową liczbę stron. Wszystko, czego potrzebuje stary czytnik, jest obecne, więc plik wydaje się zdrowy. Obiekty, które zniknęły, to te upakowane w strumieniach obiektów: słowniki pól AcroForm, elementy struktury tagged-PDF, długi ogon małych słowników, które nigdy nie musiały być widoczne dla starszej przeglądarki

Nie zauważasz tej luki, dopóki coś nie dotknie tych obiektów, a pełny ponowny zapis dotyka ich wszystkich. Przejście dokumentu w celu ponownego zaszyfrowania lub przepisania go jest dokładnie operacją, która żąda po kolei każdego numeru obiektu, dlatego objaw ujawnia się w czasie zapisu, a nie w czasie wczytywania, z dala od swojej przyczyny

Pułapką jest detektor, który widzi xref i się zatrzymuje

Tani sposób na zdecydowanie, jak plik jest indeksowany, to podążanie za startxref i sprawdzenie pierwszych bajtów, na które wskazuje. Słowo kluczowe xref oznacza klasyczną tabelę; obiekt strumienia oznacza strumień odsyłaczy. Ten test jest poprawny dla każdego pliku, który zobowiązuje się do jednego schematu. Jest błędny dla pliku hybrydowego, którego startxref celuje w klasyczną sekcję wyłącznie po to, by zadowolić stare czytniki, podczas gdy /XRefStm w zwiastunie tej sekcji to miejsce, w którym większość dokumentu jest faktycznie indeksowana. Detektor, który zwraca „klasyczny” na pierwszym napotkanym xref, nigdy nie czyta /XRefStm, a każdy obiekt, który żyje tylko w strumieniu, staje się niewidoczny

var
  Pdf: THotPDF;
  PageCount: Integer;
begin
  Pdf := THotPDF.Create(nil);
  try
    PageCount := Pdf.LoadFromFile('Invoice_XLS.pdf');  // count is correct
    // inspect or edit the loaded document here
    Pdf.SaveLoadedDocument('Invoice_secured.pdf');     // walks every object
  finally
    Pdf.Free;
  end;
end;

Przy detektorze z wczesnym wyjściem wczytywanie wygląda dobrze, a ponowny zapis jest miejscem, w którym brakujące obiekty dają o sobie znać. Rozwiązaniem nie jest odczytanie większej liczby bajtów na początku; jest nim rozpoznanie hybrydowego zwiastuna i podążenie za /XRefStm przed uznaniem pliku za gotowy

Kolejność scalania nie podlega negocjacji

Gdy oba indeksy zostaną odczytane, można je połączyć tylko w jednym kierunku. Strumień odsyłaczy musi zostać scalony jako pierwszy, z klasycznymi wpisami wypełnionymi wokół niego. Powodem jest małe oszustwo leżące u podstaw formatu. Plik hybrydowy oznacza swoje skompresowane obiekty jako wolne w klasycznej tabeli, aby stare czytniki je zignorowały. Program wczytujący, który stosuje zasadę „pierwszy widziany wygrywa” (first-seen-wins) i czyta klasyczną tabelę jako pierwszą, zapisze te numery obiektów jako wolne, a następnie odrzuci wpisy strumienia, które faktycznie je lokalizują, ponieważ sloty są już zajęte. Odwróć kolejność, a wpisy typu 2 ze strumienia, z których każdy to numer strumienia obiektów plus indeks, wygrywają sloty, które mają posiadać, a klasyczne wpisy układają się wokół nich

Ta sama dyscyplina chroni przed wskrzeszeniem usuniętego obiektu przez starszą rewizję. Aktualizacje przyrostowe (incremental updates) łączą się wstecz przez /Prev, a wolny wpis typu 0 jest wartownikiem (sentinel) sygnalizującym, że nowsza sekcja wycofała numer obiektu. Późniejsza, starsza sekcja w łańcuchu nie może zostać dopuszczona do nadpisania tego wartownika przestarzałym położeniem. Traktuj zasadę „pierwszy widziany” jako miarodajną dla znaczników wolnych i usunięty obiekt pozostaje usunięty; traktuj ją niedbale, a własna historia pliku wskrzesi treść, którą najnowsza rewizja usunęła

Co to oznacza w HotPDF

Silnik rozwiązuje pliki hybrydowe za ciebie i robi to na każdej ścieżce, która musi przetworzyć dane odsyłaczy. Wczytaj dokument za pomocą LoadFromFile lub LoadFromStream, wprowadź swoje zmiany i wywołaj SaveLoadedDocument; lub uruchom operację jednoetapową, taką jak EncryptFile, która czyta wejście i zapisuje wyjście. Tak czy inaczej, odzyskiwanie czyta /XRefStm, scala sekcję strumienia przed klasycznymi wpisami i rozwiązuje obiekty żyjące w strumieniach, zanim zapis je wyliczy. Ścieżka szyfrowania AES-256 to miejsce, w którym problem pojawił się po raz pierwszy, ponieważ szyfrowanie dokumentu przepisuje każdy obiekt, a więc wymaga, aby każdy obiekt został już zlokalizowany

// One-shot: read the hybrid input, write an AES-256 encrypted copy
Pdf.EncryptFile('Letter_DOC.pdf', 'Letter_secured.pdf',
  'owner-secret', '', aes256, [prPrint, prFillAnnotations]);

Szczegół warty zapamiętania znajduje się powyżej samego API. Pliki, które przychodzą z Worda, Excela, PowerPointa oraz z długiej listy potoków „Zapisz jako PDF”, są rutynowo hybrydowe, więc program wczytujący, który testujesz tylko na wyjściu własnego generatora, może nigdy nie spotkać takiego pliku podczas testów. Zaszczep swoje zestawy testowe dokumentami wyeksportowanymi z prawdziwych aplikacji Office, a nie tylko plikami wyprodukowanymi przez twój własny kod

Sprawdzanie pliku, który podejrzewasz

Dwie inspekcje szybko rozstrzygają kwestię. Otwórz plik w widoku szesnastkowym i odczytaj bajty po ostatnim startxref; plik hybrydowy pokazuje krótką klasyczną sekcję, której słownik zwiastuna zawiera /XRefStm. Albo porównaj liczbę obiektów zgłaszaną przez pełne przetworzenie z najwyższym numerem obiektu, który /Size deklaruje w zwiastunie. Duża różnica oznacza, że obiekty ukrywają się w strumieniach, których program wczytujący nie otworzył, co jest tym samym niedoborem, który później zamienia się w awarię w czasie zapisu

Koniec typowego eksportu z Excela czyni pierwsze sprawdzenie konkretnym. Wszystko po ostatnim słowie kluczowym xref jest zwykłym ASCII, więc sygnatura jest czytelna wprost z widoku szesnastkowego (przesunięcia poglądowe, adnotacje dodane)

xref
0 0                          % empty classic subsection: no rows at all
trailer
<< /Size 216                 % one past the highest object number in use
   /Root 1 0 R
   /Info 15 0 R
   /ID [<5C9A...> <5C9A...>]
   /XRefStm 87325            % byte offset of the cross-reference stream
>>
startxref
88710                        % points at the classic section above
%%EOF

Podsekcja 0 0 jest sygnałem: klasyczna tabela z zerową liczbą wpisów istnieje wyłącznie po to, by nieść zwiastun, a zwiastun istnieje głównie po to, by powiedzieć /XRefStm 87325. Detektor, który zatrzymuje się na słowie kluczowym xref, widział w tym momencie indeks niczego. Gdy wolisz zeskryptować sprawdzenie niż oglądać je na oko, znacznik zawsze mieści się w ostatnich kilku kilobajtach pliku, więc ograniczony odczyt wsteczny wystarczy

// Returns the /XRefStm offset from the file's tail, or -1 if the
// marker is absent (the file is not hybrid, or not a PDF at all)
function FindXRefStm(const FileName: string): Int64;
var
  FS: TFileStream;
  Tail: AnsiString;
  Len, P: Integer;
begin
  Result := -1;
  FS := TFileStream.Create(FileName, fmOpenRead or fmShareDenyWrite);
  try
    Len := 2048;                        // the trailer lives in the tail
    if FS.Size < Len then
      Len := Integer(FS.Size);
    FS.Position := FS.Size - Len;       // bounded backward read: 2 KB max
    SetLength(Tail, Len);
    FS.ReadBuffer(Tail[1], Len);
  finally
    FS.Free;
  end;
  P := Pos(AnsiString('/XRefStm'), Tail);
  if P = 0 then
    Exit;                               // no hybrid marker in the tail
  Inc(P, Length('/XRefStm'));
  while (P <= Len) and (Tail[P] in [' ', #9, #13, #10]) do
    Inc(P);                             // skip whitespace after the key
  Result := 0;
  while (P <= Len) and (Tail[P] in ['0'..'9']) do
  begin
    Result := Result * 10 + Ord(Tail[P]) - Ord('0');
    Inc(P);
  end;
end;

// Usage: a non-negative result names the byte where the stream starts
if FindXRefStm('Invoice_XLS.pdf') >= 0 then
  Writeln('hybrid-reference file: resave will need the /XRefStm section');

Traktuj tę sondę jako triaż, a nie jako parser: mówi ci, które pliki w partii zasługują na uwagę przed uruchomieniem zadania ponownego zapisu i nic więcej. To, co program wczytujący musi następnie zrobić z znalezionym przesunięciem — podążanie za łańcuchem sekcji, scalanie wpisów strumienia przed klasycznymi, poszanowanie wartowników wolnych wpisów — jest omówione krok po kroku w naszym artykule towarzyszącym o obsłudze hybrydowych PDF z aplikacji Office

Strona pisarza tej historii, czyli jak w ogóle powstają strumienie obiektów i skompresowane odsyłacze, jest opisana w naszym artykule o strumieniach obiektów i aktualizacjach przyrostowych. Gdy dany plik hybrydowy jest również bardzo duży, techniki wczytywania z przewodnika po Direct File API dla dużych przepływów pracy z PDF pozwalają go zbadać bez wczytywania całości do pamięci. Oba naturalnie łączą się z odzyskiwaniem opisanym tutaj, które jest częścią HotPDF Component dla Delphi i C++Builder, obok API do wczytywania, edycji, szyfrowania i podpisywania omówionych w innych miejscach na tym blogu