Tehnični članak

Redek leni indeks predmetov PDF v Delphiju s PDFiumPas

Želite en slovar iz 2 GB PDF in orodje najprej razpne celo tabelo križnih sklicev v tabelo, velikosti po priklopniku /Size. PDFiumPas zamenja ta korak z redkim lenim indeksom predmetov: obdrži le deskriptorje xref odsekov, razreši eno številko predmeta na zahtevo čez omejena okna in predpomni le vnose, ki se jih dejansko dotaknete

Stara oblika te kode v FPdfCompress je bila iskrena, a draga. ApplyDefaultOpenAction je prebral celotno datoteko v en TBytes, nato dodelil gosto tabelo TPdfActiveXrefEntries z eno režo na predmetno številko do /Size. Dve stvari sta šli narobe v merilu. Cena branja je rasla linearno z velikostjo dokumenta, tudi ko je klicatelj hotel štiri slovarje, gosta tabela pa se je zaletela v proračun razčlenjevalnika: TPdfParserResourceBudget.Default nastavi MaxObjects na 4.000.000, zato je bila popolnoma veljavna datoteka, katere najvišja predmetna številka sedi nad tem stropom, zavrnjena na pomnilniškem argumentu in ne na pravilnostnem

Redki leni indeks predmetov PDFiumPas v Delphiju v primerjavi z gosto tabelo križnih sklicev: gosta pot prebere celo datoteko in dodeli eno režo na predmetno številko do velikosti priklopnika, redka pot pa obdrži le deskriptorje odsekov
Samo deskriptorji ostanejo v pomnilniku, vnosi ostanejo v datoteki, vsako branje pa gre skozi omejeno okno enega MiB

Zakaj javni API PDFium ne odgovori na to vprašanje?

Ker informacija obstaja znotraj PDFium, a nikoli ne prečka meje C. CPDF_Parser vzdržuje tabelo križnih sklicev, članstvo predmetnega toka in prednost revizij interno, objavljene glave pa ne izpostavijo vstopne točke, ki bi vzela predmetno številko in vrnila njen surovi odmik, njeno generacijo, katera revizija je zmagala ali v katerem ObjStm živi. Stran shranjevanja je enako zaprta: FPDF_SaveAsCopy in FPDF_SaveWithVersion vam podata le zaporedni klic za pisanje. Vsak popravek na ravni bajtov katalogu po domorodnem shranjevanju je zato treba zgraditi v plasti Pascal, kar je razlog, da PDFiumPas razčleni te strukture sam namesto da bi ponovno uporabil DLL

Kaj redki indeks dejansko obdrži v pomnilniku?

Deskriptorje, ne vnose. Za klasično tabelo (ISO 32000-1 §7.5.4) TPdfSparseXrefSubsection shrani prvo predmetno številko, števec predmetov, bajtni odmik, kjer se začnejo vrstice vnosov, in izmerjeno širino vnosa. Vnosi sami ostanejo v datoteki. Širina se izmeri iz prve vrstice namesto da bi se domnevala 20 bajtov, ker se proizvajalci ne strinjajo o koncih vrstic; PDFiumPas sprejme 18 do 64 in zavrne vse izven tega pasu, skupaj s vsako podrazdelkom, katerega deklarirani števec bi tekel čez konec toka. Za tok križnih sklicev (§7.5.8) odsek hrani tri širine polj /W, vsako omejeno na 0 do 8, sploščene pare /Index in dekodirane bajte vnosov, katere pričakovana dolžina se izračuna iz /W in /Index, preden se razpne en sam bajt

Celoten indeks zgradi Initialize iz repnega okna največ 1 MiB, kjer se najde startxref, vsako nadaljnje branje predmeta pa uporablja predmetno okno 1 MiB. Strop surovega toka je 64 MiB in ena sama vrstica xref ne sme presegati 1024 bajtov. Če ste prebrali našo opombo o potrjevanju predmetnih in križnosklicnih tokov s PDFiumPas, ista disciplina širin polj velja tukaj, le da se zdaj uporablja za naslavljanje enega vnosa namesto za revizijo cele tabele

uses
  FPdfCompress;

var
  Source: TFileStream;
  Revision: TPdfSparseRevisionInfo;
begin
  Source := TFileStream.Create(FileName, fmOpenRead or fmShareDenyWrite);
  try
    { hodi le po startxref, verigi /Prev in katalogu }
    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;

Kako eno iskanje doseže en predmet?

Z aritmetiko, v obeh razporeditvah. Klasičen podrazdelek ima vrstice fiksne širine, torej je naslov vnosa začetek podrazdelka plus predmetni odmik krat izmerjena širina; PDFiumPas nato prebere to eno vrstico, razčleni desnomestni odmik in petmestno generacijo, preveri generacijo proti stropu 65535 iz §7.5.4 in klasificira končno ključno besedo kot axkDirect ali axkFree. Tok križnih sklicev potrebuje en korak več, ker so podrazdelki /Index zlepljeni v dekodiranem bajtnem teku, torej indeks nabere števce predhodnih podrazdelkov, preden množi z vsoto širin /W. Vrsta 1 da odmik, vrsta 2 da predmetno številko toka in članski indeks, vse ostalo pa postane axkUnknown namesto ugib

{ klasična tabela, ISO 32000-1 razdelek 7.5.4 }
EntryOffset := Subsection.EntryOffset +
  Int64(ObjectNumber - Subsection.FirstObject) * Subsection.EntryWidth;

{ tok križnih sklicev, ISO 32000-1 razdelek 7.5.8 }
EntryWidth := Section.Widths[0] + Section.Widths[1] + Section.Widths[2];
EntryPosition := Integer((PriorCount + ObjectNumber -
  Section.IndexValues[I]) * EntryWidth);

Nič v kateri koli poti ni sorazmerno z /Size. To je celotna poanta prepisave: vrednost velikosti priklopnika se prenese naprej kot metapodatki in uporabi pri pisanju prirastne revizije, nikoli pa ne vodi dodelitve. Preizkusna zbirka regresij to pribije s fixture, katere drevo strani živi pri predmetu 1.000.000.000 in 1.000.000.001 pod priklopnikom, ki deklarira /Size 1000000002. Stara gosta izvedba je zavrgla to datoteko; redki indeks razreši oba sklica in ohrani deklarirano velikost v izhodnem priklopniku

Kako PDFiumPas razreši eno predmetno številko v Delphiju: klasična tabela križnih sklicev pomnoži izmerjeno širino vrstice, tok križnih sklicev pa nabere števce predhodnih podrazdelkov, preden pomnoži s seštetimi širinami polj iz tabele /W
Obe iskanji sta čista aritmetika, torej nobeno ni sorazmerno s števcem predmetov, deklariranim v priklopniku

Hibridne revizije, verige /Prev in čuvaji okoli njih

Prednost revizij je tam, kjer naiven leni indeks zgreši. PDFiumPas hodi po verigi od startxref v najnovejši-prvi vrstnem redu in ustavi iskanje pri prvem odseku, ki odgovori, kar reproducira pravilo prednosti brez materializacije združene tabele. Hibridno-sklicne datoteke (§7.5.8.4) se obravnavajo znotraj klasične veje: ko priklopnik nosi /XRefStm, se dopolnilni odsek toka registrira pred klasičnim odsekom, ki ga je imenoval, tako da se stisnjeni predmeti, ki so nevidni navadni tabeli, še vedno najdejo, medtem ko klasični vnosi ohranijo svoj položaj. Starejše revizije se nato sledijo prek /Prev

Dva čuvaja omejita ta hod in oba štejeta na poškodovanih datotekah. Vsak obiskan odmik se zabeleži, torej /Prev, ki kaže nazaj v verigo, zaključi namesto da bi se vrtel, globina prehoda pa je omejena z MaxRecursionDepth, ki je privzeto 1024. Zastavica šifriranja se nabira čez celo verigo namesto da bi se prebrala samo iz najnovejšega priklopnika, ker dokument, katerega zadnji priklopnik izpusti /Encrypt, je lahko še vedno šifriran dlje nazaj; klicatelji, ki pripnejo revizije, se zanašajo na to zastavico, da zavrnejo pisanje objektov v odprtem besedilu v šifrirano datoteko

Kako PDFiumPas hodi po hibridni verigi revizij PDF v Delphiju: odseki se registrirajo najnovejši-prvi od startxref, dopolnilni odsek XRefStm gre pred klasično tabelo, ki ga je imenovala, hod /Prev pa je omejen z obiskanimi odmiki in stropom globine
Iskanje se ustavi pri prvem odseku, ki odgovori, kar reproducira prednost revizij brez materializacije združene tabele

Vnosi vrste 2: zakaj predmetni tok počaka

Vnos vrste 2 imenuje predmetni tok in PDFiumPas se tega toka ne dotakne, dokler klicatelj ne vpraša za člana njega. Ko končno stori, se /Type /ObjStm potrdi, /N preveri proti predmetnemu proračunu in /First proti stropu dekodiranih bajtov, /N pa se preveri zdravorazumsko proti /First, saj vsak par glave potrebuje vsaj štiri bajte. Šele nato se tok razpne in pregled glave se ustavi pri zahtevanem članu in njegovem nasledniku namesto da bi zgradil polno člansko tabelo. En dekodiran predmetni tok se ohrani naenkrat, kar je prava izmenjava, ko se veja drevesa strani zgrudi v en sam ObjStm; naš zapis o dekodiranju predmetnih tokov in predvidnikov v Delphiju pokriva, kaj se zgodi znotraj tega koraka razpenjanja (§7.5.7)

var
  Reader: TPdfSparseDictionaryReader;
  Generation: Integer;
  Dict: AnsiString;
begin
  { en ohranjen indeks, veliko branj, pozornih na generacijo }
  Reader := TPdfSparseDictionaryReader.Create(Source);
  try
    if Reader.Valid and
       Reader.ReadLatestDictionary(PageObjectNumber, Generation, Dict) then
      HandlePage(PageObjectNumber, Generation, Dict);
  finally
    Reader.Free;  { Source ostane vaš }
  end;
end;

Kjer predpomnilnik preneha dajati obljube

Indeks je posnetek in vredno je biti o tem odkrit. Odseki so razčlenjeni enkrat v Initialize; če se podlagajoč tok spremeni pozneje, je vsak predpomnjen vnos zastarel in razred tega ne bo opazil. TPdfSparseDictionaryReader drži indeks za življenjsko dobo vira v lastništvu klicatelja, kar je točno tisto, kar želi rekurzivni hod po drevesu strani, in točno tisto, česar ne smete storiti čez prepis. Predpomnilnik vnosov je ravna tabela, iskana linearno, in shranjuje tudi negativne rezultate, torej je nekaj sto iskanj poceni in nekaj sto tisoč ni. ReadDictionary zahteva točno ujemanje generacije, ReadLatestDictionary pa razreši aktivno, razlika pa je namerna: razreševanje sklicev potrebuje prvo, pregled kataloga drugo. Kjer teh meja ni moč spoštovati, okoliške enote padejo nazaj na zapuščinski razčlenjevalnik celotne datoteke namesto da bi zožili množico datotek, ki še delujejo, vzorec, ki ga uporabljamo tudi za pretočno izdajanje velikih PDF na zahtevo

Regresije med prevajalniki pokrivajo isto vedenje na vseh treh orodjarskih verigah, vključno z trditvijo, da 2 MiB vir nikoli ne vidi enega branja večjega od 1 MiB. Če vzdržujete kodo Delphi, C++Builder ali Lazarus, ki se dotika strukture PDF neposredno, in vas je naveličalo plačevanja stroškov razčlenjevanja celotne datoteke za štiri slovarje, redki indeks in javni šiv okoli njega prihajajo v komponenti PDFiumPas Delphi PDFium