Teknisk artikkel

Strømme enorme PDF-er på forespørsel med PDFium i Delphi

Et skannet arkiv kan komme opp i flere gigabyte i en enkelt PDF. En visningsapplikasjon som åpner en slik fil vil vanligvis vise én side, kanskje innholdsfortegnelsen, kanskje en side brukeren hoppet til fra et bokmerke. Å lese hele filen inn i minnet for å gjengi to sider er sløsing på alle akser: det brenner adresseområde (address space), det holder brukeren på pinebenken under en lang innledende lesing, og på en 32-biters Delphi-prosess kan det feile fullstendig før en eneste side dukker opp. PDFium ble bygget med dette i tankene. Den kan laste et dokument gjennom et tilbakekall (callback) som ber om de spesifikke byteintervallene den trenger, når den trenger dem, og den krever aldri hele filen på én gang. Én grense hører hjemme i front: denne strømmekanalen (streaming channel) beskriver filen med en 32-biters lengde, så den serverer en enkelt fil opptil 4 GiB, som dekker nesten alle skannede arkiver i praksis. En fil forbi den linjen er ikke denne artikkelens territorium; den vil enten deles opp i volumer ved skannetidspunktet eller åpnes gjennom en direkte tilgangsstrategi i stedet, og vakten (guard) som håndhever taket får ærlig talt sin egen seksjon nedenfor

Komponenten eksponerer den banen gjennom en strømadapter (stream adapter). Du gir den enhver TStream, og PDFium trekker blokker fra den strømmen på forespørsel. Filen kan ligge på disk, i et database-blob-felt, eller bak enhver annen TStream-etterkommer, og ingenting av den kopieres inn i minnet på forhånd

Hvordan PDFium ber om byte

PDFiums C-API laster et dokument fra et oppringer-levert (caller-supplied) objekt beskrevet av FPDF_FILEACCESS-strukturen. Strukturen har tre deler som betyr noe her: et lengdefelt, et lesetilbakekall (read callback), og en ugjennomsiktig (opaque) brukerparameter. Inngangspunktet (entry point) som forbruker den er FPDF_LoadCustomDocument. Så snart PDFium holder den strukturen, analyserer (parses) den traileren, finner kryssreferansetabellen (cross-reference table), og fra da av leser den kun det en gitt operasjon krever. Å åpne dokumentet berører filens hale (tail) og en håndfull katalogobjekter. Å gjengi side 400 leser innholdsstrømmene og ressursene for den siden og ingenting annet

Dette er forskjellen mellom en bufret (buffered) lasting og en strømmende lasting. En bufret lasting leser filen fra ende til annen før PDFium ser byte null. En strømmende lasting snur forholdet opp ned: PDFium driver lesingene, og bytene som aldri berøres blir aldri lest. For en fler-gigabyte fil vist en side av gangen, er det gapet mellom en ubrukelig lasting og en umiddelbar en

Strømadapteren

Adapteren som bygger bro mellom en Delphi TStream og FPDF_FILEACCESS er TPdfStreamAdapter. Konstruktøren (constructor) tar strømmen og et eierskapsflagg, fanger (captures) strømlengden én gang, fyller ut FPDF_FILEACCESS-posten (record), og kobler opp lesetilbakekallet (read callback). Når PDFium senere kaller tilbake med en forskyvning (offset) og en størrelse, søker (seeks) adapteren strømmen til den forskyvningen og kopierer nøyaktig det intervallet inn i bufferen PDFium oppga

// Verbatim from the component: the stream-to-FPDF_FILEACCESS bridge
constructor TPdfStreamAdapter.Create(AStream: TStream; AOwnsStream: Boolean);
begin
  inherited Create;
  if AStream = nil then
    raise EPdfError.Create('TPdfStreamAdapter: AStream is nil');
  FStream := AStream;
  FOwnsStream := AOwnsStream;

  // FPDF_FILEACCESS.m_FileLen is a 32-bit unsigned long. Refuse a stream
  // that would silently truncate past 4 GiB.
  if AStream.Size > High(FPDF_DWORD) then
    raise EPdfError.Create('TPdfStreamAdapter: stream exceeds the 4 GiB limit');

  FillChar(FFileAccess, SizeOf(FFileAccess), 0);
  FFileAccess.m_FileLen  := FPDF_DWORD(AStream.Size);
  FFileAccess.m_GetBlock := GetBlockCallback;
  FFileAccess.m_Param    := Self;
end;

Eierskapsflagget avgjør hvem som frigjør (frees) strømmen. Send False, og oppringeren (caller) beholder strømmen og må holde den i live gjennom hele dokumentets levetid. Send True, og adapteren tar over, og frigjør strømmen når dokumentet lukkes. Uansett må strømmen overleve enhver lesing PDFium vil utføre, fordi PDFium holder FPDF_FILEACCESS-pekeren og vil kalle tilbake når som helst mens dokumentet er åpent, ikke bare under den opprinnelige lastingen

Hvorfor tilbakekallet er en statisk funksjon

Lesetilbakekallet som PDFium lagrer i m_GetBlock er en ren C-funksjonspeker med konvensjonen cdecl for oppkall (calling convention). En Delphi-metode kan ikke brukes direkte, fordi en metode bærer med seg et skjult Self-argument som en C-oppringer (C caller) ikke vet noe om og aldri vil levere. Adapteren deklarerer derfor tilbakekallet som en class function merket cdecl; static, som kompilerer til en frittstående funksjon med det C-rammeoppsettet (C frame layout) PDFium forventer, og ingen implisitt Self

Det løser oppkallskonvensjonen, men reiser et nytt spørsmål: uten noen Self, hvordan når tilbakekallet den spesifikke strømmen den skal lese fra? Svaret er den ugjennomsiktige brukerparameteren (opaque user parameter). Når adapteren bygger posten (record), lagrer den sin egen instanspeker i m_Param. PDFium gir den samme pekeren tilbake som det første argumentet til ethvert tilbakekall. Den statiske funksjonen støper (casts) den tilbake til en TPdfStreamAdapter og sender lesingen mot den instansens strøm. Dette er den standard trampolinen for å gi objektkontekst over en C-grense som ikke har noen oppfatning av objekter

// Verbatim from the component: the cdecl trampoline back to the instance
class function TPdfStreamAdapter.GetBlockCallback(
  param   : Pointer;
  position: FPDF_DWORD;
  pBuf    : PByte;
  size    : FPDF_DWORD): Integer; cdecl;
var
  Adapter: TPdfStreamAdapter;
begin
  Result := 0;
  if (param = nil) or (pBuf = nil) or (size = 0) then
    Exit;
  Adapter := TPdfStreamAdapter(param);   // recover the instance from m_Param
  if Adapter.FStream = nil then
    Exit;
  try
    Adapter.FStream.Position := Int64(position);
    Adapter.FStream.ReadBuffer(pBuf^, Int64(size));
    Result := 1;
  except
    Result := 0;  // report failure by return value, never by raising
  end;
end;

4 GiB-taket og hvorfor det trenger en vakt

Her er hvor grensen angitt i åpningen kommer fra. Lengdefeltet m_FileLen i FPDF_FILEACCESS er en 32-biters usignert (unsigned) verdi. Dens største representerbare lengde er én byte under 4 GiB. En TStream rapporterer størrelsen sin som en Int64, så en strøm kan beskrive langt flere byte enn feltet kan inneholde. I det øyeblikket en strøms størrelse overstiger det taket, finnes det ingen ærlig måte å fortelle PDFium hvor lang filen er

Den feilaktige responsen er å tilordne størrelsen og la den rulle rundt (wrap). Å avkorte (truncating) en 5 GiB-lengde til et 32-biters felt produserer et lite, plausibelt utseende tall, og PDFium vil deretter analysere filen i den tro at den slutter omtrent en gigabyte inne. Traileren og kryssreferansetabellen befinner seg ved filens virkelige ende, langt forbi den avkortede lengden, så analysen (parse) feiler på en måte som ikke har noe å gjøre med den faktiske årsaken. Du ville feilsøkt en kryssreferansefeil på en fil som er helt gyldig, uten noe hint om at et heltall (integer) rullet rundt (wrapped) to lag lenger opp

Adapteren avviser inndataene (input) i stedet. Konstruktøren (constructor) sammenligner strømstørrelsen mot High(FPDF_DWORD) og hever (raises) EPdfError det øyeblikket strømmen er for stor til å beskrive. En eksplisitt, umiddelbar feil navngir det reelle problemet ved opprettingstidspunktet. En stille avkortning (silent truncation) skjuler det bak et villedende symptom du ville jaktet på mye senere. Grensen på 4 GiB er en ekte begrensning (genuine constraint) ved denne lastebanen, og det ærlige å gjøre er å synliggjøre det høyt fremfor å glatte over (paper over) det med aritmetikk som tilfeldigvis kompilerer. Når et arkiv genuint krysser grensen, finnes løsningene lovet i toppen utenfor dette API-et: del skanningen inn i filer per volum (per-volume) som hver holder seg under taket, eller la dokumentet ligge på disk og server det gjennom en direkte-tilgang-design bygget på 64-biters forskyvninger (offsets) heller enn gjennom FPDF_FILEACCESS

Feil må ikke krysse grensen

En lesing kan feile. Strømmen kan være et nettverksstøttet objekt som får tidsavbrudd (times out), et blob-håndtak (blob handle) som ble lukket under deg, eller en fil som ble avkortet (truncated) etter at dokumentet åpnet seg. PDFiums kontrakt for lesetilbakekallet er en returverdi: ulik null for suksess, null for feil. Det er en C-ramme (C frame), og den har intet maskineri til å fange eller forplante (propagate) et Pascal-unntak

Dette er grunnen til at trampolinen pakker søket (seek) og lesingen i en try/except som svelger unntaket og returnerer null. Hvis et Delphi-unntak (Delphi exception) ble tillatt å forplante seg ut av tilbakekallet, ville det nøste seg ut (unwind) gjennom PDFiums cdecl-stabelrammer (stack frames), som aldri ble bygget for å nøstes ut av Pascals unntaksmaskineri. Resultatet er i beste fall udefinert adferd og i verste fall en hard krasj, dypt inni PDF-analysatoren med ingen brukbar stabel (stack). Å returnere null holder feilen innenfor kontrakten. PDFium ser en feilet blokklesing, aborterer operasjonen ryddig, og FPDF_LoadCustomDocument rapporterer at dokumentet ikke kunne lastes, noe komponenten viser (surfaces) som en EPdfError på Pascal-siden der den hører hjemme

Åpne et dokument på denne måten

Komponentmetoden som driver strømmebanen (streaming path) er LoadCustomDocument, deklarert som en distinkt metode fremfor enda en LoadDocument-overbelastning (overload) slik at å sende en TMemoryStream aldri ved et uhell havner på den bufrede banen. Den bygger adapteren, kaller FPDF_LoadCustomDocument, og holder adapteren i live for levetiden til det lastede dokumentet

var
  Pdf: TPdf;
  FileStream: TFileStream;
begin
  Pdf := TPdf.Create(nil);
  FileStream := TFileStream.Create('Archive_4GB.pdf', fmOpenRead or fmShareDenyWrite);
  try
    // Hand stream ownership to Pdf: it frees FileStream when the document closes.
    Pdf.LoadCustomDocument(FileStream, True);
    // PDFium has read only the trailer and catalog so far.
    // Rendering a page pulls just that page's bytes through the callback.
    // ... render or inspect pages here ...
  finally
    Pdf.Free;  // closes the document, which frees the adapter and the stream
  end;
end;

Det samme kallet fungerer for en TMemoryStream, en blob-strøm fra et databasedatasett (database dataset), eller en tilpasset (custom) TStream-etterkommer. Lasting på forespørsel tjener inn det det koster når filen er stor og bare en del av den vil bli lest: en arkivviser, en miniatyrbildegenerator (thumbnail generator) som prøver (samples) noen få sider, en søkeindeks som trekker én side om gangen. Når filen er liten eller du uansett kommer til å lese hele filen, er en bufret lasting (buffered load) enklere og strømmemaskineriet (streaming machinery) kjøper deg ingenting. Den avgjørende faktoren er forholdet mellom byte du faktisk vil berøre (touch) og byte filen inneholder

Når sidene strømmer inn på forespørsel, er neste bekymring å holde gjengitte sider responsive etter hvert som brukeren zoomer og scroller, noe som dekkes i vårt notat om gjengivelsesbuffer (render caching) og zoom-ytelse. Når det strømmede dokumentet er et en visningsapplikasjon (viewer) skal vise, men ikke la brukeren eksportere eller endre, passer teknikkene i gjennomgangen av sikker PDF-forhåndsvisning naturlig med denne lastebanen. Begge bygger på den strømmende lastingen beskrevet her, som følger med som en del av PDFium-komponenten for Delphi og C++Builder sammen med gjengivelses-, tekstuttrekkings- og annotasjons-API-ene (annotation APIs) dekket andre steder på denne bloggen