Teknisk artikel

Bakgrundskö för PDF-rendering i Delphi med HotPDF

HotPDF:s klass THPDFBackgroundRenderer är en TThread-ättling som renderar laddade PDF-sidor till bitmappar på en arbetartråd, så att en Delphi-visare kan fortsätta scrolla och rita om medan en sida fortfarande rastreras i bakgrunden. THPDFBackgroundRenderer.RequestPage köar ett sidindex för den arbetartråden, CancelAll släpper allt som fortfarande väntar, och GetCachedBitmap lämnar tillbaka en färdig bitmapp som anroparen äger och måste frigöra. Scrolla igenom ett tvåhundrasidigt inskannat kontrakt i utskriftsupplösning enbart på UI-tråden och varje sidvändning pausar fönstret tills GDI är klar med att rita det, precis den ryckighet THPDFBackgroundRenderer finns för att ta bort

Varför rendera PDF-sidor på en bakgrundstråd över huvud taget?

En bakgrundstråd förtjänar sin komplexitet eftersom HotPDF:s sidrenderare är en genuin innehållsströmstolk, inte en billig bitmappskopia som returnerar innan någon märker det: den går igenom PDF-operatorer, håller en grafiktillståndsstack, och rastrerar paths, bilder och glyfer via GDI, samma motor som täcks i att rendera laddade PDF-sidor till en TBitmap. Kör det arbetet synkront inuti en scroll- eller ritningshanterare och meddelandeslingan slutar pumpa tills anropet returnerar, vilket är vad ett fruset fönster faktiskt är. Att lägga in Application.ProcessMessages inuti renderingsanropet löser inte detta: det låter meddelandekön tömmas, men själva renderingen äger fortfarande den anropande tråden, så fönstret ritar om inaktuellt innehåll snabbare medan det verkliga arbetet inte har flyttat sig någonstans. Det enda sättet att hålla en visare responsiv under en genuint långsam rendering är att köra den renderingen någon annanstans, vilket är varför THPDFBackgroundRenderer finns som en TThread-subklass i stället för en callback eller en timer

Att sätta upp en förfrågningskö för en scrollande visare

THPDFBackgroundRenderer.Create tar den laddade THotPDF-instansen och en DPI som förblir fast under den renderarens hela livstid, så varje sida köad genom en instans renderas i en upplösning; en visare som stödjer zoom behöver en ny renderare, inte en ny DPI-egenskap, varje gång zoomnivån ändras. RequestPage lägger till ett sidindex i en intern kö och returnerar omedelbart: den gör ingen rendering själv och rör aldrig UI-tråden. Execute, den ärvda TThread-ingångspunkten HotPDF kör så fort du anropar Start, hämtar ett index i taget från kön framifrån, renderar det via dokumentets sidcache, och lagrar en kopia indexerad efter sida så att GetCachedBitmap kan lämna tillbaka den senare

type
  TViewerForm = class(TForm)
    RenderPollTimer: TTimer;
    procedure RenderPollTimerTimer(Sender: TObject);
  private
    FDoc: THotPDF;
    FRenderer: THPDFBackgroundRenderer;
    FPendingPage: Integer;
    procedure RequestPageWindow(CenterPage: Integer);
  end;

procedure TViewerForm.RequestPageWindow(CenterPage: Integer);
var
  I: Integer;
begin
  if FRenderer <> nil then
  begin
    FRenderer.CancelAll;
    FRenderer.Free;
  end;
  FRenderer := THPDFBackgroundRenderer.Create(FDoc, 150);
  for I := CenterPage - 1 to CenterPage + 1 do
    if (I >= 0) and (I < FDoc.LoadedPageCount) then
      FRenderer.RequestPage(I);
  FPendingPage := CenterPage;
  FRenderer.Start;
end;

procedure TViewerForm.RenderPollTimerTimer(Sender: TObject);
var
  Bmp: TBitmap;
begin
  if FRenderer = nil then Exit;
  Bmp := FRenderer.GetCachedBitmap(FPendingPage);
  if Bmp <> nil then
  begin
    PageImage.Picture.Bitmap.Assign(Bmp);
    Bmp.Free;
  end;
end;

GetCachedBitmap returnerar nil tills den sidans kopia är klar, så ett poll-på-en-timer-mönster som ovan räcker; det finns ingen separat klar-händelse att koppla in, HotPDF löser detta med en vanlig nil-kontroll i stället för ett större aviserings-API. Nästa avsnitt täcker vad CancelAll och det Free-anropet faktiskt gör, eftersom båda spelar roll så fort sidor börjar renderas ur ordning eller en scroll sker snabbare än kön hinner tömmas

Genvägen med ett enda anrop för en sida

THotPDF.RenderLoadedPageToBitmapAsync finns för det vanliga fallet att skjuta iväg exakt en sida utan att röra THPDFBackgroundRenderer direkt: den konstruerar renderaren internt, anropar RequestPage en gång, startar tråden, och returnerar TThread-referensen till anroparen, som äger den och ansvarar för att frigöra den. Att hämta resultatet går via THotPDF.GetLoadedCachedRenderedBitmap snarare än renderarens egen GetCachedBitmap, eftersom GetLoadedCachedRenderedBitmap läser dokumentets delade cache nyckad på sidindex och DPI, samma cache som RenderLoadedPageToBitmapCached och den inbyggda förhämtaren redan fyller — en sida som någon annan del av visaren redan har renderat vid den DPI:n kan komma tillbaka omedelbart, innan bakgrundstråden som just startats ens har schemalagts av operativsystemet

// A simpler alternative to the queue above, for one page at a time.
procedure TViewerForm.RequestSinglePage(PageIndex: Integer);
begin
  if FAsyncWorker <> nil then
    FAsyncWorker.Free; // waits if a prior page is still rendering
  FAsyncWorker := Pdf.RenderLoadedPageToBitmapAsync(PageIndex, 150);
  FPendingPage := PageIndex;
end;

procedure TViewerForm.AsyncPollTimerTimer(Sender: TObject);
var
  Bmp: TBitmap;
begin
  Bmp := Pdf.GetLoadedCachedRenderedBitmap(FPendingPage, 150);
  if Bmp <> nil then
  begin
    PageImage.Picture.Bitmap.Assign(Bmp);
    Bmp.Free;
  end;
end;

Går det att avbryta en sida som redan är köad?

CancelAll tar bara bort jobb som fortfarande sitter i kön; en sida HotPDF redan har plockat från kön och lämnat till sitt renderingsanrop fortsätter till slutförd, eftersom THPDFBackgroundRenderer saknar mekanism för att avbryta arbete redan i gång. Det är en rimlig avvägning i praktiken — en enskild sidrendering är sällan tillräckligt lång för att göra preemption värt den extra komplexiteten — men en snabb scroll som avfyrar CancelAll vid varje scroll-händelse betalar ändå för vilken enda sida som var mitt i rendering vid varje avbrott. Den officiella referensen är rak på sak om detta: redan pågående rendering kan slutföras innan tråden avslutas

Execute har ett andra, lätt att missa, beteende: loopen avslutas så fort den finner kön tom, den lägger sig inte i vänteläge för mer arbete. En THPDFBackgroundRenderer-instans är därför en engångs-batcharbetare, inte en beständig bakgrundstjänst — köa en handfull sidor, anropa Start, och så fort den sista köade sidan har renderats avslutas den underliggande OS-tråden på egen hand. Att anropa RequestPage igen på samma instans efter att Execute redan har tömt kön startar den inte om, vilket är precis varför RequestPageWindow ovan ersätter renderarinstansen vid varje anrop i stället för att försöka fortsätta mata ett långlivat objekt

Är det säkert att röra en TBitmap från en bakgrundstråd i Delphi?

Att röra en TBitmap från en bakgrundstråd är säkert i HotPDF:s design så länge bara en tråd någonsin arbetar på en given bitmappsinstans åt gången, och THPDFBackgroundRenderer upprätthåller den gränsen i stället för att lämna det åt anroparen. Execute renderar varje sida inuti dokumentets egen renderingslås, samma kritiska sektion som varje RenderLoadedPageToBitmapCached-anrop och den inbyggda PrefetchLoadedPages-förhämtaren redan delar, så den faktiska GDI-ritningen för en given sida sker på exakt en tråd åt gången och överlappar aldrig en annan rendering av det dokumentet. Den resulterande bitmappen är ett objekt ägt av arbetartråden som THPDFBackgroundRenderer aldrig publicerar direkt till en anropare

GetCachedBitmap allokerar i stället en helt ny TBitmap och anropar Assign på den under renderarens egna separata lås, så kopieringen sker alltid medan Execute är blockerad från att ersätta den cacheplatsen under den — den anropande tråden får pixeldata, aldrig det ursprungliga handtaget. Den separationen är också anledningen till att undvika att bygga en egen renderingstråd som anropar HotPDF:s renderingsfunktioner direkt utan att gå via THPDFBackgroundRenderer eller PrefetchLoadedPages: två renderingar som kapplöper mot samma laddade dokuments delade cacher och objektgraf är precis det scenario HotPDF:s interna låsning finns för att förhindra, och bakgrundsrenderarklassen ger dig den låsningen gratis i stället för att implementera om den

Hur skiljer sig detta från HotPDF:s inbyggda sidförhämtning?

PrefetchLoadedPages och THPDFBackgroundRenderer löser relaterade men olika problem: PrefetchLoadedPages, givet ett sidintervall, renderar hela det grannskapet in i den delade dokumentcachen automatiskt på sin egen arbetartråd, utan att anroparen behöver skapa eller hantera något köobjekt. THPDFBackgroundRenderer byter ut den automatiken mot kontroll — anroparen bestämmer exakt vilka sidindex som spelar roll och i vilken ordning, och kan avbryta de som fortfarande är köade utan att röra vilket intervall den inbyggda förhämtaren värmer upp någon annanstans. Båda leds via samma renderingslås, så en visare kan köra PrefetchLoadedPages för det vanliga nästa-några-sidor-fallet och ta till THPDFBackgroundRenderer bara när något utanför det mönstret dyker upp, till exempel en miniatyrremsa som hoppar rakt till en sida användaren just klickade på

begin
  // PrefetchLoadedPages takes a 1-based "start-end" range string, while
  // RequestPage below stays 0-based like every other loaded-page index.
  Pdf.PrefetchLoadedPages(Format('%d-%d', [CenterPage + 1, CenterPage + 5]), 150);

  // Reach for THPDFBackgroundRenderer only for a page outside that
  // window, such as a thumbnail the user just clicked.
  FRenderer := THPDFBackgroundRenderer.Create(Pdf, 150);
  FRenderer.RequestPage(ClickedThumbnailPage);
  FRenderer.Start;
end;

Två livscykeldetaljer är värda att ta med sig in i produktionskod. Den dokumentövergripande cachen bakom RenderLoadedPageToBitmapCached begränsas av RenderCacheCapacity, åtta sidor som standard, och vräker den senast använda posten när den är full, men en THPDFBackgroundRenderer-instans egen resultatlista har ingen sådan gräns — den behåller en bitmapp per unikt sidindex någonsin begärt genom den instansen tills instansen själv frigörs, så en renderare som hålls vid liv under en hel scrollsession vid hög DPI kommer glatt att ackumulera en bitmapp i full upplösning per sida som scrollats förbi. HotPDF avbryter inte heller automatiskt en anroparskapad renderare på samma sätt som den avbryter sin egen förhämtare innan ett dokument laddas eller förstör sig själv, eftersom en THPDFBackgroundRenderer-instans aldrig registreras på THotPDF-objektet den pekar mot — så den anropande koden måste avbryta och frigöra varje renderare byggd mot ett dokument innan det dokumentet laddas om eller frigörs, samma ordningsdisciplin HotPDF tillämpar på PrefetchLoadedPages internt

THPDFBackgroundRenderer är en del av den laddade-dokument-fasaden bakom HotPDF:s MVC-visararkitektur, och den kompletterar naturligt filnivåarbetsflödena i Direct File API för stora PDF:er när dokumentet som scrollas i sig är för stort för att laddas oreflekterat från början. Bakgrundsrendering, förfrågningsköer, och renderingscachen som beskrivs här är alla en del av standardversionen av HotPDF-komponenten för Delphi och C++Builder