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. PDFlibPas 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

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 +   // 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;

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. PDFlibPas 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

PDFlibPas 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

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. PDFlibPas 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ą

Tikros trailer žodyno pabaigos radimas

Objektų atkūrimas yra tik pusė darbo, nes įkrovikliui vis tiek reikia trailer, kad rastų /Root. PDFlibPas 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

// 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

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');   // writes a clean xref
    end;
  finally
    Pdf.Free;
  end;
end;

Atsarginis kelias yra automatinis: PDFlibPas 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 PDFlibPas Delphi PDF Library dalis, kartu su nagrinėjimo, atvaizdavimo ir pasirašymo API, aprašytais kitur šiame tinklaraštyje