Teknisk artikel

Strömma enorma PDF-filer på begäran med PDFium i Delphi

Ett inskannat arkiv kan uppgå till flera gigabyte i en enda PDF. En visare som öppnar en sådan fil vill oftast visa en sida, kanske innehållsförteckningen, kanske en sida som användaren hoppade till från ett bokmärke. Att läsa in hela filen i minnet för att rendera två sidor är slösaktigt i alla avseenden: det bränner adressrymd, det får användaren att stanna upp bakom en lång inledande läsning, och på en 32-bitars Delphi-process kan det misslyckas helt innan en enda sida visas. PDFium byggdes med detta i åtanke. Det kan ladda ett dokument genom ett återanrop som ber om de specifika byteintervall det behöver, när det behöver dem, och det kräver aldrig hela filen på en gång. En gräns hör hemma längst fram: denna strömningskanal beskriver filen med en 32-bitars längd, så den betjänar en enskild fil upp till 4 GiB, vilket täcker nästan varje skannat arkiv i praktiken. En fil bortom den gränsen är inte den här artikelns territorium; den vill delas upp i volymer vid skanningstillfället eller öppnas via en direktåtkomststrategi istället, och vakten som upprätthåller taket får ärligt talat ett eget avsnitt nedan

Komponenten exponerar den vägen genom en strömadapter. Du ger den vilken TStream som helst, och PDFium hämtar block från den strömmen på begäran. Filen kan ligga på disk, i ett databas-blob-fält, eller bakom någon annan TStream-ättling, och inget av det kopieras in i minnet i förväg

Hur PDFium ber om byte

PDFiums C API laddar ett dokument från ett objekt som tillhandahålls av anroparen, beskrivet av FPDF_FILEACCESS-strukturen. Strukturen har tre delar som är viktiga här: ett längdfält, ett läs-återanrop, och en opak användarparameter. Ingångspunkten som konsumerar den är FPDF_LoadCustomDocument. När PDFium väl håller den strukturen tolkar det trailern, lokaliserar korsreferenstabellen, och från och med då läser det bara vad en given operation kräver. Att öppna dokumentet berör filens svans och en handfull katalogobjekt. Att rendera sida 400 läser innehållsströmmarna och resurserna för den sidan och ingenting annat

Detta är skillnaden mellan en buffrad laddning och en strömmande laddning. En buffrad laddning läser filen från början till slut innan PDFium ser byte noll. En strömmande laddning inverterar relationen: PDFium driver läsningarna, och byten som aldrig berörs blir aldrig lästa. För en fil på flera gigabyte som visas en sida i taget, är det skillnaden mellan en oanvändbar laddning och en omedelbar sådan

Strömadaptern

Adaptern som överbryggar en Delphi TStream till FPDF_FILEACCESS är TPdfStreamAdapter. Dess konstruktor tar strömmen och en ägandeflagga, fångar strömmens längd en gång, fyller i FPDF_FILEACCESS-posten och kopplar upp läs-återanropet. När PDFium senare anropar tillbaka med en förskjutning och en storlek, söker adaptern i strömmen till den förskjutningen och kopierar exakt det intervallet till den buffert PDFium tillhandahållit

// 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;

Ägandeflaggan avgör vem som frigör strömmen. Skicka in False och anroparen behåller strömmen och måste hålla den vid liv under hela dokumentets livslängd. Skicka in True och adaptern tar över, och frigör strömmen när dokumentet stängs. Oavsett vilket måste strömmen överleva varje läsning som PDFium kommer att utföra, eftersom PDFium håller i FPDF_FILEACCESS-pekaren och kommer att anropa tillbaka när som helst medan dokumentet är öppet, inte bara under den inledande laddningen

Varför återanropet är en statisk funktion

Det läs-återanrop som PDFium lagrar i m_GetBlock är en vanlig C-funktionspekare med anropskonventionen cdecl. En Delphi-metod kan inte användas direkt, eftersom en metod bär på ett dolt Self-argument som en C-anropare inte vet någonting om och aldrig kommer att tillhandahålla. Adaptern deklarerar därför återanropet som en class function markerad cdecl; static, vilket kompileras till en fristående funktion med den C-ramlayout som PDFium förväntar sig och inget implicit Self

Det löser anropskonventionen men väcker en andra fråga: utan Self, hur når återanropet den specifika ström som det är tänkt att läsa från? Svaret är den opaka användarparametern. När adaptern bygger posten lagrar den sin egen instanspekare i m_Param. PDFium lämnar tillbaka samma pekare som det första argumentet i varje återanrop. Den statiska funktionen typomvandlar tillbaka den till en TPdfStreamAdapter och skickar iväg läsningen mot den instansens ström. Detta är standard-studsmattan för att skicka objektkontext över en C-gräns som saknar uppfattning om objekt

// 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 och varför det behöver en vakt

Här är varifrån gränsen som angavs inledningsvis kommer. Längdfältet m_FileLen i FPDF_FILEACCESS är ett 32-bitars osignerat värde. Dess största representerbara längd är en byte under 4 GiB. En TStream rapporterar sin storlek som en Int64, så en ström kan beskriva mycket fler byte än fältet kan rymma. I samma ögonblick som en ströms storlek överskrider det taket, finns det inget ärligt sätt att tala om för PDFium hur lång filen är

Fel reaktion är att tilldela storleken och låta den slå runt. Att stympa en längd på 5 GiB till ett 32-bitars fält producerar ett litet, troligt utseende nummer, och PDFium kommer då att tolka filen i tron att den slutar ungefär en gigabyte in. Trailern och korsreferenstabellen ligger vid det riktiga slutet av filen, långt förbi den stympade längden, så tolkningen misslyckas på ett sätt som inte har något att göra med den faktiska orsaken. Du skulle sitta och felsöka ett korsreferensfel på en fil som är helt giltig, utan någon antydan om att ett heltal slagit runt två lager upp

Adaptern avvisar istället indatan. Konstruktorn jämför strömstorleken mot High(FPDF_DWORD) och kastar EPdfError i samma ögonblick som strömmen är för stor för att beskriva. Ett explicit, omedelbart fel namnger det verkliga problemet vid tidpunkten för konstruktion. En tyst stympning gömmer det bakom ett missvisande symptom du skulle jaga mycket senare. 4 GiB-gränsen är en genuin begränsning i den här laddningsvägen, och det ärliga är att lyfta upp det högljutt snarare än att pappersöver det med aritmetik som råkar kompilera. När ett arkiv genuint går över gränsen, lever botemedlen som utlovades i toppen utanför detta API: dela upp skanningen i filer per volym som var och en håller sig under taket, eller lämna dokumentet på disk och betjäna det genom en direktåtkomstdesign byggd på 64-bitars offset istället för genom FPDF_FILEACCESS

Fel får inte passera gränsen

En läsning kan misslyckas. Strömmen kan vara ett nätverksbackat objekt som får en timeout, ett blob-handtag som stängdes under dig, eller en fil som stympades efter att dokumentet öppnades. PDFiums kontrakt för läs-återanropet är ett returvärde: skilt från noll för framgång, noll för misslyckande. Det är en C-ram, och det har inget maskineri för att fånga eller sprida ett Pascal-undantag

Det är därför studsmattan lindar in sökningen och läsningen i en try/except som sväljer undantaget och returnerar noll. Om ett Delphi-undantag tilläts sprida sig ut ur återanropet, skulle det rulla tillbaka genom PDFiums cdecl-stackramar, vilka aldrig byggdes för att rullas tillbaka av Pascals undantagsmaskineri. Resultatet är odefinierat beteende i bästa fall och en hård krasch i värsta fall, djupt inuti PDF-tolken med ingen användbar stack. Att returnera noll håller felet innanför kontraktet. PDFium ser en misslyckad blockläsning, avbryter operationen rent, och FPDF_LoadCustomDocument rapporterar att dokumentet inte kunde laddas, vilket komponenten lyfter upp som ett EPdfError på Pascal-sidan där det hör hemma

Att öppna ett dokument på det här sättet

Komponentmetoden som driver strömningsvägen är LoadCustomDocument, deklarerad som en distinkt metod snarare än en annan överlagring av LoadDocument, så att en överlämning av en TMemoryStream aldrig av misstag hamnar på den buffrade vägen. Den bygger adaptern, anropar FPDF_LoadCustomDocument, och håller adaptern vid liv under hela det laddade dokumentets livslängd

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;

Samma anrop fungerar för en TMemoryStream, en blob-ström från ett databas-dataset, eller en anpassad TStream-ättling. Laddning på begäran gör skäl för sig när filen är stor och bara en del av den kommer att läsas: en arkivvisare, en miniatyrbildsgenerator som samplar några sidor, ett sökindex som hämtar en sida i taget. När filen är liten eller du ändå kommer att läsa hela den, är en buffrad laddning enklare och strömningsmaskineriet ger dig ingenting. Den avgörande faktorn är förhållandet mellan byten du faktiskt kommer att beröra och de byten filen innehåller

När sidorna väl strömmar in på begäran, är nästa bekymmer att hålla renderade sidor responsiva när användaren zoomar och skrollar, vilket täcks i vår anteckning om renderingscaching och zoomprestanda. När det strömmande dokumentet är ett som en visare bör visa men inte låta användaren exportera eller ändra, paras teknikerna i genomgången av säker PDF-förhandsgranskning naturligt med den här laddningsvägen. Båda bygger på den strömmande laddningen som beskrivs här, vilken levereras som en del av PDFium Component för Delphi och C++Builder tillsammans med de API:er för rendering, textextraktion och kommentarer som täcks på annat håll i denna blogg