Teknisk artikel

Sparsomt dovent PDF-objektindeks i Delphi med PDFiumPas

Du vil have én ordbog ud af en 2 GB PDF, og værktøjet udvider først hele krydsreferencetabellen til et array, der er størrelsesbestemt af traileren /Size. PDFiumPas erstatter det trin med et sparsomt dovent objektindeks: det beholder kun xref-sektionsbeskrivelserne, opløser ét enkelt objektnummer efter behov gennem afgrænsede vinduer og cacher kun de poster, du reelt rørte

Den gamle form af denne kode i FPdfCompress var ærlig, men dyr. ApplyDefaultOpenAction læste den komplette fil ind i én TBytes og allokerede derefter et tæt TPdfActiveXrefEntries-array med én plads pr. objektnummer op til /Size. To ting gik galt ved skala. Læseomkostningen voksede lineært med dokumentstørrelse, selv når kalderen ville have fire ordbøger, og det tætte array kolliderede med parserbudgettet: TPdfParserResourceBudget.Default sætter MaxObjects til 4.000.000, så en helt gyldig fil, hvis højeste objektnummer ligger over det loft, blev afvist af et hukommelsesargument frem for et korrekthedsargument

PDFiumPas sparsomme dovne objektindeks i Delphi sammenlignet med et tæt krydsreferencearray: den tætte sti læser hele filen og allokerer én plads pr. objektnummer op til trailerstørrelsen, mens den sparsomme sti beholder kun sektionsbeskrivelser
Kun beskrivelser bliver i hukommelsen, posterne bliver i filen, og hver læsning går gennem et afgrænset én-mebibyte-vindue

Hvorfor svarer den offentlige PDFium-API ikke på dette spørgsmål?

Fordi informationen findes inde i PDFium, men aldrig krydser C-grænsen. CPDF_Parser vedligeholder krydsreferencetabellen, objektstream-medlemskabet og revisionsforrangen internt, men de publicerede headers eksponerer ingen indgang, der tager et objektnummer og returnerer dets rå offset, dets generation, hvilken revision der vandt, eller hvilken ObjStm det bor i. Gemmesiden er lige så lukket: FPDF_SaveAsCopy og FPDF_SaveWithVersion rækker dig kun en sekventiel skrive-callback. Enhver byte-niveau-patch af et katalog efter en indfødt gemning må derfor bygges i Pascal-laget, hvilket er derfor, PDFiumPas selv parser disse strukturer i stedet for at genbruge DLL-filen

Hvad beholder det sparsomme indeks reelt i hukommelsen?

Beskrivelser, ikke poster. For en klassisk tabel (ISO 32000-1 §7.5.4) gemmer en TPdfSparseXrefSubsection det første objektnummer, objektantallet, byte-offsettet, hvor posterne rækker begynder, og den målte postbredde. Posterne selv bliver i filen. Bredden måles fra den første række frem for at antages at være 20 bytes, fordi producenter er uenige om linjeafslutninger; PDFiumPas accepterer 18 til 64 og afviser alt uden for det bånd, ligesom enhver delsektion, hvis erklærede antal ville løbe forbi streamens ende. For en krydsreferencestream (§7.5.8) holder sektionen de tre /W-feltbredder, hver begrænset til 0-8, de fladtrykte /Index-par og de afkodede postbytes, hvis forventede længde beregnes ud fra /W og /Index, før en enkelt byte udvides

Hele indekset bygges af Initialize fra et halevindue på højst 1 MiB, som er, hvor startxref findes, og hver efterfølgende objektlæsning bruger et 1 MiB-objektvindue. Rå streamloftet er 64 MiB, og en enkelt xref-linje må ikke overskride 1024 bytes. Hvis du har læst vores note om validering af objekt- og krydsreferencestreams med PDFiumPas, gælder samme feltbredde-disciplin her, blot bruges den nu til at adressere én post i stedet for at auditere en hel tabel

uses
  FPdfCompress;

var
  Source: TFileStream;
  Revision: TPdfSparseRevisionInfo;
begin
  Source := TFileStream.Create(FileName, fmOpenRead or fmShareDenyWrite);
  try
    { går kun startxref, /Prev-kæden og kataloget igennem }
    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;

Hvordan når ét opslag ét objekt?

Ved aritmetik, i begge layout. En klassisk delsektion har fastbredderækker, så adressen på en post er delsektionens start plus objektets offset gange den målte bredde; PDFiumPas læser derefter dén linje, parser det ti-cifrede offset og femcifrede generation, tjekker generationen mod loftet 65535 fra §7.5.4 og klassificerer det afsluttende nøgleord som axkDirect eller axkFree. En krydsreferencestream behøver ét trin mere, fordi /Index-delsektioner er sammenkædet i den afkodede bytekørsel, så indekset akkumulerer antallet af foregående delsektioner, før det ganger med den summerede /W-bredde. Type 1 giver et offset, type 2 giver et objektstreamnummer og et medlemsindeks, og alt andet bliver axkUnknown frem for et gæt

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

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

Intet i nogen af stierne er proportionalt med /Size. Det er hele pointen med omskrivningen: trailerens størrelsesværdi føres frem som metadata og bruges ved skrivning af den inkrementelle revision, men den driver aldrig en allokering. Regressionstestpakken fastgør det med en fixture, hvis sidetræ bor ved objekt 1.000.000.000 og 1.000.000.001 under en trailer, der erklærer /Size 1000000002. Den gamle tætte implementering afviste den fil; det sparsomme indeks opløser begge referencer og bevarer den erklærede størrelse i outputtraileren

Hvordan PDFiumPas opløser ét objektnummer i Delphi: en klassisk krydsreferencetabel ganger den målte rækkebredde, mens en krydsreferencestream akkumulerer antallet af foregående delsektioner, før den ganger de summerede feltbredder fra /W-arrayet
Begge opslag er ren aritmetik, så intet af dem er proportionalt med det objektantal, der erklæres i traileren

Hybridrevisioner, /Prev-kæder og vagterne omkring dem

Revisionsforrang er, hvor et naivt dovent indeks tager fejl. PDFiumPas går kæden fra startxref igennem i nyest-først-rækkefølge og stopper et opslag ved den første sektion, der svarer, hvilket reproducerer forrangsreglen uden at materialisere en flettet tabel. Hybridreferencefiler (§7.5.8.4) håndteres inde i den klassiske gren: når traileren bærer en /XRefStm, registreres den supplerende streamsektion før den klassiske sektion, der refererede til den, så komprimerede objekter, der er usynlige for den simple tabel, stadig findes, mens de klassiske poster beholder deres status. Ældre revisioner følges derefter gennem /Prev

To vagter afgrænser den gennemgang, og begge tæller på beskadigede filer. Hvert besøgt offset registreres, så en /Prev, der peger tilbage ind i kæden, terminerer i stedet for at snurre, og gennemløbsdybden er loftet af MaxRecursionDepth, som er 1024 som standard. Krypteringsflaget akkumuleres på tværs af hele kæden frem for kun at læses fra den nyeste trailer, fordi et dokument, hvis nyeste trailer udelader /Encrypt, stadig kan være krypteret længere tilbage; kaldere, der tilføjer revisioner, stoler på det flag for at nægte at skrive klartekst-objekter ind i en krypteret fil

Hvordan PDFiumPas går en hybrid PDF-revisionskæde igennem i Delphi: sektioner registreres nyest først fra startxref, en supplerende XRefStm-sektion går forud for den klassiske tabel, der navngav den, og /Prev-gennemgangen er afgrænset af besøgte offsets og et dybdeloft
Et opslag stopper ved den første sektion, der svarer, hvilket reproducerer revisionsforrangen uden nogensinde at materialisere en flettet tabel

Type 2-poster: hvorfor objektstreamen venter

En type 2-post navngiver en objektstream, og PDFiumPas rører ikke den stream, før en kalder beder om et medlem af den. Når det endelig sker, verificeres /Type /ObjStm, /N tjekkes mod objektbudgettet og /First mod loftet for afkodede bytes, og /N sanity-tjekkes mod /First, da hvert header-par behøver mindst fire bytes. Først derefter udvides streamen, og headerscannet stopper ved det ønskede medlem og dets efterfølger frem for at bygge en fuld medlemstabel. Én afkodet objektstream tilbageholdes ad gangen, hvilket er den rigtige handel, når en sidetrægren klynger sig i en enkelt ObjStm; vores redegørelse om objektstream- og predictor-afkodning i Delphi dækker, hvad der sker inde i det udvidelsestrin (§7.5.7)

var
  Reader: TPdfSparseDictionaryReader;
  Generation: Integer;
  Dict: AnsiString;
begin
  { ét tilbageholdt indeks, mange generationsbevidste læsninger }
  Reader := TPdfSparseDictionaryReader.Create(Source);
  try
    if Reader.Valid and
       Reader.ReadLatestDictionary(PageObjectNumber, Generation, Dict) then
      HandlePage(PageObjectNumber, Generation, Dict);
  finally
    Reader.Free;  { Source forbliver din }
  end;
end;

Hvor cachen holder op med at love noget

Indekset er et snapshot, og det er værd at være ligetil omkring. Sektioner parses én gang i Initialize; hvis den underliggende stream modificeres bagefter, er hver cachet post forældet, og klassen bemærker det ikke. TPdfSparseDictionaryReader holder indekset i den kalder-ejede levetid af kilden, hvilket er præcis, hvad et rekursivt gennemløb over et sidetræ vil have, og præcis, hvad du ikke må gøre på tværs af en omskrivning. Postcachen er et fladt array, der søges lineært, og den gemmer også negative resultater, så et par hundrede opslag er billige og et par hundrede tusind er ikke. ReadDictionary kræver et nøjagtigt generationsmatch, mens ReadLatestDictionary opløser den aktive, og forskellen er bevidst: referenceopløsning behøver den førstnævnte, kataloginspektion den sidstnævnte. Hvor disse grænser ikke kan hædres, falder de omgivende enheder tilbage til den ældre hel-fil-parser i stedet for at indsnævre mængden af filer, der stadig virker, et mønster vi også bruger til on-demand-streaming af store PDF-filer

Cross-kompiler-regressioner dækker samme adfærd på alle tre værktøjskæder, inklusive en assertion på, at en 2 MiB-kilde aldrig ser en enkelt læsning større end 1 MiB. Hvis du vedligeholder Delphi-, C++Builder- eller Lazarus-kode, der rører PDF-strukturen direkte, og du er træt af at betale hel-fil-parseomkostninger for fire ordbøger, følger det sparsomme indeks og den offentlige søm omkring det med i PDFiumPas Delphi PDFium-komponenten