Techninis straipsnis

Retas vėlinamasis PDF objektų indeksas Delphi su PDFiumPas

Jums reikia vieno žodyno iš 2 GB PDF, o įrankis pirmiausia išskleidžia visą kryžminių nuorodų lentelę į masyvą, dydžio pagal trailerio /Size. PDFiumPas tą žingsnį pakeičia retu vėlinamuoju objektų indeksu: jis laiko tik xref sekcijų aprašus, pagal pareikalavimą išsprendžia vieną objekto numerį per apribotus langus ir talpykloje laiko tik tuos įrašus, kurių realiai palietėte

Senoji šio kodo forma FPdfCompress buvo sąžininga, bet brangi. ApplyDefaultOpenAction skaitė visą failą į vieną TBytes, tada išdėstydavo tankų TPdfActiveXrefEntries masyvą su vienu lizdu kiekvienam objekto numeriui iki /Size. Mastelyje du dalykai žlungdavo. Skaitymo kaina augo tiesiškai su dokumento dydžiu, net kai kvietėjas norėjo keturių žodynų, o tankus masyvas susidūrė su analizatoriaus biudžetu: TPdfParserResourceBudget.Default nustato MaxObjects į 4,000,000, todėl visiškai taisyklingas failas, kurio aukščiausias objekto numeris sėdi virš tos ribos, buvo atmetamas dėl atminties argumento, o ne teisingumo

PDFiumPas retas vėlinamasis objektų indeksas Delphi, lyginamas su tankiu kryžminių nuorodų masyvu: tankus kelias skaito visą failą ir išdėsto po vieną lizdą kiekvienam objekto numeriui iki trailerio dydžio, o retas kelias laiko tik sekcijų aprašus
Atmintyje lieka tik aprašai, įrašai lieka faile, o kiekvienas skaitymas eina per apribotą vieno mebibyto langą

Kodėl PDFium viešoji API neatsako į šį klausimą?

Nes informacija egzistuoja PDFium viduje, bet niekada neperžengia C ribos. CPDF_Parser viduje palaiko kryžminių nuorodų lentelę, objektų srauto narystę ir revizijų pirmumą, tačiau paskelbtos antraštės neatskleidžia jokio įėjimo taško, kuris priimtų objekto numerį ir grąžintų jo žalią poslinkį, jo kartą, kuri revizija laimėjo ar kuriame ObjStm jis gyvena. Išsaugojimo pusė vienodai uždaryta: FPDF_SaveAsCopy ir FPDF_SaveWithVersion duoda tik nuoseklaus rašymo callback. Bet koks baitų lygio katalogo lopas po natyvaus išsaugojimo todėl turi būti statomas Pascal sluoksnyje — todėl PDFiumPas šias struktūras analizuoja pats, vietoj DLL pakartotinio naudojimo

Ką retas indeksas iš tikrųjų laiko atmintyje?

Aprašus, o ne įrašus. Klasikinei lentelei (ISO 32000-1 §7.5.4) TPdfSparseXrefSubsection saugo pirmąjį objekto numerį, objektų skaičių, baitų poslinkį, kuriame prasideda įrašų eilutės, ir išmatuotą įrašo plotį. Patys įrašai lieka faile. Plotis išmatuojamas iš pirmos eilutės, o ne laikomas 20 baitų, nes gamintojai nesutaria dėl eilučių pabaigų; PDFiumPas priima 18 iki 64 ir atmeta viską už tos juostos, kartu su bet kuriu poskyriu, kurio paskelbtas skaičius nueitų už srauto pabaigos. Kryžminių nuorodų srautui (§7.5.8) sekcija laiko tris /W laukų pločius, kiekvieną suvaržytą nuo 0 iki 8, išlygintus /Index poras ir iškoduotus įrašų baitus, kurių numatomas ilgis apskaičiuojamas iš /W ir /Index prieš išpumpuojant bent vieną baitą

Visas indeksas sukonstruojamas Initialize iš uodegos lango, ne didesnio kaip 1 MiB, kuriame randamas startxref, ir kiekvienas vėlesnis objekto skaitymas naudoja 1 MiB objekto langą. Žalio srauto riba yra 64 MiB, o viena xref eilutė negali viršyti 1024 baitų. Jei skaitėte mūsų pastabą apie objektų ir kryžminių nuorodų srautų patikrinimą su PDFiumPas, ta pati laukų pločio disciplina galioja ir čia, tik dabar ji naudojama vienam įrašui adresuoti, o ne visai lentelei audituoti

uses
  FPdfCompress;

var
  Source: TFileStream;
  Revision: TPdfSparseRevisionInfo;
begin
  Source := TFileStream.Create(FileName, fmOpenRead or fmShareDenyWrite);
  try
    { eina tik per startxref, /Prev grandinę ir katalogą }
    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;

Kaip viena paieška pasiekia vieną objektą?

Per aritmetiką, abiejuose išdėstymuose. Klasikinis poskyris turi fiksuoto pločio eilutes, tad įrašo adresas yra poskyrio pradžia plius objekto poslinkis, daugintas iš išmatuoto pločio; PDFiumPas tada skaito tą vieną eilutę, analizuoja dešimtženklį poslinkį ir penkiaženklę kartą, tikrina kartą prieš 65535 ribą iš §7.5.4 ir galinį raktažodį klasifikuoja kaip axkDirect arba axkFree. Kryžminių nuorodų srautui reikia vieno papildomo žingsnio, nes /Index poskyriai sujungti iškoduotoje baitų eilutėje, tad indeksas sumuoja ankstesnių poskyrių skaičius prieš dauginimą iš sumuoto /W pločio. 1 tipas duoda poslinkį, 2 tipas — objektų srauto numerį ir narystės indeksą, o bet kas kita tampa axkUnknown, o ne spėjimu

{ klasikinė lentelė, ISO 32000-1 skyrius 7.5.4 }
EntryOffset := Subsection.EntryOffset +
  Int64(ObjectNumber - Subsection.FirstObject) * Subsection.EntryWidth;

{ kryžminių nuorodų srautas, ISO 32000-1 skyrius 7.5.8 }
EntryWidth := Section.Widths[0] + Section.Widths[1] + Section.Widths[2];
EntryPosition := Integer((PriorCount + ObjectNumber -
  Section.IndexValues[I]) * EntryWidth);

Niekas abiejuose keliuose nėra proporcinga /Size. Visa perrašymo esmė tame: trailerio dydžio reikšmė nešama toliau kaip metaduomenys ir naudojama rašant papildomą reviziją, bet niekada nevaldo alokacijos. Regresijos rinkinys tai užfiksuoja atveju, kurio puslapių medis gyvena objektuose 1,000,000,000 ir 1,000,000,001 po traileriu, skelbiančiu /Size 1000000002. Sena tanki realizacija tą failą atmetė; retas indeksas išsprendžia abi nuorodas ir išsaugoja paskelbtą dydį išvesties traileryje

Kaip PDFiumPas išsprendžia vieną objekto numerį Delphi: klasikinė kryžminių nuorodų lentelė daugina išmatuotą eilutės plotį, o kryžminių nuorodų srautas sumuoja ankstesnių poskyrių skaičius prieš daugindamas sumuotus laukų pločius iš /W masyvo
Abu paieškos yra gryna aritmetika, tad nė viena nėra proporcinga trailerio paskelbtam objektų skaičiui

Hibridinės revizijos, /Prev grandinės ir apsaugos aplink jas

Revizijų pirmumas yra ten, kur naivus vėlinamas indeksas suklysta. PDFiumPas eina grandine nuo startxref naujausios-pirmos tvarka ir sustabdo paiešką ties pirmąja sekcija, kuri atsako — taip atkuriama pirmumo taisyklė be sujungtos lentelės materializavimo. Hibridinės nuorodos failai (§7.5.8.4) apdorojami klasikinėje šakoje: kai traileris neša /XRefStm, papildomo srauto sekcija registruojama prieš klasikinę sekciją, kuri ją nurodė, todėl suspausti objektai, nematomi paprastai lentelei, vis tiek randami, o klasikiniai įrašai išlaiko savo statusą. Senesnės revizijos paskui sekinamos per /Prev

Du sargai riboja tą eigą, ir abu svarbūs sugadintuose failuose. Kiekvienas aplankytas poslinkis užregistruojamas, tad /Prev, rodantis atgal į grandinę, baigiasi vietoj sukinėjimosi, o apėjimo gylis ribojamas MaxRecursionDepth, kuris pagal numatytuosius yra 1024. Šifravimo žyma kaupiama per visą grandinę, o ne skaitoma iš vien naujausio trailerio, nes dokumentas, kurio naujausias traileris praleidžia /Encrypt, vis tiek gali būti šifruotas toliau atgal; kvietėjai, prijungiantys revizijas, pasikliauja ta žyma, kad atsisakytų rašyti atviro teksto objektus į šifruotą failą

Kaip PDFiumPas eina per hibridinę PDF revizijų grandinę Delphi: sekcijos registruojamos naujausios-pirmos iš startxref, papildoma XRefStm sekcija eina prieš klasikinę lentelę, kuri ją paminėjo, o /Prev eiga ribojama aplankytais poslinkiais ir gylio riba
Paieška sustoja ties pirmąja sekcija, kuri atsako, — taip atkuriamas revizijų pirmumas be sujungtos lentelės materializavimo

2 tipo įrašai: kodėl objektų srautas laukia

2 tipo įrašas įvardija objektų srautą, ir PDFiumPas to srauto neliečia, kol kvietėjas neprašo jo nario. Kai galiausiai prašo, /Type /ObjStm patikrinama, /N tikrinama prieš objektų biudžetą, o /First — prieš iškoduotų baitų ribą, ir /N sveiko proto tikrinama prieš /First, nes kiekvienai antraštės porai reikia bent keturių baitų. Tik tada srautas išpumpuojamas, o antraštės skenavimas sustoja ties prašomu nariu ir jo įpėdiniu, vietoj pilnos narių lentelės kūrimo. Vienas iškoduotas objektų srautas laikomas vienu metu — tai teisinga kaina, kai puslapių medžio šaka susikaupia į vieną ObjStm; mūsų aprašymas apie objektų srauto ir prediktoriaus dekodavimą Delphi dengia, kas vyksta tame išpumpavimo žingsnyje (§7.5.7)

var
  Reader: TPdfSparseDictionaryReader;
  Generation: Integer;
  Dict: AnsiString;
begin
  { vienas laikomas indeksas, daug kartas atžvelgiančių skaitymų }
  Reader := TPdfSparseDictionaryReader.Create(Source);
  try
    if Reader.Valid and
       Reader.ReadLatestDictionary(PageObjectNumber, Generation, Dict) then
      HandlePage(PageObjectNumber, Generation, Dict);
  finally
    Reader.Free;  { Source lieka jūsų }
  end;
end;

Kur talpykla nustoja duoti pažadų

Indeksas yra momentinė kopija, ir verta tai pasakyti tiesiai. Sekcijos analizuojamos vieną kartą Initialize; jei pagrindinis srautas po to keičiamas, kiekvienas talpyklos įrašas yra pasenęs, ir klasė to nepastebės. TPdfSparseDictionaryReader laiko indeksą kvietėjo valdomą šaltinio gyvavimo laiką — būtent to nori rekursyvi eiga per puslapių medį ir būtent to negalima daryti per perrašymą. Įrašų talpykla yra plokščias masyvas, ieškomas tiesiškai, ir jis saugo ir neigiamus rezultatus, tad keli šimtai paieškų yra pigios, o keli šimtai tūkstančių — ne. ReadDictionary reikalauja tikslios kartos atitikties, o ReadLatestDictionary išsprendžia aktyviąją — skirtumas sąmoningas: nuorodų sprendimui reikia pirmosios, katalogo apžiūrai — antrosios. Kur tų ribų negalima laikytis, aplinkiniai vienetai grįžta prie seno viso failo analizatoriaus, vietoj to, kad siaurintų dar veikiančių failų aibę — tas pats šablonas, kurį naudojame ir didelių PDF srautiniam tiekimui pagal pareikalavimą

Kryžminio kompiliavimo regresijos dengia tą patį elgesį visose trijose grandinėse, įskaitant teiginį, kad 2 MiB šaltinis niekada nemato vieno skaitymo, didesnio nei 1 MiB. Jei prižiūrite Delphi, C++Builder arba Lazarus kodą, liečiantį PDF struktūrą tiesiogiai, ir pavargote mokėti viso failo analizės kainą už keturis žodynus, retas indeksas ir viešoji siūlė aplink jį yra PDFiumPas Delphi PDFium komponente