Technisch artikel

Sparse lazy PDF-objectindex in Delphi met PDFiumPas

U wilt één dictionary uit een PDF van 2 GB en de tool klapt eerst de hele kruisverwijzingstabel uit naar een array groot volgens de trailer /Size. PDFiumPas vervangt die stap door een sparse lazy object index: hij houdt alleen de xref-sectiebeschrijvingen bij, resolveert één objectnummer op verzoek via begrensde vensters en cachet alleen de entries die u daadwerkelijk hebt aangeraakt

De oude vorm van deze code in FPdfCompress was eerlijk maar duur. ApplyDefaultOpenAction las het volledige bestand in één TBytes en alloceerde daarna een dichte TPdfActiveXrefEntries-array met één slot per objectnummer tot aan /Size. Op schaal gingen er twee dingen mis. De leeskosten groeiden lineair met de documentgrootte, zelfs als de aanroeper maar vier dictionaries wilde, en de dichte array botste tegen het parserbudget: TPdfParserResourceBudget.Default zet MaxObjects op 4.000.000, dus een volkomen geldig bestand waarvan het hoogste objectnummer boven dat plafond zat werd afgewezen op een geheugenargument in plaats van een correctheidsargument

De sparse lazy object index van PDFiumPas in Delphi vergeleken met een dichte kruisverwijzingsarray: het dichte pad leest het hele bestand en alloceert één slot per objectnummer tot aan de trailer size, terwijl het sparse pad alleen sectiebeschrijvingen bijhoudt
Alleen beschrijvingen blijven in het geheugen, de entries blijven in het bestand, en elke lezing gaat via een begrensd venster van één mebibyte

Waarom geeft de publieke PDFium-API hierop geen antwoord?

Omdat de informatie binnen PDFium bestaat maar nooit de C-grens oversteekt. CPDF_Parser onderhoudt de kruisverwijzingstabel, het object stream-lidmaatschap en de revisieprecedentie intern, maar de gepubliceerde headers tonen geen entry point die een objectnummer neemt en zijn ruwe offset, zijn generation, welke revisie won of in welke ObjStm hij woont teruggeeft. De save-kant is even gesloten: FPDF_SaveAsCopy en FPDF_SaveWithVersion geven u alleen een sequentiële write-callback. Elke patch op byteniveau van een catalog na een native save moet dus in de Pascallaag worden gebouwd, en daarom parseert PDFiumPas deze structuren zelf in plaats van de DLL te hergebruiken

Wat houdt de sparse index echt in het geheugen bij?

Beschrijvingen, geen entries. Voor een klassieke tabel (ISO 32000-1 §7.5.4) slaat een TPdfSparseXrefSubsection het eerste objectnummer, het objectaantal, de byte-offset waar de entryrijen beginnen en de gemeten entrybreedte op. De entries zelf blijven in het bestand. De breedte wordt gemeten vanaf de eerste rij in plaats van verondersteld 20 bytes te zijn, want producenten verschillen over regeleindes; PDFiumPas accepteert 18 tot 64 en wijst alles buiten die band af, samen met elke subsection waarvan het opgegeven aantal voorbij het einde van de stream zou lopen. Voor een cross-reference stream (§7.5.8) houdt de sectie de drie /W-veldbreedtes bij, elk beperkt tot 0 tot en met 8, de afgevlakte /Index-paren en de gedecodeerde entrybytes, waarvan de verwachte lengte uit /W en /Index wordt berekend voordat er ook maar één byte wordt opgeblazen

De hele index wordt door Initialize opgebouwd vanuit een staartvenster van ten hoogste 1 MiB, waar startxref wordt gevonden, en elke daaropvolgende objectlezing gebruikt een objectvenster van 1 MiB. Het plafond voor de ruwe stream is 64 MiB en een enkele xref-regel mag niet groter zijn dan 1024 bytes. Hebt u onze notitie over object- en cross-reference streams valideren met PDFiumPas gelezen, dan geldt hier dezelfde veldbreedtediscipline, alleen wordt die nu gebruikt om één entry te adresseren in plaats van een hele tabel te auditen

uses
  FPdfCompress;

var
  Source: TFileStream;
  Revision: TPdfSparseRevisionInfo;
begin
  Source := TFileStream.Create(FileName, fmOpenRead or fmShareDenyWrite);
  try
    { loopt alleen startxref, de /Prev-keten en de catalog door }
    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;

Hoe bereikt één opzoekactie één object?

Door rekenkunde, in beide layouts. Een klassieke subsection heeft rijen met vaste breedte, dus het adres van een entry is de subsection-start plus de objectoffset maal de gemeten breedte; PDFiumPas leest daarna die ene regel, parseert de tien cijfers van de offset en de vijf van de generation, controleert de generation tegen het plafond van 65535 uit §7.5.4 en classificeert het afsluitende keyword als axkDirect of axkFree. Een cross-reference stream heeft één stap meer nodig omdat /Index-subsections in de gedecodeerde bytereeks aaneengeschakeld zijn, dus de index telt de aantallen van voorafgaande subsections op voordat hij vermenigvuldigt met de opgetelde /W-breedte. Type 1 levert een offset op, type 2 levert een object stream-nummer en een ledenindex op, en al het restant wordt axkUnknown in plaats van een gok

{ klassieke tabel, ISO 32000-1 sectie 7.5.4 }
EntryOffset := Subsection.EntryOffset +
  Int64(ObjectNumber - Subsection.FirstObject) * Subsection.EntryWidth;

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

Niets in beide paden is proportioneel aan /Size. Dat is het hele punt van de herschrijving: de trailer size-waarde wordt als metadata voortgedragen en gebruikt bij het schrijven van de incrementele revisie, maar stuurt nooit een allocatie aan. De regressiesuite spijkert dit vast met een fixture waarvan de page tree op object 1.000.000.000 en 1.000.000.001 woont onder een trailer die /Size 1000000002 opgeeft. De oude dichte implementatie weigerde dat bestand; de sparse index resolveert beide referenties en behoudt de opgegeven grootte in de uitvoertrailer

Hoe PDFiumPas één objectnummer resolveert in Delphi: een klassieke kruisverwijzingstabel vermenigvuldigt de gemeten rijbreedte, terwijl een cross-reference stream de aantallen van voorafgaande subsections optelt voordat hij vermenigvuldigt met de opgetelde veldbreedtes uit de /W-array
Beide opzoekacties zijn pure rekenkunde, dus geen van beide is proportioneel aan het objectaantal dat in de trailer wordt opgegeven

Hybride revisies, /Prev-ketens en de wachters eromheen

Revisieprecedentie is waar een naïeve lazy index de mist in gaat. PDFiumPas loopt de keten vanaf startxref in nieuwste-eerst-volgorde en stopt een opzoekactie bij de eerste sectie die antwoordt, wat de precedentieregel reproduceert zonder een samengevoegde tabel te materialiseren. Hybrid-reference bestanden (§7.5.8.4) worden binnen de klassieke tak afgehandeld: draagt de trailer een /XRefStm, dan wordt de aanvullende streamsectie geregistreerd vóór de klassieke sectie die ernaar verwees, zodat gecomprimeerde objecten die de gewone tabel niet ziet alsnog worden gevonden terwijl de klassieke entries hun positie houden. Oudere revisies worden daarna via /Prev gevolgd

Twee wachters begrenzen die wandeling, en beide doen ertoe bij beschadigde bestanden. Elke bezochte offset wordt genoteerd, dus een /Prev die terugwijst in de keten termineert in plaats van te blijven draaien, en de traversediepte wordt geplafonneerd door MaxRecursionDepth, dat standaard op 1024 staat. De versleutelingsvlag wordt over de hele keten opgeteld in plaats van alleen uit de nieuwste trailer gelezen, want een document waarvan de laatste trailer /Encrypt weglaat kan verder achteraan nog versleuteld zijn; aanroepers die revisies toevoegen vertrouwen op die vlag om het schrijven van plaintext-objecten in een versleuteld bestand te weigeren

Hoe PDFiumPas een hybride PDF-revisieketen doorloopt in Delphi: secties worden nieuwste eerst vanaf startxref geregistreerd, een aanvullende XRefStm-sectie gaat vooraf aan de klassieke tabel die haar noemde, en de /Prev-wandeling wordt begrensd door bezochte offsets en een dieptelimiet
Een opzoekactie stopt bij de eerste sectie die antwoordt, wat revisieprecedentie reproduceert zonder ooit een samengevoegde tabel te materialiseren

Type-2-entries: waarom de object stream wacht

Een type-2-entry noemt een object stream, en PDFiumPas raakt die stream niet aan totdat een aanroeper om een lid vraagt. Als het zover is, wordt /Type /ObjStm geverifieerd, wordt /N tegen het objectbudget gecontroleerd en /First tegen het plafond voor gedecodeerde bytes, en wordt /N nog eens tegen /First gecontroleerd omdat elk headerpaar ten minste vier bytes nodig heeft. Pas daarna wordt de stream opgeblazen, en de headerscan stopt bij het gevraagde lid en zijn opvolger in plaats van een volledige ledentabel te bouwen. Er wordt telkens één gedecodeerde object stream vastgehouden, en dat is de juiste afweging wanneer een page tree-tak in één ObjStm clustert; onze beschrijving van object stream- en predictordecoding in Delphi dekt wat er binnen die inflate-stap gebeurt (§7.5.7)

var
  Reader: TPdfSparseDictionaryReader;
  Generation: Integer;
  Dict: AnsiString;
begin
  { één vastgehouden index, veel generation-bewuste lezingen }
  Reader := TPdfSparseDictionaryReader.Create(Source);
  try
    if Reader.Valid and
       Reader.ReadLatestDictionary(PageObjectNumber, Generation, Dict) then
      HandlePage(PageObjectNumber, Generation, Dict);
  finally
    Reader.Free;  { De Source blijft van jou }
  end;
end;

Waar de cache stopt met beloftes

De index is een snapshot, en het is de moeite waard om daar bot over te zijn. Secties worden één keer geparseerd in Initialize; als de onderliggende stream daarna wordt gewijzigd, is elke gecachte entry verouderd en zal de class het niet opmerken. TPdfSparseDictionaryReader houdt de index vast voor de door de aanroeper bepaalde levensduur van de bron, wat precies is wat een recursieve wandeling over een page tree wil en precies wat u niet moet doen rond een herschrijving. De entrycache is een vlakke array die lineair wordt doorzocht en hij slaat ook negatieve resultaten op, dus enkele honderden opzoekacties zijn goedkoop en enkele honderdduizenden niet. ReadDictionary eist een exacte generation-match terwijl ReadLatestDictionary de actieve resolveert, en het verschil is bewust: referentieresolutie heeft de eerste nodig, cataloginspectie de tweede. Waar deze grenzen niet kunnen worden geëerd, vallen de omliggende units terug op de legacy whole-file parser in plaats van de set bestanden die nog werken te verkleinen, een patroon dat we ook gebruiken voor on-demand streaming van grote PDFs

Cross-compilerregressies dekken hetzelfde gedrag op alle drie de toolchains, inclusief een assertion dat een bron van 2 MiB nooit één lezing groter dan 1 MiB ziet. Onderhoudt u Delphi-, C++Builder- of Lazarus-code die de PDF-structuur direct raakt en bent u de whole-file parse-kosten voor vier dictionaries zat, dan zitten de sparse index en de publieke seam eromheen in de PDFiumPas Delphi PDFium component