Tehnički članak

Red čekanja za pozadinsko iscrtavanje PDF-a u Delphiju uz HotPDF

Klasa THPDFBackgroundRenderer u HotPDF-u nasljeđuje TThread i iscrtava učitane PDF stranice u bitmape na radnoj dretvi, tako da Delphi preglednik može nastaviti pomicati prikaz i ponovno iscrtavati sadržaj dok se stranica još rasterizira u pozadini. THPDFBackgroundRenderer.RequestPage stavlja indeks stranice u red čekanja za tu radnu dretvu, CancelAll uklanja sve što još čeka na obradu, a GetCachedBitmap vraća gotovu bitmapu čiji je vlasnik pozivatelj i koju on mora osloboditi. Pomičite li dvjestostraničani skenirani ugovor u tiskovnoj rezoluciji isključivo na UI dretvi, svako okretanje stranice zaustavlja prozor dok GDI ne završi crtanje, a upravo je to zastajkivanje koje THPDFBackgroundRenderer uklanja

Zašto uopće iscrtavati PDF stranice na pozadinskoj dretvi?

Pozadinska dretva opravdava svoju složenost jer je HotPDF-ov renderer stranica pravi interpreter sadržajnog toka (content stream), a ne jeftino kopiranje bitmape koje vrati rezultat prije nego itko primijeti: prolazi kroz PDF operatore, održava stog stanja grafike (graphics-state stack) i rasterizira putanje, slike i znakove preko GDI-ja — istog mehanizma opisanog u članku o iscrtavanju učitanih PDF stranica u TBitmap. Pokrenete li taj posao sinkrono unutar rukovatelja za pomicanje prikaza ili crtanje, petlja poruka prestaje raditi dok se poziv ne vrati, a upravo to zamrznuti prozor i jest. Umetanje Application.ProcessMessages unutar poziva za iscrtavanje to ne rješava: petlja poruka se doduše prazni, no samo iscrtavanje i dalje drži pozivajuću dretvu, pa prozor brže ponovno iscrtava zastarjeli sadržaj dok se stvarni posao uopće ne pomiče naprijed. Jedini način da preglednik ostane responzivan tijekom uistinu sporog iscrtavanja jest pokrenuti to iscrtavanje negdje drugdje, zbog čega THPDFBackgroundRenderer postoji kao podklasa TThread, a ne kao callback ili timer

Postavljanje reda čekanja zahtjeva za preglednik s pomicanjem prikaza

THPDFBackgroundRenderer.Create prima učitanu instancu THotPDF i DPI vrijednost koja ostaje fiksna za cijeli životni vijek tog renderera, pa se svaka stranica stavljena u red preko iste instance iscrtava u istoj rezoluciji; preglednik koji podržava zumiranje treba novi renderer, a ne samo novo DPI svojstvo, kad god se razina zuma promijeni. RequestPage dodaje indeks stranice na kraj internog reda čekanja i odmah se vraća: sam ne obavlja nikakvo iscrtavanje i nikada ne dira UI dretvu. Execute — naslijeđena ulazna točka TThread koju HotPDF pokreće čim pozovete Start — svaki put uzima po jedan indeks s početka tog reda, iscrtava ga preko predmemorije stranica dokumenta i pohranjuje kopiju indeksiranu po stranici kako bi je GetCachedBitmap kasnije mogao vratiti

HotPDF dijagram tijeka THPDFBackgroundRenderer gdje pozivi UI dretve na RequestPage pune interni red, radna dretva ga ističe pod zajedničkom render bravom u predmemoriju dokumenta, a tempirano ispitivanje GetCachedBitmap predaje pozivatelju svježu kopiju bitmape ili nil
Zahtjeve upisane na UI dretvi ispražnjuje jedan po jedan radna dretva, i svaka gotova bitmapa vraća se kao kopija u vlasništvu pozivatelja kroz obično timer ispitivanje
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 vraća nil sve dok kopija te stranice nije spremna, pa je obrazac provjere putem timera (poll-on-a-timer), poput gornjeg, sasvim dovoljan; ne postoji zaseban događaj spremnosti koji biste trebali povezati — HotPDF to rješava običnom provjerom na nil umjesto veće API za obavijesti. Sljedeći odjeljak objašnjava što CancelAll i taj poziv Free zapravo rade, jer oboje postaje važno čim stranice počnu završavati izvan redoslijeda ili kad se pomicanje prikaza događa brže nego što se red čekanja stigne isprazniti

Prečac jednim pozivom za pojedinačnu stranicu

THotPDF.RenderLoadedPageToBitmapAsync postoji za uobičajen slučaj pokretanja iscrtavanja točno jedne stranice bez izravnog rada s THPDFBackgroundRenderer: interno kreira renderer, jednom poziva RequestPage, pokreće dretvu i vraća referencu na TThread pozivatelju, koji postaje njezin vlasnik i odgovoran je za njeno oslobađanje. Dohvaćanje rezultata ide preko THotPDF.GetLoadedCachedRenderedBitmap, a ne preko rendererove vlastite GetCachedBitmap, jer GetLoadedCachedRenderedBitmap čita zajedničku predmemoriju dokumenta indeksiranu po indeksu stranice i DPI-ju — istu predmemoriju koju već pune RenderLoadedPageToBitmapCached i ugrađeni prefetcher. Stranica koju je neki drugi dio preglednika već iscrtao pri toj DPI vrijednosti može se tako vratiti odmah, i prije nego što operacijski sustav uopće rasporedi tek pokrenutu pozadinsku dretvu

// Jednostavnija alternativa gornjem redu čekanja, za jednu stranicu odjednom.
procedure TViewerForm.RequestSinglePage(PageIndex: Integer);
begin
  if FAsyncWorker <> nil then
    FAsyncWorker.Free; // čeka ako se prethodna stranica još renderira
  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;

Može li se otkazati stranica koja je već u redu čekanja?

CancelAll uklanja samo poslove koji još čekaju u redu; stranicu koju je HotPDF već uzeo s početka reda i predao vlastitom pozivu za iscrtavanje nastavlja se obrađivati do kraja, jer THPDFBackgroundRenderer nema mehanizam za prekid posla koji je već u tijeku. To je u praksi razuman kompromis — iscrtavanje jedne stranice rijetko traje dovoljno dugo da bi prekidanje (preemption) opravdalo dodatnu složenost — no kod brzog pomicanja prikaza koje poziva CancelAll na svaki događaj pomicanja i dalje se plaća cijena bilo koje stranice koja je bila usred iscrtavanja u trenutku svakog otkazivanja. Službena dokumentacija je izričita po tom pitanju: iscrtavanje koje je već pokrenuto može se dovršiti prije nego što dretva zaista završi

HotPDF: snimka trenutka CancelAll koja ispušta redne render poslove dok stranica već skinuta s reda nastavlja renderiranje do kraja, uz napomenu o jednokratnom životnom ciklusu da se ispražnjena instanca renderera nikad ne pokreće ponovno
CancelAll briše samo unose koji još čekaju u redu, stranica u letu uvijek završi, i ispražnjenu instancu treba zamijeniti a ne ponovno koristiti

Execute ima još jedno ponašanje koje se lako previdi: petlja izlazi čim ustanovi da je red čekanja prazan, ne čeka besposleno na novi posao. Instanca THPDFBackgroundRenderer stoga je jednokratni skupni (batch) radnik, a ne trajna pozadinska usluga — stavite nekoliko stranica u red, pozovite Start, i čim se posljednja stranica iz reda iscrta, osnovna OS dretva sama završava. Ponovni poziv RequestPage na istoj instanci nakon što je Execute već ispraznio red ne pokreće je ponovno, i upravo zato gornja funkcija RequestPageWindow na svakom pozivu zamjenjuje instancu renderera umjesto da pokušava neprestano hraniti jedan dugoživući objekt

Je li sigurno raditi s TBitmap objektom iz pozadinske dretve u Delphiju?

Rad s TBitmap objektom iz pozadinske dretve siguran je u HotPDF-ovoj arhitekturi sve dok u svakom trenutku samo jedna dretva radi s danom instancom bitmape, a THPDFBackgroundRenderer sam nameće tu granicu umjesto da to prepusti pozivatelju. Execute iscrtava svaku stranicu unutar vlastite brave za iscrtavanje (render lock) dokumenta — iste kritične sekcije koju već dijele svaki poziv RenderLoadedPageToBitmapCached i ugrađeni prefetcher PrefetchLoadedPages — pa se stvarno GDI crtanje za danu stranicu u svakom trenutku odvija na točno jednoj dretvi i nikada se ne preklapa s drugim iscrtavanjem istog dokumenta. Rezultirajuća bitmapa objekt je u vlasništvu radne dretve koji THPDFBackgroundRenderer nikada izravno ne objavljuje pozivatelju

GetCachedBitmap umjesto toga alocira potpuno novi TBitmap i na njemu poziva Assign pod rendererovom vlastitom, zasebnom bravom, pa se kopiranje uvijek odvija dok je Execute-u blokirana zamjena tog mjesta u predmemoriji ispod njega — pozivajuća dretva dobiva podatke o pikselima, nikada izvorni handle. Ta odvojenost ujedno je i razlog zašto se ne isplati pisati vlastitu dretvu za iscrtavanje koja izravno poziva HotPDF-ove funkcije za iscrtavanje mimo THPDFBackgroundRenderer ili PrefetchLoadedPages: dva iscrtavanja koja se natječu nad zajedničkim predmemorijama i grafom objekata istog učitanog dokumenta upravo je scenarij koji HotPDF-ovo interno zaključavanje sprječava, a klasa pozadinskog renderera to zaključavanje daje besplatno, umjesto da ga morate ponovno implementirati

Po čemu se ovo razlikuje od ugrađenog HotPDF prefetcha stranica?

PrefetchLoadedPages i THPDFBackgroundRenderer rješavaju srodne, ali različite probleme: PrefetchLoadedPages, uz zadani raspon stranica, automatski iscrtava cijelo to susjedstvo u zajedničku predmemoriju dokumenta na vlastitoj radnoj dretvi, bez ikakvog objekta reda čekanja koji bi pozivatelj morao kreirati ili njime upravljati. THPDFBackgroundRenderer tu automatizaciju mijenja za kontrolu — pozivatelj sam odlučuje koji su indeksi stranica bitni i kojim redoslijedom, te može otkazati one koje su još u redu, a da pritom ne dira raspon koji ugrađeni prefetcher zagrijava negdje drugdje. Oba mehanizma prolaze kroz istu bravu za iscrtavanje, pa preglednik može koristiti PrefetchLoadedPages za uobičajen slučaj sljedećih nekoliko stranica, a posegnuti za THPDFBackgroundRenderer samo kad se pojavi nešto izvan tog obrasca, primjerice traka minijatura koja skoči izravno na stranicu koju je korisnik upravo kliknuo

begin
  // PrefetchLoadedPages prima 1-indeksirani niz raspona "start-end", dok
  // RequestPage ispod ostaje 0-indeksiran kao svaki drugi indeks učitane stranice.
  Pdf.PrefetchLoadedPages(Format('%d-%d', [CenterPage + 1, CenterPage + 5]), 150);

  // Posegnite za THPDFBackgroundRenderer samo za stranicu izvan tog
  // prozora, poput sličice koju je korisnik upravo kliknuo.
  FRenderer := THPDFBackgroundRenderer.Create(Pdf, 150);
  FRenderer.RequestPage(ClickedThumbnailPage);
  FRenderer.Start;
end;

Dva detalja životnog ciklusa vrijedi ponijeti u produkcijski kod. Predmemorija na razini cijelog dokumenta iza RenderLoadedPageToBitmapCached ograničena je svojstvom RenderCacheCapacity, prema zadanim postavkama osam stranica, i kad se popuni izbacuje najduže nekorišten unos (least-recently-used) — no vlastiti popis rezultata jedne instance THPDFBackgroundRenderer nema takvo ograničenje: čuva po jednu bitmapu za svaki različit indeks stranice ikad zatražen kroz tu instancu, sve dok se sama instanca ne oslobodi, pa će renderer održavan tijekom cijele sesije pomicanja prikaza pri visokom DPI-ju rado nagomilati po jednu bitmapu pune rezolucije za svaku stranicu preko koje se korisnik pomaknuo. HotPDF također ne otkazuje automatski renderer koji je kreirao pozivatelj na način na koji otkazuje vlastiti prefetcher prije učitavanja dokumenta ili pri vlastitom uništavanju, jer instanca THPDFBackgroundRenderer nikada nije registrirana na objektu THotPDF na koji se odnosi — stoga pozivajući kod mora otkazati i osloboditi svaki renderer izgrađen nad dokumentom prije ponovnog učitavanja ili oslobađanja tog dokumenta, istom disciplinom redoslijeda koju HotPDF interno primjenjuje na PrefetchLoadedPages

HotPDF usporedba ugrađenog PrefetchLoadedPages automatskog zagrijavanja susjeda s redom THPDFBackgroundRenderer kojim upravlja pozivatelj, pri čemu obje putanje serijalizira jedna render brava na razini dokumenta
PrefetchLoadedPages automatizira uobičajeni prozor sljedećih stranica dok THPDFBackgroundRenderer opslužuje izravne skokove poput klikova na sličice, i obje putanje serijaliziraju se na istoj render bravi

THPDFBackgroundRenderer samo je jedan dio pročelja (facade) učitanog dokumenta iza HotPDF-ove MVC arhitekture preglednika, i prirodno se nadopunjuje s radnim tijekovima na razini datoteke opisanima u Direct File API za velike PDF-ove, kada je dokument koji se pomiče sam po sebi prevelik da bi ga se olako učitalo. Pozadinsko iscrtavanje, redovi čekanja zahtjeva i predmemorija iscrtavanja opisani ovdje dio su standardne HotPDF Delphi komponente za Delphi i C++Builder