Tekninen artikkeli

Harva laiska PDF-objekti-indeksi Delphissä PDFiumPasilla

Haluat yhden sanakirjan 2 GB:n PDF:stä, ja työkalu laajentaa ensin koko ristiviittaustaulukon taulukoksi, jonka koko määräytyy trailerin /Size-arvon mukaan. PDFiumPas korvaa kyseisen vaiheen harvalla laiskalla objekti-indeksillä: se säilyttää vain xref-osioiden deskriptorit, selvittää yhden objektinumeron tarvittaessa rajattujen ikkunoiden kautta ja välimuistittaa vain ne merkinnät, joita oikeasti kosketit

Tämän koodin vanha muoto moduulissa FPdfCompress oli rehellinen mutta kallis. ApplyDefaultOpenAction luki koko tiedoston yhteen TBytes-muuttujaan ja varasi sitten tiheän TPdfActiveXrefEntries-taulukon, jossa oli yksi paikka jokaista objektinumeroa kohden arvoon /Size asti. Kaksi asiaa meni pieleen suuressa mittakaavassa. Lukukustannus kasvoi lineaarisesti asiakirjan koon mukaan, vaikka kutsuja halusi vain neljä sanakirjaa, ja tiheä taulukko törmäsi jäsennysbudjettiin: TPdfParserResourceBudget.Default asettaa MaxObjects-arvoksi 4 000 000, joten täysin kelvollinen tiedosto, jonka korkein objektinumero sijaitsi kyseisen katton yläpuolella, hylättiin muistoperusteella eikä oikeellisuusperusteella

PDFiumPasin harva laiska objekti-indeksi Delphissä verrattuna tiheään ristiviitaustaulukkoon: tiheä polku lukee koko tiedoston ja varaa yhden paikan jokaista objektinumeroa kohden trailerin kokoon asti, kun taas harva polku säilyttää vain osioiden deskriptorit
Vain deskriptorit pysyvät muistissa, merkinnät pysyvät tiedostossa, ja jokainen luku kulkee rajatun yhden mebitavun ikkunan kautta

Miksi PDFiumin julkinen API ei vastaa tähän kysymykseen?

Koska tieto on olemassa PDFiumin sisällä, mutta se ei koskaan ylitä C-rajapintaa. CPDF_Parser ylläpitää sisäisesti ristiviittaustaulukkoa, objektivirtajäsenyyttä ja revisioiden etusijajärjestystä, mutta julkaistut otsikkotiedostot eivät näytä yhtään sisääntulopistettä, joka ottaisi objektinumeron ja palauttaisi sen raakasiirtymän, sen sukupolven, sen, mikä revisio voitti, tai sen, missä objektivirrassa (ObjStm) se asuu. Tallennuspuoli on yhtä suljettu: FPDF_SaveAsCopy ja FPDF_SaveWithVersion antavat sinulle vain peräkkäisen kirjoitustakaisinkutsun. Mikä tahansa tavutason paikkaus katalogiin natiivin tallennuksen jälkeen on siksi rakennettava Pascal-kerrokseen, minkä vuoksi PDFiumPas jäsentää nämä rakenteet itse sen sijaan että käyttäisi DLL:ää uudelleen

Mitä harva indeksi oikeasti pitää muistissa?

Deskriptorit, eivät merkintöjä. Klassiselle taulukolle (ISO 32000-1 §7.5.4) TPdfSparseXrefSubsection tallentaa ensimmäisen objektinumeron, objektilukumäärän, tavusiirtymän, josta merkintärivit alkavat, ja mitatun merkintäleveyden. Merkinnät itse pysyvät tiedostossa. Leveys mitataan ensimmäisestä rivistä sen sijaan että oletettaisiin 20 tavuksi, koska tuottajat ovat eri mieltä rivinvaihdoista; PDFiumPas hyväksyy arvot 18–64 ja hylkää kaiken kyseisen välin ulkopuolelta sekä jokaisen alaosion, jonka ilmoitettu lukumäärä kuluisi virran lopun ohi. Ristiviirtavirralle (§7.5.8) osio pitää sisällään kolme /W-kenttäleveyttä, kukin rajoitettuna välille 0–8, litistetyt /Index-parit ja puretut merkintätavut, joiden odotettu pituus lasketaan arvoista /W ja /Index ennen kuin yhtään tavua puretaan

Koko indeksi rakennetaan funktiolla Initialize enintään 1 MiB:n häntäikkunasta, josta startxref löytyy, ja jokainen sitä seuraava objektiluku käyttää 1 MiB:n objektiikkunaa. Raakavirran katto on 64 MiB, ja yksittäinen xref-rivi saa olla enintään 1024 tavua. Jos olet lukenut muistiinpanomme objekti- ja ristiviirtavirtojen vahvistamisesta PDFiumPasilla, sama kenttäleveyskurin pätee täällä, vain sitä käytetään nyt yhden merkinnän osoittamiseen kokonaisen taulukon auditoinnin sijaan

uses
  FPdfCompress;

var
  Source: TFileStream;
  Revision: TPdfSparseRevisionInfo;
begin
  Source := TFileStream.Create(FileName, fmOpenRead or fmShareDenyWrite);
  try
    { kulkee vain startxrefin, /Prev-ketjun ja katalogin läpi }
    if ReadPdfSparseRevisionInfo(Source, Revision) then
    begin
      Writeln('root      ', Revision.RootObjectNumber, ' ',
        Revision.RootGeneration);
      Writeln('max obj   ', Revision.MaximumObjectNumber);
      Writeln('xref str  ', Revision.UsesXrefStream);
      Writeln('encrypted ', Revision.HasEncrypt);
      Writeln(string(Revision.CatalogDictionary));
    end;
  finally
    Source.Free;
  end;
end;

Miten yksi haku yltää yhteen objektiin?

Aritmetiikalla, kummassakin asettelussa. Klassisen alaosion rivit ovat kiinteäleveitä, joten merkinnän osoite on alaosion alku plus objektin siirtymä kertaa mitattu leveys; PDFiumPas lukee sitten sen yhden rivin, jäsentää kymmennumeroisen siirtymän ja viisinumeroisen sukupolven, tarkistaa sukupolven standardin §7.5.4:n 65535-kattoa vasten ja luokittelee lopussa olevan avainsanan arvoksi axkDirect tai axkFree. Ristiviirtavirta tarvitsee yhden vaiheen lisää, koska /Index-alaosiot on liitetty yhteen puretussa tavujonossa, joten indeksi kerryttää edellisten alaosioden lukumäärät ennen kertolaskua summalla /W-leveydellä. Tyyppi 1 tuottaa siirtymän, tyyppi 2 tuottaa objektivirran numeron ja jäsenindeksin, ja mikä tahansa muu muuttuu arvoksi axkUnknown arvauksen sijaan

{ klassinen taulukko, ISO 32000-1 osio 7.5.4 }
EntryOffset := Subsection.EntryOffset +
  Int64(ObjectNumber - Subsection.FirstObject) * Subsection.EntryWidth;

{ ristiviirtavirta, ISO 32000-1 osio 7.5.8 }
EntryWidth := Section.Widths[0] + Section.Widths[1] + Section.Widths[2];
EntryPosition := Integer((PriorCount + ObjectNumber -
  Section.IndexValues[I]) * EntryWidth);

Mikään kummassakaan polussa ei ole verrannollinen arvoon /Size. Siinä on uudelleenkirjoituksen koko pointti: trailerin kokuarvo kuljetetaan eteenpäin metatietona ja sitä käytetään kirjoitettaessa inkrementaalinen revisio, mutta se ei koskaan ohjaa allokointia. Regressiotestisarja lukitsee tämän testifixturella, jonka sivupuu sijaitsee objekteissa 1 000 000 000 ja 1 000 000 001 trailerin alla, joka ilmoittaa arvon /Size 1000000002. Vanha tiheä toteutus hylkäsi kyseisen tiedoston; harva indeksi selvittää molemmat viittaukset ja säilyttää ilmoitetun koon tulostetrailerissa

Miten PDFiumPas selvittää yhden objektinumeron Delphissä: klassinen ristiviittaustaulukko kertoo mitatulla rivileveydellä, kun taas ristiviirtavirta kerryttää edellisten alaosioden lukumäärät ennen kertolaskua /W-taulukon summatuilla kenttäleveyksillä
Molemmat haut ovat puhdasta aritmetiikkaa, joten kumpikaan ei ole verrannollinen trailerissa ilmoitettuun objektilukumäärään

Hybridirevisiot, /Prev-ketjut ja niiden ympärillä olevat vartijat

Revisioiden etusijajärjestys on kohta, jossa naiivi laiska indeksi menee pieleen. PDFiumPas kulkee ketjun läpi startxrefistä uusin ensin -järjestyksessä ja pysäyttää haun ensimmäiseen osioon, joka vastaa, mikä toistaa etusijasäännön ilman yhdistetyn taulukon materialisointia. Hybridiviittaukselliset tiedostot (§7.5.8.4) käsitellään klassisen haaran sisällä: kun traileri kantaa merkintää /XRefStm, täydentävä virtaosio rekisteröidään ennen klassista osiota, joka siihen viittasi, joten tavallista taulukkoa näkymättömät pakatut objektit löytyvät yhä, kun taas klassiset merkinnät säilyttävät asemansa. Vanhempia revisioita seurataan sitten merkinnän /Prev kautta

Kaksi vartijaa rajaa kyseisen kulun, ja molemmat merkitsevät vaurioituneissa tiedostoissa. Jokainen vierailtu siirtymä kirjataan muistiin, joten ketjuun takaisin osoittava /Prev päättyy pyörimisen sijaan, ja läpikäynnin syvyys rajataan arvolla MaxRecursionDepth, joka on oletuksena 1024. Salauslippu kumuloidaan koko ketjun yli sen sijaan että luettaisiin pelkästään uusimmasta trailerista, koska asiakirja, jonka uusin traileri jättää merkinnän /Encrypt pois, voi silti olla salattu ketjun aiemmassa kohdassa; revisioita liittävät kutsujat nojaavat kyseiseen lippuun kieltäytyäkseen kirjoittamasta selkotekstiobjekteja salattuun tiedostoon

Miten PDFiumPas kulkee hybridin PDF-revisioketjun läpi Delphissä: osiot rekisteröidään startxrefistä uusin ensin, täydentävä XRefStm-osio menee klassisen taulukon edelle, joka sen nimesi, ja /Prev-kulkua rajataan vierailluilla siirtymillä ja syvyyskatolla
Haku pysähtyy ensimmäiseen osioon, joka vastaa, mikä toistaa revisioiden etusijajärjestyksen ilman että yhdistettyä taulukkoa koskaan materialisoidaan

Tyypin 2 merkinnät: miksi objektivirta odottaa

Tyypin 2 merkintä nimeää objektivirran, eikä PDFiumPas koske kyseiseen virtaan ennen kuin kutsuja pyytää jotain sen jäsentä. Kun se lopulta tekee niin, /Type /ObjStm vahvistetaan, /N tarkistetaan objektibudjettia ja /First purettujen tavujen kattoa vasten, ja suhde /N ja /First välillä käydään läpi terveen järjen tarkistuksessa, koska jokainen otsikkopari tarvitsee vähintään neljä tavua. Vasta sen jälkeen virta puretaan, ja otsikkoskannaus pysähtyy pyydettyyn jäseneen ja sen seuraajaan kokonaisen jäsentaulukon rakentamisen sijaan. Yksi purettu objektivirta säilytetään kerrallaan, mikä on oikea kompromissi, kun sivupuun haara klusteroituu yhdeksi ObjStm-virraksi; kirjoituksamme objektivirran ja prediktorin purku Delphissä kattaa, mitä kyseisen purkuvaiheen sisällä tapahtuu (§7.5.7)

var
  Reader: TPdfSparseDictionaryReader;
  Generation: Integer;
  Dict: AnsiString;
begin
  { yksi säilytetty indeksi, monta sukupolvitietoista lukua }
  Reader := TPdfSparseDictionaryReader.Create(Source);
  try
    if Reader.Valid and
       Reader.ReadLatestDictionary(PageObjectNumber, Generation, Dict) then
      HandlePage(PageObjectNumber, Generation, Dict);
  finally
    Reader.Free;  { Source jää sinulle }
  end;
end;

Missä välimuistin lupaukset loppuvat

Indeksi on tilannekuva, ja sen sanominen suoraan kannattaa. Osiot jäsentetään kerran funktiossa Initialize; jos taustalla olevaa virtaa muokataan sen jälkeen, jokainen välimuistissa oleva merkintä on vanhentunut, eikä luokka huomaa sitä. TPdfSparseDictionaryReader pitää indeksin kutsujan omistaman lähteen elinkaaren ajan, mikä on juuri se, mitä rekursiivinen sivupuun läpikäynti haluaa, ja juuri se, mitä ei saa tehdä uudelleenkirjoituksen yli. Merkintävälimuisti on tasaista taulukkoa, jota haetaan lineaarisesti, ja se tallentaa myös negatiiviset tulokset, joten muutama sata hakua on halpoja ja muutama sata tuhatta ei ole. ReadDictionary vaatii tarkan sukupolviotsakkeen, kun taas ReadLatestDictionary selvittää aktiivisen, ja ero on harkittu: viittausten selvittäminen tarvitsee edellistä, katalogin tarkastus jälkimmäistä. Kun näitä rajoja ei voida noudattaa, ympäröivät moduulit palaavat vanhaan kokonaisen tiedoston jäsentäjään sen sijaan että kaventaisivat vielä toimivien tiedostojen joukkoa, malli, jota käytämme myös artikkelissa suurten PDF-tiedostojen suoratoisto tarvittaessa

Kääntäjienväliset regressiotestit kattavat saman käyttäytymisen kaikilla kolmella työkaluketjulla, mukaan lukien väittämä, jonka mukaan 2 MiB:n lähde ei koskaan näe yhtään yli 1 MiB:n lukua. Jos ylläpidät Delphi-, C++Builder- tai Lazarus-koodia, joka koskettaa PDF-rakennetta suoraan, ja kyllästyt maksamaan koko tiedoston jäsentämiskustannuksia neljästä sanakirjasta, harva indeksi ja sen ympärillä oleva julkinen sauma toimitetaan PDFiumPas Delphi PDFium -komponentissa