Techninis straipsnis

Sugadintų PDF xref lentelių atkūrimas Delphi aplinkoje

Kai PDF kryžminės nuorodos lentelė yra nenaudojama, sprendimas yra ją visiškai ignoruoti ir atkurti iš failo turinio. PDF Library for Delphi Delphi PDF Library tai daro vienu perėjimu žetonų skaitytuvu, kuris įrašo kiekvieną tikrą netiesioginio objekto antraštę, kurią mato, tada atkuria trailer žodyną ir perduoda atkurtą lentelę įprastam įkroviklio

PDF Library for Delphi taisymo grandinė, kai kryžminių nuorodų lentelė pažeista: įkėliklis pasuka į vieno praėjimo atkūrimo skenavimą, o švarus pakartotinis išsaugojimas pagamina naują xref
Sugedo indeksas, o ne baitai: atsarginė paieška atkuria objektų poslinkius ir tinkamą trailer, įkėliklis tęsia įprastai, o pakartotinis įrašymas išduoda švarų xref

Kas lūžta pirmiausia, kai PDF sugadintas?

Kryžminės nuorodos lentelė yra trapiausia PDF dalis, nes tai vienintelė dalis, sauganti absoliučius baitų poslinkius. ISO 32000-1 §7.5.4 apibrėžia tuos įrašus kaip dešimties skaitmenų poslinkius nuo failo pradžios, o §7.5.5 padeda startxref raktažodį netoli pabaigos, rodantį į pačią lentelę. Kiekvienas iš tų skaičių tampa negaliojantis dėl bet kokio redagavimo, kuris pastumia baitus. FTP sesija, veikusi teksto režimu ir transliavusi CRLF, nutrūkęs atsisiuntimas, blogas sektorius bendroje disko dalyje, paketinis įrankis, pridėjęs duomenis be teisingo laipsniško atnaujinimo užrašymo: visi jie palieka objektų duomenis puikiai skaitomus, o indeksą rodantį į šiukšles

Štai kodėl "failas sugadintas ir taisomas" yra toks dažnas dialogas. Baitai beveik visada vis dar ten. Kas dingsta, tai žemėlapis. Atkūrimas todėl nėra prarastų duomenų teismo ekspertizė, tai indekso, kurį galima išvesti iš turinio, atkūrimas, ir jis pavyksta žymiai dažniau, nei vartotojai tikisi, nes brangus turinys, puslapių medžiai bei šriftai ir vaizdai, lieka nepaliesti

Kodėl paieška N 0 obj randa klaidingus atitikmenis?

Naivus atkūrimas ieško žalio baitų šablono "sveikasis, sveikasis, obj" ir įrašo kiekvieną atitikimą. Jis randa per daug. PDF yra konteinerio formatas, o trys failo sritys yra nepermatomos objektų gramatikai: komentarai (§7.2), eilutės (§7.3.4) ir srauto duomenys (§7.3.8). Bet kuri iš jų gali turėti baitus, skaitomus lygiai kaip objekto antraštė, ir nė viena iš jų nėra objekto antraštė. Antraštė literalioje eilutėje, likęs derinimo komentaras arba du megabaitai Flate ar DCT išvesties mielai sukurs kažką, kas atrodo kaip 99 0 obj

const
  Trap: AnsiString =
    '4 0 obj'#10 +
    '(a caption that mentions 88 0 obj)'#10 +   // teksto eilutė, ne objektas
    'endobj'#10 +
    '% 77 0 obj left over from a debug dump'#10 +  // komentaras, ne objektas
    '5 0 obj'#10 +
    '<< /Length 2097152 >>'#10 +
    'stream'#10 +
    { du MiB suspaustų baitų, kuriuose yra baitų seka
      99 0 obj, o toliau ir visas endstream }
    'endstream'#10 +
    'endobj'#10;

Kiekvienas klaidingas įrašas kainuoja dvigubai. Jis užteršia atkurtą lentelę neegzistuojančiu objekto numeriu ir gali užgožti tikrą objektą su tuo pačiu numeriu, pasirodantį vėliau faile. PDF Library for Delphi todėl visai nesinaudoja šablonų atitikimu. Jis tokenizuoja, o tai reiškia, kad visada žino, ar baitai po žymekliu yra kodas, ar duomenys, o duomenys praleidžiami niekada jų neinterpretuojant

Vienas perėjimas būsenų mašina per 64 KiB blokus

PDF Library for Delphi nuskaito visą failą lygiai vieną kartą, 64 KiB blokais, su būsenų mašina, sukurta ant ISO 32000-1 §7.2 žetonų taisyklių ir §7.3.10 netiesioginio objekto sintaksės. Žetonas baigiasi tarpo simboliu arba vienu iš skiriamųjų simbolių, o objekto antraštė įrašoma tik tada, kai matoma pilna seka: teigiamas objekto numeris, neneigiamas kartos numeris ir grynas obj raktažodis. Įrašytas poslinkis yra objekto numerio žetono pradžia, į ką ir turi rodyti kryžminės nuorodos įrašas, ne obj raktažodžio pozicija

Baitų žemėlapis, rodantis, kaip primityvi objektų antraščių paieška randa tris vaideniškus atitikmenis eilutėje, komentare ir srauto duomenyse, o PDF Library for Delphi leksemų skeneris užregistruoja tik tikras antraštes
Eilutės, komentarai ir srauto baitai gali tobulai imituoti objektų antraštes; todėl skenuotojas žetonuoja, o ne ieško pagal šabloną, užregistruodamas tik pilnąsias N G obj sekas
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;

Svarbi smulkmena yra ta, kad žetono būsena ir eilutės būsena išgyvena bloko ribą. Antraštė, kertanti 65536 baitų liniją, vis dar atpažįstama, nes dalinis žetonas, laukianti sveikųjų skaičių pora ir eilutės viduje esančios vėliavėlės visos pernešamos į kitą bloką. Buferiai fiksuoti: 64 KiB skenavimui, 32 baitai ilgiausiam galimam žetonui, o vieninteliai masyvai, augantys kartu su failu, yra objekto numerio, kartos numerio ir 64 bitų poslinkio sąrašai, proporcingi realiam objektų skaičiui, o ne failo dydžiui. Praktikoje skenavimas atlieka nuoseklius skaitymus ir daugiausiai du aiškius peršokimus per visą dokumentą, ir būtent tai leidžia jam veikti su daugiašimtų megabaitų įvestimis, aptartomis straipsnyje apie tiesioginės prieigos sujungimą ir skaidymą

Kodėl srautu negalima pasitikėti, kad jis baigsis prie endstream?

Todėl, kad srauto duomenys yra savavaliai baitai, o savavaliai baitai gali atsitiktinai sudaryti endstream. Srautas, prasidedantis po stream raktažodžio, turi būti praleistas kaip nepermatomi duomenys, kol jis iš tikrųjų nesibaigia, tačiau pirmasis uždarančio raktažodžio pasirodymas yra tik kandidatas. PDF Library for Delphi tai sprendžia reikalaudamas patvirtinimo: endstream žetonas priimamas kaip tikra srauto pabaiga tik tada, kai kitas žetonas be tarpų yra savarankiškas endobj, seka, kurios §7.3.8 reikalauja aplink srauto objektą. Atsitiktinis atitikimas suspaustuose duomenyse beveik niekada neturi tokio tęsinio, todėl skaitytuvas lieka srauto viduje ir tęsia toliau. Dvi mažesnės taisyklės yra svarbios lygiai taip pat. stream raktažodis įjungia srauto būseną tik tada, kai jis yra grynas raktažodis, todėl vardo objektas, pvz., /stream žodyne, jos niekada nesukelia. O obj arba trailer žetonas gerbiamas tik tada, kai žetonas neperviršijo 32 baitų ribos ir neprasidėjo solidusu. Be šių dviejų apsaugų resursų žodynas su neteisingais rakto vardais pakaktų nukreipti skenavimą, o tai būtent tokia priešiškos įvesties klasė, aptarta pastabose apie saugų nepatikimų PDF failų nagrinėjimą

Sprendimų ciklas, patvirtinantis tikrą PDF srauto pabaigą xref atkūrimo metu Delphi: endstream leksemą priimama tik kai po jos seka savarankiškas endobj
endstream kandidatui reikia patvirtinimo iš sekančio atskiro endobj; pliko raktažodžio, 32 baitų ir pasvirojo brūkšnio apsaugos neleidžia suklastoti žodynams nukreipti skenavimo

Tikros trailer žodyno pabaigos radimas

Objektų atkūrimas yra tik pusė darbo, nes įkrovikliui vis tiek reikia trailer, kad rastų /Root. PDF Library for Delphi atsimena paskutines 64 trailer raktažodžio pozicijas, rastas skenavimo metu, ir jas tikrina atgaline tvarka, pradedant naujausia, todėl naujausias tinkamas trailer laimi, o paklydęs raktažodis, po kurio neseka žodynas, tiesiog netenka patikros galiojimo ir pereina prie ankstesnio kandidato. Kiekvienas kandidatas skaitomas su 1 MiB riba, o žodyno pabaiga randama sekant įdėtų << ir >> gylį kartu su literalios eilutės pabėgimo simboliais, šešioliktainiais eilutėmis ir komentarais

// Naivus skaitytuvas, sustojantis ties pirmuoju '>>', sutrumpina šį trailerį,
// o fiksuoto 2048 baitų lango dydis gali jį perpjauti per pusę, jei jis didelis
'trailer'#10 +
'<< /Size 5 /Root 1 0 R' +
'   /Custom << /Text (value >> preserved) >> >>'#10

Gylio sekimas nėra akademinis. Sutrumpintas trailer, praradęs /Encrypt, paverčia atkuriamą šifruotą dokumentą neatidaromu, o /Info ar pasirinktinio požodyno praradimas tyliai atmeta metaduomenis, nuo kurių gali priklausyti žemesnė sistema. Jei failas šifruotas, atkurtas trailer yra tai, kas leidžia veikti normaliam kredencialų keliui, o pakartotinio bandymo semantika ta pati, kaip aprašyta straipsnyje apie šifruoto dokumento įkėlimą

Ko atkūrimas negali grąžinti

Atkūrimas yra geriausios pastangos priemonė, ir sąžiningas jo ribų pripažinimas yra jo pristatymo dalis. Trys atvejai visiškai nepavyksta. Objektai, supakuoti objektų srautuose (§7.5.7), nėra individualiai matomi baitų skenavimui, todėl jei konteineris išlieka, bet jo kryžminės nuorodos srautas (§7.5.8) neišlieka, jo turimi objektai nėra indeksuojami atkuriant. Failas, kurio turinys iš tikrųjų buvo sugadintas, o ne tik neteisingai indeksuotas, sukurs antraštes, kurių turinys nebesiskaito. O failas be atkuriamo trailer raktažodžio ir be skaitomo katalogo neturi prie ko pritvirtinti dokumento medžio, nepriklausomai nuo to, kiek objektų antraščių buvo rasta

Pasikartojantys objektų numeriai yra įdomus vidurinis atvejis. Laipsniškai atnaujintas failas teisėtai turi kelias to paties objekto numerio kartas, o išlikusi kryžminės nuorodos grandinė yra vienintelis įrašas apie tai, kuri buvo aktuali. Atkūrimas neturi tos grandinės, todėl jis įrašo kiekvieną matomą antraštę failo tvarka ir vėliau išsprendžia pagal objekto numerį. Paprastai laimi vėlesnė versija, kas paprastai teisinga, tačiau dokumentas, kuris buvo atnaujintas, o tada iš dalies grąžintas atgal, gali sugrįžti subtiliai kitoks, nei aprašė originalus xref. Linijinizuoti failai turi tą pačią išlygą iš priešingos pusės: pirmojo puslapio išdėstymas ir užuominų lentelės tampa beprasmiai, kai indeksas atkuriamas iš naujo, todėl sutaisytas failas turėtų būti traktuojamas kaip paprastas, nelinijinizuotas dokumentas

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');   // įrašo švarią xref lentelę
    end;
  finally
    Pdf.Free;
  end;
end;

Atsarginis kelias yra automatinis: PDF Library for Delphi paleidžia žalią skenavimą, kai tik kryžminės nuorodos grandinė negali būti perskaityta, o taip pat kai kiekvienas naudojamas įrašas tvirtina poslinkį nulis, kas yra lentelės, kuri buvo parašyta, bet niekada neužpildyta, požymis. GetDocumentRepaired grąžina 1, kai tas kelias suveikė, ir tai verta registruoti, o ne ignoruoti, nes dokumentas, kuris įsikėlė per atkūrimą, turėtų būti iš naujo išsaugotas švariame faile, o ne paliktas eigoje taip, lyg nieko nebūtų atsitikę. Jo išsaugojimas parašo šviežią, nuoseklią kryžminės nuorodos lentelę, kas yra pigiausias įmanomas taisymas kiekvienam žemesnės grandies vartotojui

Atkūrimo kelias, GetDocumentRepaired vėliavėlė ir čia parodytas srautinis įkroviklis yra PDF Library for Delphi Delphi PDF Library dalis, kartu su nagrinėjimo, atvaizdavimo ir pasirašymo API, aprašytais kitur šiame tinklaraštyje