Ko je tabela navzkrižnih referenc PDF neuporabna, je popravek, da jo popolnoma prezremo in jo obnovimo iz telesa datoteke. PDFlibPas Delphi PDF Library to počne z enoprehodnim tokenskim skenerjem, ki zabeleži vsako pravo glavo posrednega objekta, ki jo zazna, nato obnovi slovar trailerja in obnovljeno tabelo preda običajnemu nalagalniku
Kaj se pri poškodovanem PDF-ju pokvari najprej
Tabela navzkrižnih referenc je najbolj krhek del PDF-ja, ker je edini del, ki hrani absolutne bajtne odmike. ISO 32000-1 §7.5.4 te vnose definira kot desetmestne odmike od začetka datoteke, §7.5.5 pa besedo startxref postavi blizu konca, kjer kaže na samo tabelo. Vsako od teh števil izniči vsako urejanje, ki premakne bajte. FTP seja, ki je tekla v besedilnem načinu in prevedla CRLF, prekinjen prenos, sektor, ki je odpovedal na skupnem disku, paketno orodje, ki je dodajalo, ne da bi pravilno zapisalo prirastno posodobitev: vsi ti puščajo podatke objektov popolnoma berljive, indeks pa kaže na smeti
Zato je "datoteka je poškodovana in se popravlja" tako pogosto pogovorno okno. Bajti so skoraj vedno še vedno tam. Manjka zemljevid. Obnova torej ni forenzična obnovitev izgubljenih podatkov, temveč obnova indeksa, ki ga je mogoče izpeljati iz telesa, uspe pa veliko pogosteje, kot uporabniki pričakujejo, ker je draga vsebina, drevesa strani, pisave in slike, nedotaknjena
Zakaj iskanje N 0 obj najde napačna ujemanja?
Naivna obnova preišče surove bajte za vzorcem "celo število, celo število, obj" in zabeleži vsak zadetek. Najde preveč. PDF je vsebniška oblika, tri regije datoteke pa so neprosojne za slovnico objektov: komentarji (§7.2), nizi (§7.3.4) in podatki tokov (§7.3.8). Katerakoli od njih lahko vsebuje bajte, ki se berejo natanko kot glava objekta, nobena od njih pa ni glava objekta. Napis v dobesednem nizu, zaostal razhroščevalni komentar ali dva megabajta izhoda Flate ali DCT bodo z veseljem proizvedli nekaj, kar je videti kot 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;
Vsak napačen vnos stane dvakrat. Onesnaži obnovljeno tabelo s številko objekta, ki ne obstaja, in lahko zasenči pravi objekt z isto številko, ki se pojavi kasneje v datoteki. PDFlibPas zato sploh ne ujema vzorcev. Tokenizira, kar pomeni, da vedno ve, ali so bajti pod kurzorjem koda ali vsebina, vsebina pa je preskočena, ne da bi bila kdaj interpretirana
Enoprehoden avtomat stanj čez bloke po 64 KiB
PDFlibPas preišče celotno datoteko natanko enkrat, v blokih po 64 KiB, z avtomatom stanj, zgrajenim na pravilih žetonov ISO 32000-1 §7.2 in skladnji posrednih objektov iz §7.3.10. Žeton se konča pri belem prostoru ali pri enem od ločilnih znakov, glava objekta pa je zabeležena šele, ko je videno popolno zaporedje pozitivne številke objekta, nenegativne številke generacije in gole besede obj. Zabeleženi odmik je začetek žetona številke objekta, kar je tisto, na kar mora kazati vnos navzkrižne reference, ne pa položaj besede 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;
Pomembna podrobnost je, da stanje žetona in stanje niza preživita mejo bloka. Glava, ki razpenja mejo pri 65536 bajtih, je še vedno prepoznana, ker se delni žeton, čakajoč par celih števil in zastavice znotraj niza vsi prenesejo v naslednji blok. Medpomnilniki so fiksni: 64 KiB za skeniranje, 32 bajtov za najdaljši žeton, ki lahko sploh šteje, edina polja, ki rastejo z datoteko, pa so seznami številk objektov, generacij in 64-bitnih odmikov, ki so sorazmerni z resničnim številom objektov, ne pa z velikostjo datoteke. V praksi skeniranje izdaja zaporedna branja in največ dva izrecna skoka čez celoten dokument, kar ga naredi izvedljivega na vhodih velikosti več sto megabajtov, obravnavanih v članku o neposrednem dostopu pri združevanju in deljenju
Zakaj se toku ne sme zaupati, da se konča pri endstream?
Ker so podatki toka poljubni bajti, poljubni bajti pa lahko po naključju črkujejo endstream. Tok, ki se začne za besedo stream, je treba preskočiti kot neprosojne podatke, dokler se resnično ne konča, prva pojavitev zaključne besede pa je le kandidatka. PDFlibPas to razreši tako, da zahteva potrditev: žeton endstream je sprejet kot pravi konec toka le, ko je naslednji žeton, ki ni beli prostor, samostojen endobj, zaporedje, ki ga §7.3.8 zahteva okoli objekta toka. Naključni zadetek znotraj stisnjenih podatkov skoraj nikoli nima tega nadaljevanja, zato skener ostane znotraj toka in nadaljuje. Enako pomembni sta dve manjši pravili. Beseda stream vstopi v stanje toka le, kadar je gola beseda, tako da objekt imena, kot je /stream, v slovarju nanj nikoli ne sproži. In žeton obj ali trailer je upoštevan le, kadar žeton ni presegel omejitve 32 bajtov in se ni začel s poševnico. Brez teh dveh varoval bi slovar virov z napačnimi imeni ključev zadostoval, da bi skeniranje sprožil s poti, kar je natanko razred sovražnih vhodov, obravnavan v opombah o varnem razčlenjevanju nezaupanja vrednih PDF-jev
Iskanje pravega konca slovarja trailerja
Obnovitev objektov je le polovica dela, ker nalagalnik še vedno potrebuje trailer, da najde /Root. PDFlibPas si zapomni zadnjih 64 najdenih položajev besede trailer med skeniranjem in jih preveri nazaj, od najnovejšega naprej, tako da zmaga najnovejši uporaben trailer, zablodela beseda, ki ji ne sledi slovar, pa preprosto ne prestane preverjanja in se preide na prejšnjega kandidata. Vsak kandidat je prebran z omejitvijo 1 MiB, konec slovarja pa je lociran s sledenjem gnezdene globine << in >> skupaj z ubežnimi zaporedji dobesednih nizov, šestnajstiškimi nizi in komentarji
// 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
Sledenje globini ni akademsko. Prirezan trailer, ki izgubi /Encrypt, obnovljiv šifriran dokument spremeni v takega, ki ga ni mogoče odpreti, izguba /Info ali slovarja po meri pa tiho zavrže metapodatke, od katerih je lahko odvisen sistem naprej po verigi. Če je datoteka šifrirana, obnovljeni trailer omogoči normalno pot poverilnic, semantika ponovnih poskusov pa je enaka tisti, opisani v članku o nalaganju šifriranih dokumentov
Kaj vam obnova ne more vrniti
Obnova je najboljši mogoč poskus, priznavanje njenih meja pa je del njenega izdajanja. Trije primeri povsem odpovejo. Objekti, zapakirani znotraj tokov objektov (§7.5.7), niso posamično vidni bajtnemu skeniranju, zato če vsebnik preživi, njegov tok navzkrižnih referenc (§7.5.8) pa ne, objekti, ki jih vsebuje, niso indeksirani z obnovo. Datoteka, katere telo je bilo dejansko poškodovano, ne le napačno indeksirano, bo proizvedla glave, katerih vsebina se ne razčleni več. Datoteka brez obnovljive besede trailer in brez berljivega kataloga pa nima ničesar, na kar bi zasidrala drevo dokumenta, ne glede na to, koliko glav objektov je bilo najdenih
Podvojene številke objektov so zanimiv vmesni primer. Prirastno posodobljena datoteka legitimno vsebuje več generacij iste številke objekta, preživela veriga navzkrižnih referenc pa je edini zapis, katera je bila trenutna. Obnova te verige nima, zato zabeleži vsako glavo, ki jo vidi, v vrstnem redu datoteke, nato pa razreši po številki objekta. Ponavadi zmaga poznejša revizija, kar je ponavadi pravilno, vendar se lahko dokument, ki je bil posodobljen in nato delno vrnjen nazaj, po obnovi rahlo razlikuje od tistega, kar je opisoval izvirni xref. Linearizirane datoteke nosijo isto opozorilo z druge strani: postavitev prve strani in namigovalne tabele izgubijo pomen, ko je indeks regeneriran, zato bi bilo treba popravljeno datoteko obravnavati kot navaden, nelineariziran dokument
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;
Nadomestna pot je samodejna: PDFlibPas surovo skeniranje zažene, kadarkoli verige navzkrižnih referenc ni mogoče prebrati, in tudi kadar vsak vnos v uporabi trdi odmik nič, kar je podpis tabele, ki je bila zapisana, a nikoli izpolnjena. GetDocumentRepaired vrne 1, kadar je ta pot tekla, vredno pa jo je beležiti, ne pa ignorirati, ker bi bilo treba dokument, ki se je naložil z obnovo, ponovno shraniti v čisto datoteko, ne pa ga pustiti v cevovodu, kot da se ni nič zgodilo. Njegovo shranjevanje zapiše svežo, skladno tabelo navzkrižnih referenc, kar je najcenejši mogoč popravek za vsakega odjemalca naprej po verigi
Pot obnove, zastavica GetDocumentRepaired in tukaj prikazani pretočni nalagalnik so del PDFlibPas Delphi PDF Library, skupaj z API-ji za razčlenjevanje, upodabljanje in podpisovanje, obravnavanimi drugje na tem blogu