Kad je PDF cross-reference tablica neupotrebljiva, rješenje je potpuno je zanemariti i obnoviti iz tijela datoteke. PDFlibPas Delphi PDF Library to čini jednoprolaznim token skenerom koji bilježi svako pravo zaglavlje indirektnog objekta koje pronađe, zatim oporavlja rječnik trailera i predaje obnovljenu tablicu uobičajenom učitavaču
Što se prvo pokvari kad je PDF oštećen?
Cross-reference tablica je najkrhkiji dio PDF-a, jer je jedini dio koji pohranjuje apsolutne pomake u bajtovima. ISO 32000-1 §7.5.4 definira te unose kao deseteroznamenkaste pomake od početka datoteke, a §7.5.5 postavlja ključnu riječ startxref pri kraju, gdje pokazuje na samu tablicu. Svaki od tih brojeva poništi se bilo kojim uređivanjem koje pomakne bajtove. FTP sesija koja je radila u tekstualnom načinu i prevela CRLF, prekinuto preuzimanje, sektor koji je otkazao na dijeljenom disku, batch alat koji je dodao podatke bez ispravnog zapisivanja inkrementalnog ažuriranja: svi oni ostavljaju podatke objekata savršeno čitljivima, a indeks pokazuje na smeće
Zato je "datoteka je oštećena i popravlja se" tako čest dijalog. Bajtovi su gotovo uvijek još uvijek tu. Ono što nedostaje je karta. Rekonstrukcija stoga nije forenzički oporavak izgubljenih podataka, nego obnova indeksa koji se može izvesti iz tijela, i uspijeva mnogo češće nego što korisnici očekuju, jer skupi sadržaj, stabla stranica te fontovi i slike, ostaje netaknut
Zašto pretraga za N 0 obj pronalazi lažne pogotke?
Naivna obnova pretražuje sirove bajtove za uzorkom "cijeli broj, cijeli broj, obj" i bilježi svaki pogodak. Pronalazi previše. PDF je format kontejnera, a tri regije datoteke su neprozirne za gramatiku objekata: komentari (§7.2), nizovi (§7.3.4) i podaci tokova (§7.3.8). Bilo koja od njih može sadržavati bajtove koji se čitaju točno kao zaglavlje objekta, a nijedna od njih nije zaglavlje objekta. Natpis u literalnom nizu, zaostali debug komentar ili dva megabajta Flate ili DCT izlaza rado će proizvesti nešto što izgleda kao 99 0 obj
const
Trap: AnsiString =
'4 0 obj'#10 +
'(a caption that mentions 88 0 obj)'#10 + // literal string, not an object
'endobj'#10 +
'% 77 0 obj left over from a debug dump'#10 + // comment, not an object
'5 0 obj'#10 +
'<< /Length 2097152 >>'#10 +
'stream'#10 +
{ two MiB of compressed bytes that contain the byte sequence
99 0 obj and, further along, a complete endstream }
'endstream'#10 +
'endobj'#10;
Svaki lažni unos košta dvostruko. Zagađuje obnovljenu tablicu brojem objekta koji ne postoji, a može zasjeniti pravi objekt istog broja koji se kasnije pojavljuje u datoteci. PDFlibPas zato uopće ne podudara uzorke. On tokenizira, što znači da uvijek zna nalaze li se bajtovi ispod pokazivača u kôdu ili u tovaru, a tovar se preskače bez ikakvog tumačenja
Jednoprolazni stroj stanja preko blokova od 64 KiB
PDFlibPas skenira cijelu datoteku točno jednom, u blokovima od 64 KiB, sa strojem stanja izgrađenim na pravilima tokena ISO 32000-1 §7.2 i sintaksi indirektnog objekta iz §7.3.10. Token završava bjelinom ili jednim od graničnih znakova, a zaglavlje objekta bilježi se samo kad je viđen potpun slijed pozitivnog broja objekta, nenegativnog broja generacije i gole ključne riječi obj. Zabilježeni pomak je početak tokena broja objekta, na što unos cross-reference-a mora pokazivati, a ne položaj ključne riječi obj
function RebuildIsWhiteSpace(Value: Byte): Boolean;
begin
Result := (Value = 0) or (Value = 9) or (Value = 10) or
(Value = 12) or (Value = 13) or (Value = 32);
end;
function RebuildIsDelimiter(Value: Byte): Boolean;
begin
Result := (Value = Ord('(')) or (Value = Ord(')')) or
(Value = Ord('<')) or (Value = Ord('>')) or
(Value = Ord('[')) or (Value = Ord(']')) or
(Value = Ord('{')) or (Value = Ord('}')) or
(Value = Ord('/')) or (Value = Ord('%'));
end;
Važna pojedinost je da stanje tokena i stanje niza preživljavaju granicu bloka. Zaglavlje koje se proteže preko linije od 65536 bajtova i dalje se prepoznaje, jer djelomični token, cijelobrojni par koji čeka i zastavice unutar niza svi prelaze u sljedeći blok. Spremnici su fiksni: 64 KiB za skeniranje, 32 bajta za najdulji token koji uopće može biti bitan, a jedina polja koja rastu s datotekom su popisi brojeva objekta, brojeva generacije i 64-bitnih pomaka, koji su proporcionalni stvarnom broju objekata, a ne veličini datoteke. U praksi skeniranje izdaje sekvencijalna čitanja i najviše dva eksplicitna skoka preko cijelog dokumenta, što ga čini izvedivim na ulazima od nekoliko stotina megabajta o kojima govori članak o direktnom pristupu spajanju i razdvajanju
Zašto se toku ne može vjerovati da završava na endstream?
Zato što su podaci toka proizvoljni bajtovi, a proizvoljni bajtovi mogu slučajno napisati endstream. Tok koji počinje nakon ključne riječi stream mora se preskočiti kao neprozirni podaci dok stvarno ne završi, ali prvo pojavljivanje zatvarajuće ključne riječi samo je kandidat. PDFlibPas ovo rješava zahtijevajući potvrdu: token endstream prihvaća se kao pravi kraj toka samo kad je sljedeći token koji nije bjelina samostalan endobj, slijed koji §7.3.8 zahtijeva oko objekta toka. Slučajan pogodak unutar komprimiranih podataka gotovo nikad nema taj nastavak, pa skener ostaje unutar toka i nastavlja dalje. Dva manja pravila jednako su bitna. Ključna riječ stream ulazi u stanje toka samo kad je gola ključna riječ, pa objekt imena poput /stream u rječniku nikad je ne pokreće. A token obj ili trailer se poštuje samo kad token nije premašio ograničenje od 32 bajta i nije počeo kosom crtom. Bez ta dva čuvara, rječnik resursa s pogrešnim imenima ključeva bio bi dovoljan da skretanje skeniranja, što je upravo klasa neprijateljskog unosa obrađena u bilješkama o sigurnom parsiranju nepouzdanih PDF-ova
Pronalaženje pravog kraja rječnika trailera
Oporavak objekata samo je pola posla, jer učitaču i dalje treba trailer za pronalaženje /Root. PDFlibPas pamti posljednjih 64 pozicije ključne riječi trailer pronađene tijekom skeniranja i validira ih unatrag, počevši od najnovijeg, tako da najnoviji upotrebljivi trailer pobjeđuje, a zalutala ključna riječ koja nije praćena rječnikom jednostavno padne na validaciji i prelazi na prethodnog kandidata. Svaki kandidat čita se s ograničenjem od 1 MiB, a kraj rječnika locira se praćenjem ugniježđene dubine << i >> zajedno s escape znakovima literalnih nizova, heksadecimalnim nizovima i komentarima
// A naive reader that stops at the first '>>' truncates this trailer,
// and a fixed 2048-byte window can cut it in half on a large one
'trailer'#10 +
'<< /Size 5 /Root 1 0 R' +
' /Custom << /Text (value >> preserved) >> >>'#10
Praćenje dubine nije akademsko. Skraćeni trailer koji izgubi /Encrypt pretvara oporabljivi šifrirani dokument u neotvoriv, a gubitak /Info ili prilagođenog podrječnika tiho odbacuje metapodatke o kojima nizvodni sustav može ovisiti. Ako je datoteka šifrirana, oporavljeni trailer je ono što omogućuje pokretanje uobičajenog puta za vjerodajnice, a semantika ponovnih pokušaja ista je kao ona opisana u članku o učitavanju šifriranog dokumenta
Što vam rekonstrukcija ne može vratiti
Rekonstrukcija je najbolji mogući pokušaj, a dio isporuke je i biti iskren o njezinim granicama. Tri slučaja u potpunosti ne uspijevaju. Objekti upakirani unutar tokova objekata (§7.5.7) nisu pojedinačno vidljivi skeniranju bajtova, pa ako kontejner preživi, a njegov cross-reference tok (§7.5.8) ne, objekti koje sadrži nisu indeksirani obnovom. Datoteka čije je tijelo stvarno oštećeno, a ne tek pogrešno indeksirano, proizvest će zaglavlja čiji se sadržaj više ne parsira. A datoteka bez oporabljive ključne riječi trailer i bez čitljivog kataloga nema na što usidriti stablo dokumenta, bez obzira koliko je zaglavlja objekata pronađeno
Duplicirani brojevi objekata su zanimljiv srednji slučaj. Inkrementalno ažurirana datoteka legitimno sadrži nekoliko generacija istog broja objekta, a preživjeli lanac cross-reference-a jedini je zapis koja je od njih trenutna. Obnova nema taj lanac, pa bilježi svako zaglavlje koje vidi redoslijedom u datoteci i razrješava po broju objekta naknadno. Obično pobjeđuje kasnija revizija, što je obično ispravno, ali dokument koji je bio ažuriran pa djelomično vraćen unatrag može se vratiti suptilno drukčiji od onoga što je izvorni xref opisivao. Linearizirane datoteke nose istu opasku iz suprotnog smjera: raspored prve stranice i tablice naznaka postaju besmisleni čim se indeks regenerira, pa se s popravljenom datotekom treba postupati kao s običnim, nelineariziranim dokumentom
var
Pdf: TPDFlib;
begin
Pdf := TPDFlib.Create;
try
if Pdf.LoadFromFile('truncated-invoice.pdf', '') = 1 then
begin
if Pdf.GetDocumentRepaired = 1 then
LogWarning('xref was unusable; the table was reconstructed');
if Pdf.PageCount > 0 then
Pdf.SaveToFile('recovered-invoice.pdf'); // writes a clean xref
end;
finally
Pdf.Free;
end;
end;
Prijelaz na rezervni put je automatski: PDFlibPas pokreće sirovo skeniranje kad god se lanac cross-reference-a ne može pročitati, a i kad svaki unos u upotrebi tvrdi da je pomak nula, što je potpis tablice koja je zapisana, ali nikad popunjena. GetDocumentRepaired vraća 1 kad je taj put pokrenut, i vrijedi ga bilježiti umjesto zanemariti, jer dokument koji se učitao kroz rekonstrukciju treba ponovno spremiti u čistu datoteku, a ne ostaviti u cjevovodu kao da se ništa nije dogodilo. Spremanje zapisuje svježu, konzistentnu cross-reference tablicu, što je najjeftiniji mogući popravak za svakog nizvodnog potrošača
Put rekonstrukcije, zastavica GetDocumentRepaired i strujni učitavač prikazani ovdje dio su PDFlibPas Delphi PDF Library, uz API-je za parsiranje, renderiranje i potpisivanje pokrivene drugdje na ovom blogu