Artykuł techniczny

Ręczna edycja PDF w edytorze tekstu, a potem naprawa pliku

HotPDF potrafi zapisać wczytany dokument w formie diagnostycznej, zaprojektowanej do odczytu i edycji przez człowieka, a następnie naprawić plik po dokonaniu edycji. SaveLoadedDocumentDiagnostic zapisuje dokument z deterministycznym porządkiem kluczy słownika, gdzie każdy obiekt jest zwykłym obiektem pośrednim, bez strumieni obiektów, bez strumienia odsyłaczy krzyżowych i bez linearyzacji. RepairDiagnosticDocument odbudowuje następnie przesunięcia bajtowe, długości strumieni, tabelę odsyłaczy krzyżowych i trailer, które twoja edycja unieważniła

Ta para funkcji istnieje dla jednego przepływu pracy: zrozumienia lub naprawienia tego, co naprawdę znajduje się wewnątrz PDF. Każdy programista, który debugował problem z renderowaniem, chciał otworzyć plik, zmienić jedną liczbę w jednym słowniku i zobaczyć wynik. W zwykłym PDF jest to niemożliwe, ponieważ treść jest skompresowana w strumienie obiektów, a każdy wstawiony bajt przesuwa przesunięcia, które tabela odsyłaczy krzyżowych rejestruje z dokładnością co do bajtu

Co odróżnia zapis diagnostyczny?

Sześć ustawień zmienia się razem. Klucze słowników są zapisywane w deterministycznej kolejności, więc dwa zapisy tego samego dokumentu dają porównywalne pliki. Tryb wyjścia diagnostycznego opisuje strukturę adnotacjami. Wymuszony jest pełny ponowny zapis, więc nic nie jest przenoszone jako nietknięta rewizja przyrostowa. Linearyzacja jest wyłączona, ponieważ zlinearyzowany plik jest zorganizowany z myślą o przeglądarce internetowej, a nie o czytelniku. Strumienie odsyłaczy krzyżowych są wyłączone, więc tabela jest zwykłym tekstem. A strumienie obiektów są wyłączone, więc żaden obiekt nie jest ukryty wewnątrz skompresowanego kontenera

Wynikiem jest plik, którego nagłówek niesie znacznik diagnostyczny obok zwykłej sygnatury %PDF-, a którego treść można przeglądać w dowolnym edytorze. Obiekty pojawiają się jeden po drugim, słowniki są czytelne, a strumienie treści znajdują się tam, gdzie można je znaleźć. Narzędzie naprawcze sprawdza obecność tego znacznika i odrzuca pliki, które go nie mają, ponieważ zwarta struktura zwykłego PDF nie jest czymś, co powinien próbować interpretować naprawiacz działający na zakresach bajtów

Diagram zestawiający zwykły produkcyjny zapis PDF z diagnostycznym zapisem HotPDF, który zapisuje zwykłe, czytelne obiekty oraz tekstową tabelę xref możliwą do otwarcia w dowolnym edytorze tekstu
Zwykły zapis ukrywa obiekty wewnątrz skompresowanych strumieni, natomiast zapis diagnostyczny rozkłada każdy obiekt osobno, blok po bloku, z tekstową tabelą odsyłaczy krzyżowych. Wynikiem jest plik, który można przeglądać i zmieniać w dowolnym edytorze
uses
  HPDFDoc;

var
  Pdf: THotPDF;
begin
  Pdf := THotPDF.Create(nil);
  try
    if Pdf.LoadFromFile('invoice.pdf') <= 0 then
      Exit;
    // Czytelny, jeden obiekt na blok, nic ukrytego w strumieniach obiektów
    Pdf.SaveLoadedDocumentDiagnostic('invoice-diagnostic.pdf');
  finally
    Pdf.Free;
  end;
end;

Dlaczego edycja psuje plik i co trzeba odbudować?

Trzy elementy w PDF kodują pozycje bajtowe lub liczby bajtów. Tabela odsyłaczy krzyżowych mapuje każdy numer obiektu na przesunięcie bajtowe. Wartość startxref na końcu pliku wskazuje tę tabelę. A każdy słownik strumienia niesie /Length, mówiące, ile bajtów zajmuje treść strumienia

Zmień jeden znak w dowolnym miejscu przed końcem pliku, a przesunięcia następujące po nim staną się błędne. Dodaj linię do strumienia treści, a jego /Length również będzie błędne. Większość przeglądarek odrzuci taki plik, a te, które go odzyskują, robią to przez skanowanie, co raczej ukrywa błąd, niż go naprawia. Naprawa odbudowuje wszystkie trzy elementy na podstawie tego, co bajty faktycznie teraz mówią

Diagram pokazujący, jak ręczna edycja przesuwa przesunięcia bajtowe, przez co tabela odsyłaczy krzyżowych, wskaźnik startxref i długości strumieni stają się nieaktualne, dopóki RepairDiagnosticDocument nie odbuduje wszystkich trzech na podstawie faktycznego układu
Dodanie bajtów przesuwa każde następujące po nich przesunięcie, więc zapisane pozycje, wskaźnik startxref i zadeklarowane długości strumieni przestają odpowiadać rzeczywistości. Naprawa odbudowuje każdy z nich na podstawie tego, co edytowany plik faktycznie teraz zawiera
var
  Options: THPDFDiagnosticRepairOptions;
  Report: THPDFDiagnosticRepairReport;
begin
  Options := THPDFDiagnosticRepairOptions.Default;
  Options.VerifyOutput := True;         // wczytaj wynik przed zastąpieniem

  if THotPDF.RepairDiagnosticDocument('invoice-diagnostic.pdf',
       'invoice-fixed.pdf', Options, Report) then
    Writeln(Format('%d objects, %d streams, %d lengths corrected, ' +
      '%d xref rows rebuilt, %d pages verified',
      [Report.ObjectCount, Report.StreamCount,
       Report.RepairedStreamLengthCount, Report.RebuiltXRefEntryCount,
       Report.VerifiedPageCount]))
  else
    Writeln(Format('repair failed (%d): %s',
      [Ord(Report.Status), string(Report.Diagnostic)]));
end;

Kopiuj bajty, nie serializuj ponownie

Decyzją projektową, która czyni to narzędzie godnym zaufania, jest to, że nigdy nie wczytuje ono dokumentu, by zapisać go ponownie od zera. Kopiuje zakresy bajtów obiektów, które zwalidowało, zmienia tylko błędne wartości /Length i odbudowuje wokół nich tabelę, trailer i startxref

Wczytanie i ponowna serializacja byłyby dużo prostsze do zaimplementowania, ale po cichu zmieniałyby rzeczy. Nieznana składnia obiektów zostałaby znormalizowana, sekwencje ucieczki w łańcuchach zostałyby przepisane w formie preferowanej przez zapisujący, a już zakodowane ładunki strumieni mogłyby zostać przekodowane. Dla narzędzia diagnostycznego jest to nie do przyjęcia: zmieniłeś jedną liczbę, więc diff powinien pokazać jedną liczbę, a nie tysiąc przypadkowych zmian formatowania. Jeśli chcesz wiedzieć dokładnie, co zmieniło się między dwoma plikami, to jest problem porównania, opisany w porównywaniu dwóch plików PDF pod względem struktury i pikseli

Diagram porównujący naprawę HotPDF, kopiującą dosłownie zwalidowane zakresy bajtów, ze ścieżką wczytania i ponownej serializacji, która normalizuje składnię, przepisuje sekwencje ucieczki w łańcuchach i przekodowuje ładunki strumieni
Naprawa kopiuje zwalidowane zakresy bajt po bajcie i łata tylko błędne wartości /Length, dzięki czemu twój ręcznie wykonany diff pozostaje nietknięty. Ponowne wczytanie i serializacja po cichu przepisałyby składnię, sekwencje ucieczki i kodowania, których nigdy nie dotknąłeś

Dwie reguły parsowania zapobiegające fałszywym obiektom

Skaner działający na zakresach bajtów napotyka oczywistą pułapkę: tekst wyglądający jak nagłówek obiektu może pojawić się wewnątrz ładunku strumienia albo wewnątrz łańcucha znaków. Naiwne skanowanie wygenerowałoby wiersz odsyłacza krzyżowego wskazujący na środek strumienia treści, a wynikowy plik byłby uszkodzony w nowy, jeszcze bardziej mylący sposób

HotPDF unika tego, pomijając cały zakres bajtów każdego zwalidowanego obiektu, korzystając z jego zarejestrowanego przesunięcia końcowego, dzięki czemu nic wewnątrz treści obiektu nigdy nie jest brane pod uwagę jako kandydat. Między obiektami dozwolone są tylko białe znaki PDF i komentarze; wszystko inne oznacza, że region nie został zrozumiany i jego bajty nie mogą trafić do wyniku

Długości strumieni wymagają równie dużej ostrożności. Rzeczywista długość ładunku wyklucza znak końca linii, który zapisujący wstawia przed endstream. Gdy zadeklarowana długość trafia dokładnie na znacznik, deklaracja jest akceptowana. Gdy tak się nie dzieje, naprawiacz wykonuje ograniczone przeszukiwanie w poszukiwaniu pary endstream i endobj i odejmuje dokładnie jeden ogranicznik: CR, LF albo CRLF. Pomylenie się tutaj o jeden bajt daje plik, który otwiera się i renderuje stronę z jednym zabłąkanym operatorem na końcu swojego strumienia treści

Co trailer może zachować

Odbudowany trailer nie jest kopią starego. /Size, /Prev i /XRefStm opisują poprzedni układ pliku i są ponownie obliczane albo odrzucane. Przetrwają wyłącznie wpisy, których cele zostały zwalidowane: /Info, /ID i /Encrypt. A /Root musi wskazywać dokładnie jeden obiekt Catalog — nie zero i nie kilka, co zwykle jest efektem pliku złożonego przez kopiowanie i wklejanie bloków obiektów

Bezpieczeństwo wyjścia jest obsłużone tak, jak powinno być w narzędziu działającym na plikach, na których ludziom zależy. Wynik jest zapisywany do pliku tymczasowego w katalogu docelowym, a następnie weryfikowany przez wczytanie go zwykłą ścieżką z wyłączonym odzyskiwaniem odsyłaczy krzyżowych, więc plik, który otwiera się tylko dzięki odzyskiwaniu, liczy się jako niepowodzenie. Dopiero po zweryfikowaniu drzewa stron plik tymczasowy atomowo zastępuje plik docelowy. Nieudana naprawa nigdy nie nadpisuje istniejącego wyniku

Limity i uczciwe kody błędów

Trzy limity ograniczają pracę: bajty wejściowe, liczbę obiektów kandydujących i maksymalny numer obiektu, domyślnie 8 GiB, dwa miliony i dwa miliony. Limit numeru obiektu ma większe znaczenie, niż mogłoby się wydawać, ponieważ rzadka tabela z kilkoma obiektami ponumerowanymi w miliardach w przeciwnym razie ogromnie rozdęłaby odbudowaną tabelę odsyłaczy krzyżowych. Klasyczne tabele odsyłaczy krzyżowych odrzucają też przesunięcia wymagające więcej niż dziesięciu cyfr dziesiętnych, co jest ograniczeniem formatu, a nie wyborem implementacyjnym

Niepowodzenia zgłaszają konkretny status, a nie wartość logiczną: nieprawidłowe argumenty, źródło zbyt duże, plik nie jest plikiem diagnostycznym, przekroczony limit obiektów, nieprawidłowy obiekt, zduplikowany obiekt, brakujący katalog, nieprawidłowy trailer, błąd wyjścia lub niepowodzenie walidacji. Każdy odpowiada innej poprawce. Zduplikowany obiekt oznacza, że wkleiłeś blok obiektu dwukrotnie. Brakujący katalog zwykle oznacza, że obiekt niosący /Type /Catalog został uszkodzony. Niepowodzenie walidacji oznacza, że struktura jest teraz spójna, ale drzewo stron się nie wczytuje, co zwykle jest efektem tablicy /Kids zedytowanej niezgodnie z jej /Count

Dwie praktyczne uwagi zamykają temat. Zachowaj plik diagnostyczny, nie tylko naprawiony, aby kolejna edycja zaczynała się od czytelnej podstawy. I nie wysyłaj wyjścia diagnostycznego do użytkowników końcowych: jest ono większe niż zwykły zapis, ponieważ nic nie jest skompresowane w strumienie obiektów, a produkcyjną ścieżką dla zwartego pliku jest zwykła ścieżka zapisu opisana w strumieniach obiektów i aktualizacjach przyrostowych. Gdy celem jest znalezienie błędu, a nie jego zmiana, zautomatyzowana ścieżka w automatyzacji raportów preflight daje odpowiedź szybciej niż czytanie bajtów

Zapis diagnostyczny, naprawa, preflight i porównywanie działają na tym samym modelu wczytanego dokumentu w Delphi i C++Builder; pełną listę funkcji znajdziesz na stronie komponentu HotPDF PDF dla Delphi