Odborný článok

Riedky lenivý index objektov PDF v Delphi s PDFiumPas

Chcete z 2 GB PDF jeden slovník a nástroj najprv rozbalí celú tabuľku krížových odkazov do poľa dimenzovaného trailerom /Size. PDFiumPas nahrádza tento krok riedkym lenivým indexom objektov: drží len deskriptory sekcií xref, vyrieši jediné číslo objektu na želanie cez ohraničené okná a cacheuje len položky, ktorých sa skutočne dotknete

Stará podoba tohto kódu vo FPdfCompress bola úprimná, ale drahá. ApplyDefaultOpenAction prečítal celý súbor do jediného TBytes a potom alokoval husté pole TPdfActiveXrefEntries s jedným slotom na číslo objektu až po /Size. Vo veľkom pokazili dve veci. Cena čítania rástla lineárne s veľkosťou dokumentu, aj keď volajúci chcel štyri slovníky, a husté pole sa zrazilo s rozpočtom parsera: TPdfParserResourceBudget.Default nastavuje MaxObjects na 4 000 000, takže úplne valídny súbor, ktorého najvyššie číslo objektu sedí nad tým stropom, bol odmietnutý argumentom pamäte, nie správnosti

Riedky lenivý index objektov PDFiumPas v Delphi v porovnaní s hustým poľom krížových odkazov: hustá cesta prečíta celý súbor a alokuje jeden slot na číslo objektu až po veľkosť traileru, zatiaľ čo riedka cesta drží len deskriptory sekcií
V pamäti zostávajú len deskriptory, položky zostávajú v súbore a každé čítanie prechádza ohraničeným oknom jedného MiB

Prečo verejné API PDFium neodpovedá na túto otázku?

Pretože informácia existuje vnútri PDFium, ale nikdy neprejde cez hranicu C. CPDF_Parser spravuje tabuľku krížových odkazov, členstvo v object streams a prednosť revízií interne, publikované hlavičky však nevystavujú žiadny vstupný bod, ktorý by zobral číslo objektu a vrátil jeho surový offset, jeho generáciu, ktorá revízia vyhrala alebo v ktorom ObjStm býva. Strana ukladania je zatvorená rovnako: FPDF_SaveAsCopy a FPDF_SaveWithVersion vám podajú len sekvenčné write spätné volanie. Každú bajtovú záplatu katalógu po natívnom uložení preto treba postaviť vo vrstve Pascal, čo je dôvod, prečo PDFiumPas tieto štruktúry parsuje samo namiesto znovupoužitia DLL

Čo vlastne drží riedky index v pamäti?

Deskriptory, nie položky. Pre klasickú tabuľku (ISO 32000-1 §7.5.4) ukladá TPdfSparseXrefSubsection prvé číslo objektu, počet objektov, bajtový offset, kde začínajú riadky položiek, a odmeranú šírku položky. Samotné položky zostávajú v súbore. Šírka sa odmeria z prvého riadku namiesto predpokladu 20 bajtov, pretože sa výrobcovia nezhodnú ohľadom koncov riadkov; PDFiumPas akceptuje 18 až 64 a odmietne čokoľvek mimo tohto pásma, rovnako aj každú podsekciu, ktorej deklarovaný počet by prebehol za koniec prúdu. Pre cross-reference stream (§7.5.8) drží sekcia tri šírky polí /W, každú obmedzenú na 0 až 8, zarovnané dvojice /Index a dekódované bajty položiek, ktorých očakávaná dĺžka sa vypočíta z /W a /Index skôr, než sa nafúkne jediný bajt

Celý index vybuduje Initialize z koncového okna najviac 1 MiB, kde sa nájde startxref, a každé ďalšie čítanie objektu používa 1 MiB okno objektu. Strop surového prúdu je 64 MiB a jediný riadok xref nesmie presiahnuť 1024 bajtov. Ak ste čítali našu poznámku o validácii object a cross-reference streamov s PDFiumPas, tá istá disciplína šírok polí platí aj tu, len sa teraz používa na adresovanie jednej položky namiesto auditu celej tabuľky

uses
  FPdfCompress;

var
  Source: TFileStream;
  Revision: TPdfSparseRevisionInfo;
begin
  Source := TFileStream.Create(FileName, fmOpenRead or fmShareDenyWrite);
  try
    { prejde len startxref, reťaz /Prev a katalóg }
    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;

Ako jedno vyhľadanie dosiahne jeden objekt?

Aritmetikou, v oboch usporiadaniach. Klasická podsekcia má riadky pevnej šírky, takže adresa položky je začiatok podsekcie plus offset objektu krát odmeraná šírka; PDFiumPas potom prečíta ten jeden riadok, vyparsuje desaťciferný offset a päťcifernú generáciu, skontroluje generáciu proti stropu 65535 z §7.5.4 a klasifikuje koncové kľúčové slovo ako axkDirect alebo axkFree. Cross-reference stream potrebuje jeden krok navyše, pretože podsekcie /Index sú zreťazené v dekódovanom behu bajtov, takže index akumuluje počty predchádzajúcich podsekcii skôr, než násobí súčtovou šírkou /W. Typ 1 dáva offset, typ 2 dáva číslo object streamu a index člena a čokoľvek iné sa stane axkUnknown namiesto odhadu

{ klasická tabuľka, ISO 32000-1 sekcia 7.5.4 }
EntryOffset := Subsection.EntryOffset +
  Int64(ObjectNumber - Subsection.FirstObject) * Subsection.EntryWidth;

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

Nič v ani jednej ceste nie je proporcionálne /Size. V tom je celý zmysel prepisu: hodnota veľkosti z traileru sa nesie ďalej ako metadata a používa sa pri zápise prírastkovej revízie, ale nikdy nesmeruje alokáciu. Regresná sada to pripína prípravkom, ktorého strom stránok býva na objektoch 1 000 000 000 a 1 000 000 001 pod trailerom deklarujúcim /Size 1000000002. Stará hustá implementácia ten súbor odmietla; riedky index vyrieši obe odkazy a zachová deklarovanú veľkosť vo výstupnom traileri

Ako PDFiumPas vyrieši jedno číslo objektu v Delphi: klasická tabuľka krížových odkazov násobí odmeranú šírku riadku, zatiaľ čo cross-reference stream akumuluje počty predchádzajúcich podsekcii skôr, než násobí sčítanými šírkami polí z poľa /W
Obe vyhľadania sú čistá aritmetika, takže ani jedno nie je proporcionálne počtu objektov deklarovanému v traileri

Hybridné revízie, reťazce /Prev a stráže okolo nich

Prednosť revízií je miesto, kde naivný lenivý index zlyhá. PDFiumPas prechádza reťaz od startxref v poradí od najnovšej a zastaví vyhľadávanie pri prvej sekcii, ktorá odpovie, čím reprodukuje pravidlo prednosti bez hmotnej zlúčenej tabuľky. Súbory hybrid-reference (§7.5.8.4) sa ošetria vnútri klasickej vetvy: keď trailer nesie /XRefStm, doplnková sekcia prúdu sa zaregistruje pred klasickú sekciu, ktorá na ňu odkazovala, takže komprimované objekty neviditeľné pre obyčajnú tabuľku sa stále nájdu, zatiaľ čo klasické položky si držia svoje postavenie. Staršie revízie sa potom nasledujú cez /Prev

Dve stráže ohraničujú túto prechádzku a obe sú dôležité na poškodených súboroch. Každý navštívený offset sa zaznamená, takže /Prev ukazujúci späť do reťazca sa ukončí namiesto točenia sa a hĺbka prechádzky sa stropuje cez MaxRecursionDepth, ktoré má predvolených 1024. Príznak šifrovania sa akumuluje cez celý reťaz namiesto čítania len z najnovšieho traileru, pretože dokument, ktorého najnovší trailer vynecháva /Encrypt, môže byť stále šifrovaný ďalej vzadu; volajúci, ktorí pripájajú revízie, sa na tento príznak opierajú, aby odmietli zápis plaintext objektov do šifrovaného súboru

Ako PDFiumPas prechádza hybridný reťaz revízií PDF v Delphi: sekcie sa registrujú od najnovšej zo startxref, doplnková sekcia XRefStm ide pred klasickú tabuľku, ktorá ju menuje, a prechádzka /Prev je ohraničená navštívenými offsetmi a stropom hĺbky
Vyhľadávanie sa zastaví pri prvej sekcii, ktorá odpovie, čím reprodukuje prednosť revízií bez akéhokoľvek hmotnej zlúčenej tabuľky

Položky typu 2: prečo object stream čaká

Položka typu 2 menuje object stream a PDFiumPas sa toho prúdu nedotkne, kým o člena nepožiada volajúci. Keď to konečne urobí, overí sa /Type /ObjStm, /N sa skontroluje proti rozpočtu objektov a /First proti stropu dekódovaných bajtov a /N sa sanity-skontroluje proti /First, pretože každá hlavičková dvojica potrebuje aspoň štyri bajty. Až potom sa prúd nafúkne a sken hlavičky sa zastaví na požadovanom členovi a jeho nasledovníkovi namiesto budovania úplnej tabuľky členov. V jednom momente sa zadrží jeden dekódovaný object stream, čo je správny obchod, keď sa vetva stromu stránok zhlukne do jediného ObjStm; náš rozbor dekódovania object streamov a prediktorov v Delphi pokrýva, čo sa deje vnútri toho kroku nafúknutia (§7.5.7)

var
  Reader: TPdfSparseDictionaryReader;
  Generation: Integer;
  Dict: AnsiString;
begin
  { jeden zadržaný index, veľa čítaní s ohľadom na generáciu }
  Reader := TPdfSparseDictionaryReader.Create(Source);
  try
    if Reader.Valid and
       Reader.ReadLatestDictionary(PageObjectNumber, Generation, Dict) then
      HandlePage(PageObjectNumber, Generation, Dict);
  finally
    Reader.Free;  { Source zostáva váš }
  end;
end;

Kde cache prestáva dávať sľuby

Index je snímka a stojí za to byť v tom priamy. Sekcie sa parsujú raz v Initialize; ak sa podkladový prúd potom upraví, každá cacheovaná položka je zastaraná a trieda si toho nevšimne. TPdfSparseDictionaryReader drží index po dobu životnosti zdroja vlastnenú volajúcim, čo je presne to, čo chce rekurzívna prechádzka stromom stránok a presne to, čo nesmiete robiť naprieč prepisom. Cache položiek je ploché pole prehľadávané lineárne a ukladá aj negatívne výsledky, takže niekoľko sto vyhľadaní je lacných a niekoľko sto tisíc nie je. ReadDictionary vyžaduje presnú zhodu generácie, zatiaľ čo ReadLatestDictionary vyrieši aktívnu a rozdiel je zámerný: rozlišovanie odkazov potrebuje to prvé, inšpekcia katalógu to druhé. Tam, kde sa tieto medze nedajú dodržať, okolité jednotky spadnú späť na dedičný parser celého súboru namiesto zúženia sady súborov, ktoré ešte fungujú — vzor, ktorý používame aj pre streamovanie veľkých PDF na želanie

Regresie naprieč kompilátormi pokrývajú to isté správanie na všetkých troch toolchainoch, vrátane tvrdenia, že 2 MiB zdroj nikdy nevidí jediné čítanie väčšie než 1 MiB. Ak udržiavate kód v Delphi, C++Builder alebo Lazare, ktorý sa dotýka štruktúry PDF priamo, a máte dosť platiť náklady na parsovanie celého súboru za štyri slovníky, riedky index a verejný šev okolo neho sa dodávajú v PDFiumPas Delphi PDFium component