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

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

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

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

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

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

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 komponente za Delphi i C++Builder