Tekninen artikkeli

Vahingoittuneiden PDF-xref-taulukoiden uudelleenrakennus Delphissä

Kun PDF:n ristiviittaustaulukko on käyttökelvoton, ratkaisu on ohittaa se kokonaan ja rakentaa se uudelleen tiedoston rungosta. PDF Library for Delphi Delphi PDF Library tekee tämän yhden läpikäynnin token-skannerilla, joka tallentaa jokaisen aidon epäsuoran objektin otsikon, jonka se näkee, palauttaa sitten trailer-sanakirjan ja antaa uudelleenrakennetun taulukon tavalliselle lataajalle

PDF Library for Delphi -kirjaston korjausputki, kun ristiviittaustaulu on vaurioitunut: lataaja turvautuu yksivaiheiseen palautusskannaukseen ja puhdas uudelleentallennus tuottaa tuoreen xref-taulun
Hajosi indeksi, eivät tavut: varaskanaus rakentaa objektien offsetit ja käyttökelpoisen trailerin uudelleen, lataaja jatkaa normaalisti, ja uudelleentallennus tuottaa puhtaan xref:n

Mikä hajoaa ensimmäisenä, kun PDF vahingoittuu

Ristiviittaustaulukko on PDF:n hauraimmin osa, koska se on ainoa osa, joka tallentaa absoluuttisia tavusiirtymiä. ISO 32000-1 §7.5.4 määrittelee nämä merkinnät kymmennumeroisiksi siirtymiksi tiedoston alusta, ja §7.5.5 sijoittaa startxref-avainsanan lähelle loppua osoittamaan itse taulukkoon. Jokainen näistä luvuista mitätöityy minkä tahansa muokkauksen myötä, joka siirtää tavuja. FTP-istunto, joka ajettiin tekstitilassa ja käänsi CRLF:n, katkennut lataus, jaetulla asemalla vioittunut sektori, eräajotyökalu, joka liitti sisältöä kirjoittamatta asteittaista päivitystä oikein: kaikki nämä jättävät objektidatan täysin luettavaksi ja indeksin osoittamaan roskaan

Siksi "tiedosto on vahingoittunut ja sitä korjataan" on niin yleinen valintaikkuna. Tavut ovat lähes aina yhä paikallaan. Se, mikä on kadonnut, on kartta. Uudelleenrakennus ei siis ole kadonneen datan rikostekninen palautus, se on indeksin uudelleenrakennus, joka voidaan johtaa rungosta, ja se onnistuu paljon useammin kuin käyttäjät odottavat, koska kallis sisältö, sivupuut, fontit ja kuvat, on koskematon

Miksi N 0 obj -haku löytää vääriä osumia?

Naiivi uudelleenrakennus etsii raakoista tavuista kaavaa "kokonaisluku, kokonaisluku, obj" ja tallentaa jokaisen osuman. Se löytää liikaa. PDF on säiliömuoto, ja kolme tiedoston aluetta ovat läpinäkymättömiä objektikieliopille: kommentit (§7.2), merkkijonot (§7.3.4) ja virtadata (§7.3.8). Mikä tahansa niistä voi sisältää tavuja, jotka luetaan täsmälleen kuin objektin otsikko, eikä mikään niistä ole objektin otsikko. Kuvateksti kirjaimellisessa merkkijonossa, jäljelle jäänyt debug-kommentti tai kaksi megatavua Flate- tai DCT-tulostetta tuottavat kaikki mielellään jotain, joka näyttää kuin 99 0 obj

const
  Trap: AnsiString =
    '4 0 obj'#10 +
    '(a caption that mentions 88 0 obj)'#10 +   // kirjaimellinen merkkijono, ei objekti
    'endobj'#10 +
    '% 77 0 obj left over from a debug dump'#10 +  // kommentti, ei objekti
    '5 0 obj'#10 +
    '<< /Length 2097152 >>'#10 +
    'stream'#10 +
    { kaksi MiB:n pakattuja tavuja, jotka sisältävät tavujonon
      99 0 obj ja myöhemmin täydellisen endstreamin }
    'endstream'#10 +
    'endobj'#10;

Jokainen väärä merkintä maksaa kahdesti. Se saastuttaa uudelleenrakennetun taulukon objektinumerolla, jota ei ole olemassa, ja se voi peittää saman numeron todellisen objektin, joka esiintyy myöhemmin tiedostossa. PDF Library for Delphi ei siis kaavantunnista lainkaan. Se tokenisoi, mikä tarkoittaa, että se tietää aina, ovatko kohdistimen alla olevat tavut koodia vai hyötykuormaa, ja hyötykuorma ohitetaan sitä koskaan tulkitsematta

Yhden läpikäynnin tilakone 64 KiB:n lohkoissa

PDF Library for Delphi skannaa koko tiedoston täsmälleen kerran, 64 KiB:n lohkoissa, tilakoneella, joka on rakennettu ISO 32000-1 §7.2:n token-sääntöjen ja §7.3.10:n epäsuoran objektin syntaksin varaan. Token päättyy välilyöntiin tai johonkin erotinmerkkiin, ja objektin otsikko tallennetaan vain, kun täydellinen sekvenssi positiivisesta objektinumerosta, ei-negatiivisesta sukupolvinumerosta ja paljaasta obj-avainsanasta on nähty. Tallennettu siirtymä on objektinumerotokenin alku, mihin ristiviittausmerkinnän täytyy osoittaa, ei obj-avainsanan sijaintiin

Tavukartta, joka osoittaa miten naiivi objektiotsikoiden haku löytää kolme haamuosumaa merkkijonon, kommentin ja virtadatan sisältä, kun taas PDF Library for Delphi -tokeniskanneri kirjaa vain aitot otsikot
Merkkijonot, kommentit ja streamin tavut voivat jäljitellä objektien otsikoita täydellisesti; siksi skanneri tokenisoi kaavahakemisen sijaan ja kirjaa ylös vain kokonaisia N G obj -sarjoja
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;

Tärkeä yksityiskohta on se, että token-tila ja merkkijonotila säilyvät lohkorajan yli. Otsikko, joka hajaantuu 65536-tavuisen rajan yli, tunnistetaan silti, koska osittainen token, odottava kokonaislukupari ja merkkijonossa-liput kaikki kulkeutuvat seuraavaan lohkoon. Puskurit ovat kiinteitä: 64 KiB skannaukselle, 32 tavua pisimmälle tokenille, jolla voi ylipäätään olla merkitystä, ja ainoat taulukot, jotka kasvavat tiedoston mukana, ovat objektinumero-, sukupolvinumero- ja 64-bittiset siirtymälistat, jotka ovat suhteessa todelliseen objektimäärään eivätkä tiedoston kokoon. Käytännössä skannaus tekee peräkkäisiä lukuja ja korkeintaan kaksi selkeää siirtymähakua koko dokumentin yli, mikä tekee siitä käyttökelpoisen artikkelissa suorasta pääsystä yhdistämiseen ja jakamiseen käsitellyillä satojen megatavujen syötteillä

Miksi virtaan ei voi luottaa, että se päättyy endstream-avainsanaan?

Koska virtadata on mielivaltaisia tavuja, ja mielivaltaiset tavut voivat vahingossa tavata sanan endstream. Virta, joka alkaa stream-avainsanan jälkeen, täytyy ohittaa läpinäkymättömänä datana, kunnes se aidosti päättyy, mutta sulkevan avainsanan ensimmäinen esiintymä on vain ehdokas. PDF Library for Delphi ratkaisee tämän vaatimalla vahvistuksen: endstream-token hyväksytään virran todelliseksi lopuksi vain, kun seuraava ei-välilyönti-token on itsenäinen endobj, sekvenssi, jonka §7.3.8 vaatii virtaobjektin ympärille. Sattumanvarainen osuma pakatun datan sisällä ei lähes koskaan saa tuota jatkoa, joten skanneri pysyy virran sisällä ja jatkaa. Kaksi pienempää sääntöä ovat yhtä tärkeitä. stream-avainsana siirtää virtatilaan vain, kun se on paljas avainsana, joten nimiobjekti kuten /stream sanakirjassa ei koskaan laukaise sitä. Ja obj- tai trailer-token huomioidaan vain, kun token ei ylittänyt 32 tavun rajaa eikä alkanut kauttaviivalla. Ilman näitä kahta vartijaa resurssisanakirja väärillä avainnimillä riittäisi suistamaan skannauksen raiteiltaan, mikä on täsmälleen sitä vihamielisen syötteen luokkaa, joka käsitellään muistiossa epäluotettavien PDF-tiedostojen turvallisesta jäsentämisestä

Päätössilmukka, joka vahvistaa PDF-virran todellisen lopun xref-palautuksen aikana Delphissä: endstream-tokeni hyväksytään vain, jos erillinen endobj seuraa
endstream-ehdokas tarvitsee vahvistuksen sitä seuraavalta yksittäiseltä endobj:ilta; paljas avainsana -, 32-tavun ja kauttaviivan suojat estävät viritettyjä sanakirjoja ohjaamasta skannausta raiteiltaan

Trailer-sanakirjan todellisen lopun löytäminen

Objektien palauttaminen on vain puolet työstä, koska lataaja tarvitsee yhä trailerin löytääkseen /Root-avaimen. PDF Library for Delphi muistaa skannauksen aikana löydetyt 64 viimeisintä trailer-avainsanan sijaintia ja validoi ne taaksepäin, tuorein ensin, joten uusin käyttökelpoinen trailer voittaa, ja irrallinen avainsana, jota ei seuraa sanakirja, epäonnistuu yksinkertaisesti validoinnissa ja siirtyy edelliseen ehdokkaaseen. Jokainen ehdokas luetaan 1 MiB:n rajalla, ja sanakirjan loppu paikannetaan seuraamalla sisäkkäisiä <<- ja >>-syvyyksiä yhdessä kirjaimellisten merkkijonojen escape-merkkien, heksadesimaalisten merkkijonojen ja kommenttien kanssa

// Naiivi lukija, joka pysähtyy ensimmäiseen '>>', typistää tämän trailerin,
// ja kiinteä 2048-tavun ikkuna voi katkaista sen puoliväliin suuressa tiedostossa
'trailer'#10 +
'<< /Size 5 /Root 1 0 R' +
'   /Custom << /Text (value >> preserved) >> >>'#10

Syvyyden seuranta ei ole akateemista. Katkennut trailer, joka menettää /Encrypt-avaimen, muuttaa palautettavissa olevan salatun dokumentin avautumattomaksi, ja /Info- tai mukautetun alisanakirjan menettäminen hylkää hiljaa metatietoa, josta jokin alavirran järjestelmä saattaa olla riippuvainen. Jos tiedosto on salattu, palautettu trailer on se, mikä sallii tavallisen tunnistetietopolun ajaa, ja uudelleenyrityssemantiikka on sama, joka kuvataan artikkelissa salatun dokumentin lataamisesta

Mitä uudelleenrakennus ei voi antaa takaisin

Uudelleenrakennus on parhaan yrityksen periaate, ja sen rajoista rehellinen oleminen on osa sen julkaisua. Kolme tapausta epäonnistuu suoraan. Objektivirtojen sisään pakatut objektit (§7.5.7) eivät ole yksitellen näkyviä tavuskannaukselle, joten jos säiliö säilyy mutta sen ristiviittausvirta (§7.5.8) ei, säiliön sisältämiä objekteja ei indeksoida uudelleenrakennuksessa. Tiedosto, jonka runko todella vahingoittui pelkän väärän indeksoinnin sijaan, tuottaa otsikoita, joiden sisältö ei enää jäsenny. Ja tiedostolla, jossa ei ole palautettavissa olevaa trailer-avainsanaa eikä luettavaa katalogia, ei ole mitään, mihin ankkuroida dokumenttipuu, riippumatta siitä, kuinka monta objektin otsikkoa löydettiin

Kaksoiskappaleina esiintyvät objektinumerot ovat kiinnostava välitapaus. Asteittain päivitetty tiedosto sisältää oikeutetusti saman objektinumeron useita sukupolvia, ja säilynyt ristiviittausketju on ainoa tallenne siitä, mikä niistä oli ajantasainen. Uudelleenrakennuksella ei ole tuota ketjua, joten se tallentaa jokaisen näkemänsä otsikon tiedostojärjestyksessä ja ratkaisee sitten objektinumeron mukaan. Yleensä myöhempi versio voittaa, mikä on yleensä oikein, mutta dokumentti, joka päivitettiin ja sitten osittain peruutettiin, voi palata hienovaraisesti erilaisena kuin alkuperäinen xref kuvasi. Linearisoidut tiedostot kantavat saman varauksen toisesta suunnasta: ensimmäisen sivun asettelu ja vihjetaulukot ovat merkityksettömiä heti, kun indeksi on regeneroitu, joten korjattua tiedostoa tulisi käsitellä tavallisena, ei-linearisoituna dokumenttina

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');   // kirjoittaa puhtaan xref-taulukon
    end;
  finally
    Pdf.Free;
  end;
end;

Varajärjestelmä on automaattinen: PDF Library for Delphi ajaa raa'an skannauksen aina, kun ristiviittausketjua ei voida lukea, ja myös silloin, kun jokainen käytössä oleva merkintä väittää siirtymän olevan nolla, mikä on merkki taulukosta, joka kirjoitettiin mutta jota ei koskaan täytetty. GetDocumentRepaired palauttaa arvon 1, kun tuo polku ajettiin, ja se kannattaa kirjata sen sijaan, että se ohitettaisiin, koska dokumentti, joka latautui uudelleenrakennuksen kautta, tulisi tallentaa uudelleen puhtaaseen tiedostoon sen sijaan, että se jätettäisiin putkeen kuin mitään ei olisi tapahtunut. Sen tallentaminen kirjoittaa tuoreen, johdonmukaisen ristiviittaustaulukon, mikä on halvin mahdollinen korjaus jokaiselle alavirran kuluttajalle

Tässä esitetty uudelleenrakennuspolku, GetDocumentRepaired-lippu ja suoratoistava lataaja ovat osa PDF Library for Delphi Delphi PDF Library -kirjastoa, yhdessä jäsennys-, renderöinti- ja allekirjoitus-API:en kanssa, joita käsitellään muualla tässä blogissa