Technický článek

Řídký líný index objektů PDF v Delphi s PDFiumPas

Chcete jeden slovník z PDF o 2 GB a nástroj nejprve roztáhne celou tabulku křížových referencí do pole velikostního podle traileru /Size. PDFiumPas nahrazuje ten krok řídkým líným indexem objektů: podrží jen deskriptory sekcí xref, vyřeší jediné číslo objektu na požádání přes ohraničená okna a cachuje jen položky, kterých jste skutečně dotkli

Stará podoba tohoto kódu ve FPdfCompress byla poctivá, ale drahá. ApplyDefaultOpenAction načetl kompletní soubor do jednoho TBytes, pak alokoval husté pole TPdfActiveXrefEntries s jedním slotem na číslo objektu až po /Size. Ve velkém se pokazily dvě věci. Cena čtení rostla lineárně s velikostí dokumentu, i když volající chtěl čtyři slovníky, a husté pole se střetlo s rozpočtem parseru: TPdfParserResourceBudget.Default nastavuje MaxObjects na 4 000 000, takže dokonale platný soubor, jehož nejvyšší číslo objektu sedí nad tím stropem, byl odmítnut paměťovým argumentem, nikoli správnostním

Řídký líný index objektů PDF PDFiumPas v Delphi srovnán s hustým polem křížových referencí: hustá cesta načte celý soubor a alokuje jeden slot na číslo objektu až po velikost traileru, zatímco řídká cesta podrží jen deskriptory sekcí
V paměti zůstávají jen deskriptory, položky zůstávají v souboru a každé čtení jde přes ohraničené okno jednoho mebibajtu

Proč veřejné API PDFium neodpoví na tuto otázku?

Protože informace existuje uvnitř PDFium, ale nikdy nepřejde C hranici. CPDF_Parser udržuje uvnitř tabulku křížových referencí, členství v object streams a precedenci revizí, ale zveřejněné hlavičky vystavují žádný vstupní bod, který by vzal číslo objektu a vrátil jeho surový offset, jeho generaci, která revize vyhrála, nebo ve kterém ObjStm žije. Ukládací strana je stejně zavřená: FPDF_SaveAsCopy a FPDF_SaveWithVersion vám podají jen sekvenční zápisový callback. Jakákoli bajtová oprava katalogu po nativním uložení se proto musí stavět v Pascal vrstvě, proto PDFiumPas parsuje tyto struktury samo, místo aby znovu použilo DLL

Co řídký index skutečně drží v paměti?

Deskriptory, ne položky. Pro klasickou tabulku (ISO 32000-1 §7.5.4) ukládá TPdfSparseXrefSubsection první číslo objektu, počet objektů, bajtový offset, kde začínají řádky položek, a naměřenou šířku položky. Samotné položky zůstávají v souboru. Šířka se měří z prvního řádku, nikoli se předpokládá 20 bajtů, protože producenti se rozcházejí o konce řádků; PDFiumPas přijímá 18 až 64 a odmítá cokoli mimo ten pás, spolu s jakýmkoli podsekem, jehož deklarovaný počet by běžel za konec proudu. Pro cross-reference stream (§7.5.8) sekce drží tři šířky polí /W, každé omezené na 0 až 8, zploštělé dvojice /Index a dekódované bajty položek, jejichž očekávaná délka se vypočte z /W a /Index, dřív než se nafoukne jediný bajt

Celý index staví Initialize z ocasného okna nejvýše 1 MiB, kde se najde startxref, a každé následné čtení objektu používá okno objektu 1 MiB. Strop surového proudu je 64 MiB a jediná řádka xref nesmí přesáhnout 1024 bajtů. Načetli jste-li naši poznámku o validaci object a cross-reference streams s PDFiumPas, totéž disciplína šířek polí platí tady, jen se teď používá k adresování jedné položky místo auditu celé tabulky

uses
  FPdfCompress;

var
  Source: TFileStream;
  Revision: TPdfSparseRevisionInfo;
begin
  Source := TFileStream.Create(FileName, fmOpenRead or fmShareDenyWrite);
  try
    { projde jen startxref, řetězec /Prev a 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;

Jak jediný lookup dosáhne jednoho objektu?

Aritmetikou, v obou rozloženích. Klasický podsek má řádky pevné šířky, takže adresa položky je start podseku plus offset objektu krát naměřená šířka; PDFiumPas pak načte tu jednu řádku, naparsuje desetimístný offset a pětimístnou generaci, zkontroluje generaci proti stropu 65535 z §7.5.4 a klasifikuje koncové klíčové slovo jako axkDirect nebo axkFree. Cross-reference stream potřebuje jeden krok navíc, protože podseky /Index jsou zřetězeny v dekódovaném běhu bajtů, takže index akumuluje počty předcházejících podseků, než násobí sečtenou šířkou /W. Typ 1 dává offset, typ 2 dává číslo object streamu a index člena a cokoli jiného se stane axkUnknown, nikoli odhadem

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

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

Nic v žádné cestě není proporcionální k /Size. To je celá pointa přepisu: hodnota velikosti traileru se nese dopředu jako metadata a použije při zápisu inkrementální revize, ale nikdy neřídí alokaci. Regresní sada to připíná fixturou, jejíž strom stránek žije na objektech 1 000 000 000 a 1 000 000 001 pod trailerem deklarujícím /Size 1000000002. Stará hustá implementace ten soubor odmítla; řídký index vyřeší obě reference a podrží deklarovanou velikost ve výstupním traileru

Jak PDFiumPas vyřeší jediné číslo objektu v Delphi: klasická tabulka křížových referencí násobí naměřenou šířkou řádku, zatímco cross-reference stream akumuluje počty předcházejících podseků, než násobí sečtenými šířkami polí z pole /W
Obě vyhledání jsou čistá aritmetika, takže ani jedno není proporcionální počtu objektů deklarovanému v traileru

Hybridní revize, řetězce /Prev a stráže kolem nich

Precedence revizí je místo, kde naivní líný index selhává. PDFiumPas projde řetězec od startxref v pořadí nejnovější-dřív a zastaví vyhledávání na první sekci, která odpoví, čímž reprodukuje pravidlo precedence bez materializace sloučené tabulky. Hybridně referenční soubory (§7.5.8.4) se řeší uvnitř klasické větve: nese-li trailer /XRefStm, doplňková stream sekce se zaregistruje před klasickou sekci, která na ni odkazovala, takže komprimované objekty neviditelné pro holou tabulku se stále najdou, zatímco klasické položky si podrží své postavení. Starší revize se pak následují přes /Prev

Dvě stráže ohraničují ten průchod a obě rozhodují na poškozených souborech. Každý navštívený offset se zaznamená, takže /Prev ukazující zpět do řetězce terminuje místo otáčení a hloubka procházení se stropuje MaxRecursionDepth, které má výchozí 1024. Příznak šifrování se akumuluje napříč celým řetězcem, nikoli se načte jen z nejnovějšího traileru, protože dokument, jehož poslední trailer vynechá /Encrypt, může být stále šifrovaný dále vzad; volající připojující revize se na ten příznak spoléhají, aby odmítli psát objekty v holém textu do šifrovaného souboru

Jak PDFiumPas projde hybridní řetězcem revizí PDF v Delphi: sekce se registrují nejnovější první od startxref, doplňková sekce XRefStm jde před klasickou tabulkou, která ji pojmenovala, a průchod /Prev se ohraničuje navštívenými offsety a stropem hloubky
Vyhledávání se zastaví na první sekci, která odpoví, čímž reprodukuje precedenci revizí, aniž by kdy materializovala sloučenou tabulku

Položky typu 2: proč object stream čeká

Položka typu 2 pojmenuje object stream a PDFiumPas se toho proudu nedotkne, dokud volající nepožádá o jeho člena. Když to konečně udělá, ověří se /Type /ObjStm, /N se zkontroluje proti rozpočtu objektů a /First proti stropu dekódovaných bajtů a /N se zkoumá vůči /First, protože každá dvojice hlavičky potřebuje aspoň čtyři bajty. Teprve pak se proud nafoukne a sken hlavičky se zastaví na požadovaném členovi a jeho následníkovi, místo stavby plné tabulky členů. Jeden dekódovaný object stream se drží najednou, což je správný kompromis, když se větev stromu stránek shlukne do jediného ObjStm; náš rozbor dekódování object streamu a prediktoru v Delphi pokrývá, co se děje uvnitř toho kroku inflace (§7.5.7)

var
  Reader: TPdfSparseDictionaryReader;
  Generation: Integer;
  Dict: AnsiString;
begin
  { jeden podržený index, mnoho čtení vědomých generace }
  Reader := TPdfSparseDictionaryReader.Create(Source);
  try
    if Reader.Valid and
       Reader.ReadLatestDictionary(PageObjectNumber, Generation, Dict) then
      HandlePage(PageObjectNumber, Generation, Dict);
  finally
    Reader.Free;  { Source zůstává váš }
  end;
end;

Kde cache přestává dávat sliby

Index je snímek a stojí za to být v tom tupý. Sekce se parsují jednou v Initialize; změní-li se podkladový proud potom, každá cachovaná položka je zastaralá a třída si toho nevšimne. TPdfSparseDictionaryReader drží index po dobu života zdroje vlastněnou volajícím, což je přesně to, co chce rekurzivní průchod stromem stránek, a přesně to, co nesmíte dělat napříč přepisem. Cache položek je ploché pole prohledávané lineárně a ukládá i negativní výsledky, takže pár set vyhledání je levných a pár set tisíc ne. ReadDictionary vyžaduje přesnou shodu generace, zatímco ReadLatestDictionary vyřeší aktivní, a ten rozdíl je záměrný: řešení referencí potřebuje to první, inspekce katalogu to druhé. Kde tyto meze nelze poctít, okolní jednotky spadnou na legacy parser celého souboru, místo aby zužovaly sadu souborů, které stále fungují, vzor, který používáme též pro streamování velkých PDF na požádání

Regrese napříč kompilátory pokrývají totéž chování na všech třech toolchainech, včetně tvrzení, že zdroj o 2 MiB nikdy nevidí jediné čtení větší než 1 MiB. Udržujete-li kód Delphi, C++Builder nebo Lazarus, který dotýká struktury PDF přímo, a jste unaveni platit celosouborové parse náklady za čtyři slovníky, řídký index a veřejný šev kolem něj se dodávají v PDFiumPas Delphi PDFium component