Technical Article

Strømming av gigantiske PDF-er på forespørsel med PDFium i Delphi

Et skannet arkiv kan være på flere gigabyte i én enkelt PDF. En viser som åpner en slik fil, ønsker vanligvis å vise én side, kanskje innholdsfortegnelsen, eller en side brukeren hoppet til fra et bokmerke. Å lese hele filen inn i minnet for å rendre to sider er sløseri på alle måter: Det brenner adresseområde, det lar brukeren vente bak en lang innledende lesing, og på en 32-bit Delphi-prosess kan det feile fullstendig før en eneste side vises. PDFium ble bygget med dette i tankene. Den kan laste et dokument via et tilbakekall som ber om de spesifikke byte-områdene den trenger, når den trenger dem, og den krever aldri hele filen på én gang.

Komponenten eksponerer denne banen via en strømadapter. Du gir den en hvilken som helst TStream, og PDFium henter blokker fra denne strømmen på forespørsel. Filen kan ligge på disken, i et blob-felt i en database, eller bak en hvilken som helst annen TStream-arving, og ingenting av den kopieres inn i minnet på forhånd.

Hvordan PDFium ber om byte

PDFiums C-API laster et dokument fra et kaller-levert objekt beskrevet av strukturen FPDF_FILEACCESS. Strukturen har tre deler som betyr noe her: Et lengdefelt, et tilbakekall for lesing og en ugjennomsiktig brukerparameter. Inngangspunktet som bruker det, er FPDF_LoadCustomDocument. Så snart PDFium har denne strukturen, tolker den traileren, lokaliserer kryssreferansetabellen, og leser deretter bare det en gitt operasjon krever. Å åpne dokumentet berører filens ende og en håndfull katalogobjekter. Rendring av side 400 leser innholdsstrømmene og ressursene for akkurat den siden og ingenting annet.

Dette er forskjellen mellom en bufret lasting og en strømmende lasting. En bufret lasting leser filen fra start til slutt før PDFium ser byte null. En strømmende lasting snur forholdet: PDFium styrer lesingen, og bytene som aldri berøres, blir aldri lest. For en fil på flere gigabyte som vises én side om gangen, er det forskjellen mellom en ubrukelig lasting og en øyeblikkelig lasting.

Strømadapteren

Adapteren som bygger bro fra en Delphi TStream til FPDF_FILEACCESS er TPdfStreamAdapter. Konstruktøren tar strømmen og et eierskapsflagg, fanger opp strømlengden én gang, fyller ut FPDF_FILEACCESS-posten og kobler til tilbakekallet for lesing. Når PDFium senere kaller tilbake med en forskyvning og en størrelse, adapteren søker i strømmen til denne forskyvningen og kopierer nøyaktig dette området 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 bestemmer hvem som frigjør strømmen. Send inn False, og kaller beholder strømmen og må holde den i live under hele dokumentets levetid. Send inn True, og adapteren overtar eierskapet og frigjør strømmen når dokumentet lukkes. Uansett må strømmen leve lenger enn alle lesinger 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 innledende lastingen.

Hvorfor tilbakekallet er en statisk funksjon

Tilbakekallet for lesing som PDFium lagrer i m_GetBlock er en vanlig C-funksjonspeker med kallkonvensjonen cdecl. En Delphi-metode kan ikke brukes direkte, fordi en metode bærer et skjult Self-argument som en C-kaller ikke vet noe om og aldri vil levere. Adapterer deklarerer derfor tilbakekallet som en class function merket med cdecl; static, noe som kompilerer til en frittstående funksjon med C-rammeoppsettet PDFium forventer og uten implisitt Self.

Det løser kallkonvensjonen, men reiser et nytt spørsmål: Uten Self, hvordan når tilbakekallet den spesifikke strømmen det er ment å lese fra? Svaret er den ugjennomsiktige brukerparameteren. Når adapteren bygger posten, lagrer den sin egen instanspeker i m_Param. PDFium gir denne samme pekeren tilbake som det første argumentet i hvert tilbakekall. Den statiske funksjonen caster den tilbake til en TPdfStreamAdapter og sender lesingen videre mot denne instansens strøm. Dette er den standard trampolinen for å overføre objektkontekst over en C-grense som ikke har begreper om 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

Lengdefeltet m_FileLen i FPDF_FILEACCESS er a 32-bit unsigned verdi. Dens største representerbare lengde er én byte under 4 GiB. En TStream rapporterer sin størrelse som en Int64, så en strøm kan beskrive langt flere byte enn feltet kan holde. I det øyeblikket en strøms størrelse overskrider dette taket, finnes det ingen ærlig måte å fortelle PDFium hvor lang filen er.

Den feilaktige reaksjonen er å tilordne størrelsen og la den rulle rundt. Avkorting av en 5 GiB-lengde to et 32-bit felt produserer et lite tall som ser plausibelt ut, og PDFium vil tolke filen i den tro at den slutter omtrent én gigabyte inn. Traileren og kryssreferansetabellen ligger i den faktiske slutten av filen, langt forbi den avkortede lengden, så tolkningen feiler på en måte som ikke har noe med den faktiske årsaken å gjøre. Du ville feilsøkt en kryssreferansefeil på en fil som er helt gyldig, uten noen anelse om at et heltall rullet rundt to lag over.

Adapteren avviser i stedet inndataene. Konstruktøren sammenligner strømstørrelsen mot High(FPDF_DWORD) og utløser EPdfError i det øyeblikket strømmen er for stor til å beskrives. En eksplisitt, umiddelbar feil navngir det reelle problemet på konstruksjonstidspunktet. En lydløs avkorting skjuler det bak et villedende symptom du ville jaktet på mye senere. Grensen på 4 GiB er en reell begrensning for denne lastingsbanen, og det eneste ærlige er å vise den tydelig frem i stedet for å dekke over den med matematikk som tilfeldigvis kompilerer.

Feil må ikke krysse grensen

En lesing kan feile. Strømmen kan være et nettverksbasert objekt som tidsavbrytes, et blob-håndtak som ble lukket under deg, eller en fil som ble avkortet etter at dokumentet ble åpnet. PDFiums kontrakt for tilbakekallet av lesing er en returverdi: ikke-null for suksess, null for feil. Det er en C-ramme, og den har ingen mekanismer for å fange opp eller forplante et Pascal-unntak.

Dette er grunn til at trampolinen pakker søket og lesingen inn i en try/except som svelger unntaket og returnerer null. Hvis et Delphi-unntak fikk lov til å forplante seg ut av tilbakekallet, ville det rulles tilbake gjennom PDFiums cdecl stakkrammer, som aldri ble bygget for å rulles tilbake av Pascals unntakssystem. Resultatet er udefinert oppførsel i beste fall og et hardt krasj i verste fall, dypt inne i PDF-tolkeren uten en brukbar stakk. Å returnere null holder feilen innenfor kontrakten. PDFium ser en feilet blokklesing, avbryter operasjonen på en ryddig måte, og FPDF_LoadCustomDocument rapporterer at dokumentet ikke kunne lastes, noe komponenten viser som en EPdfError på Pascal-siden der den hører hjemme.

Åpne et dokument på denne måten

Komponentmetoden som driver strømmebanen er LoadCustomDocument, deklarert som en egen metode i stedet for en annen overstyring av LoadDocument, slik at overføring av en TMemoryStream aldri utilsiktet lander på den bufrede banen. Den bygger adapteren, kaller FPDF_LoadCustomDocument, og holder adapteren i live så lenge det lastede dokumentet eksisterer.

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 databasesett, eller en tilpasset TStream-arving. Lasting på forespørsel forsvarer plassen sin når filen er stor og bare deler av den vil bli lest: en arkivviser, en miniatyrbildegenerator som sjekker noen få sider, eller en søkeindeks som henter én side om gangen. Når filen er liten, eller du uansett skal lese hele filen, er en bufret lasting enklere, og strømmemaskineriet gir deg ingenting. Den avgjørende faktoren er forholdet mellom bytene du faktisk kommer til å røre ved, og bytene filen inneholder.

Når sider strømmer inn på forespørsel, er neste utfordring å holde rendret sider responsive mens brukeren zoomer og blar, noe som er dekket i vårt notat om rendringsbuffer og zoom-ytelse. Når det strømmede dokumentet er et en viser skal vise frem, men ikke la brukeren eksportere eller endre på, passer teknikkene i gjennomgangen av sikker PDF-forhåndsvisning naturlig sammen med denne lastingsbanen. Begge bygger på den strømmende lastingen som beskrives her, som leveres som en del av PDFium-komponenten for Delphi og C++Builder sammen med API-ene for rendring, tekstuttrekking og annoteringer som er dekket andre steder på denne bloggen.