Većina PDF stranica rasterizira se u svega nekoliko milisekundi i vi o tome zapravo nikada ni ne razmišljate. A onda korisnik otvori neki A1 inženjerski nacrt, stranicu prepunu desetinama tisuća vektorskih poteza ili pak neki plakat pretrpan grupama za transparentnost i blagim maskama (soft masks), a taj samo jedan poziv koji ga i oslikava traje i do dvije ili tri sekunde. Ako se taj poziv pokrene na niti korisničkog sučelja (UI thread), prozor prestaje s iscrtavanjem promjena (repainting), naslovna traka posivi, a operacijski sustav sam nudi potpuno ubijanje odnosno prekid same aplikacije. Posao je legitiman. Stranica zbilja zahtijeva toliko puno vremena. Kvar je u tome što je renderiranje samo jedan nedjeljivi blokirajući poziv bez ikakvog načina da malo dođe do zraka i bez ikakvog načina da se zaustavi
Ovaj članak bavi se točno s jednim od ta dva problema: otkazivanjem jednog dugotrajnog renderiranja jedne jedine stranice bez zamrzavanja korisničkog sučelja (UI). Korisnik je kliknuo na sljedeću stranicu, ili zumirao, ili pak zatvorio sam dokument, a renderiranje u letu (in flight) sada predstavlja samo jedan uzaludan posao koji bi trebao završiti pri prvoj sljedećoj prilici umjesto da se pokreće skroz do samog kraja (completion). Ublažavanje i glađenje samog listanja (scroll) i zumiranja predmemoriranjem (caching) onoga što je već bilo rasterizirano sasvim je zasebna briga sa svojim vlastitim dizajnom, a što je sve pokriveno u popratnom članku s poveznicom na samom kraju. Ovdje je jedino pitanje kako napraviti da jedno progresivno renderiranje odgovori na zahtjev za otkazivanjem brzo i čisto (cleanly)
API progresivnog renderiranja kojeg PDFium već ionako isporučuje
PDFium je predvidio i očekivao upravo tu zamrzavajuću polovicu problema. Zajedno uz jednokratni FPDF_RenderPageBitmap, on izlaže progresivnu varijantu koja razbija stranicu unutar odgovarajućih komada posla (chunks). Jednom pozovete FPDF_RenderPageBitmap_Start kako biste pripremili render prema ciljanoj bitmapi, te nakon toga ponavljano zovete FPDF_RenderPage_Continue. Svaki takav Continue rasterizira omeđeni isječak i vraća status. FPDF_RENDER_TOBECONTINUED znači da ima još posla za obaviti, FPDF_RENDER_DONE znači da je stranica gotova, a FPDF_RENDER_FAILED znači da se zaustavio na pogrešci. Kada se petlja završi vi pozivate FPDF_RenderPage_Close kako biste oslobodili to progresivno stanje po stranici (per-page progressive state). Zbog toga što se kontrola vraća vašem kodu između isječaka, možete isporučivati poruke (pump messages), ažurirati pokazivač napretka (progress indicator), ili pak provjeriti je li posao još uvijek željen
Mehanizam koji PDFium pruža za odlučivanje o tome kada prepustiti kontrolu (yield) jest povratna struktura (callback struct) pod imenom IFSDK_PAUSE. Vi je uručujete u Start i u svaki Continue. Nakon svakog komada (chunk) PDFium poziva svoj NeedToPauseNow pokazivač funkcije, a ako to vrati vrijednost koja nije nula (non-zero), trenutni Continue se rano zaustavlja te vraća kontrolu natrag uz FPDF_RENDER_TOBECONTINUED. Struktura također nosi i version polje, koje se mora postaviti na 1, te user pokazivač slobodnog oblika (free-form) koji PDFium nikada ne dodiruje i koji prolazi sasvim netaknuto (untouched). Taj netaknuti pokazivač jest zapravo čitava osovina (hinge) za dizajn koji slijedi u nastavku
Prenamjena obične pauze u otkazivanje (cancel)
Originalna namjera NeedToPauseNow je presijecanje vremena (time-slicing). Vratite vrijednost koja nije nula kada je vaš proračun okvira (frame budget) potrošen, vratite nulu kako biste zadržali renderiranje, a PDFium pauzira kako biste vi mogli napraviti nešto drugo prije ponovnog nastavljanja tog istog renderiranja. PDFium Komponenta iznova koristi taj isti signal za sasvim jedan drugačiji glagol (verb). Umjesto da odgovara na "trebam li pauzirati i pustiti te da nastaviš," povratni poziv odgovara "je li ovaj posao otkazan." To dvoje se čisto preslikava jedno na drugo zbog onoga što petlja radi kada vidi zastavicu (flag). Prava pauza očekuje kasnije Continue; a otkazivanje to ne očekuje. Jednom kada pozivna petlja primijeti da je token otkazan, ona zatvara kontekst renderiranja i nikada više ne poziva Continue, tako da potpuno isti nenulti povratak (non-zero return) koji PDFium čita kao "zaustavi ovaj komad" u učinku postaje, zapravo i doslovno, "zaustavi zauvijek (stop for good)."
Otkazivanje se izražava kroz sučelje (interface), IPdfCancellationToken, čije se svojstvo IsCancelled preokreće iz false u true kada neki drugi dio programa zatraži da se renderiranje zaustavi. Most između tog Pascal sučelja i PDFium povratnog poziva u C-u jest samo jedan jedini pokazivač (pointer). Referenca sučelja tokena upisuje se u IFSDK_PAUSE.user, a statički cdecl povratni poziv čita ga natrag iz toga i preispituje (queries). Ovo je klasični problem kod puštanja da neka C biblioteka poziva povratno (call back) u Pascal: povratni poziv mora biti jedna sasvim obična funkcija s C konvencijom pozivanja (calling convention), a ne neka metoda, iz razloga što PDFium pohranjuje i poziva jedan ogoljeni (bare) pokazivač funkcije koji ne zna ama baš ništa o Pascal objektima ili o Self
type
TPdfProgressivePause = record
Pause: IFSDK_PAUSE; // PDFium reads this; .user holds the token
Token: IPdfCancellationToken; // strong ref keeps the token alive
end;
function ProgressivePauseCallback(pThis: PIFSDK_PAUSE): FPDF_BOOL; cdecl;
var
Token: IPdfCancellationToken;
begin
Result := 0;
if (pThis = nil) or (pThis^.user = nil) then
Exit;
Token := IPdfCancellationToken(pThis^.user);
if Token.IsCancelled then
Result := 1; // non-zero: PDFium stops this chunk
end;
Povratni poziv obnavlja (recovers) token kastanjem pThis^.user natrag u tip sučelja i čita IsCancelled. Ništa u njemu ne alocira (allocates), ne zaključava (locks), niti blokira (blocks), što je i te kako važno jer ga PDFium poziva na niti za renderiranje nakon svakog komada (chunk) i bilo kakav posao odrađen ovdje dodaje se na trošak samog renderiranja. Zaštita (guard) protiv nil strukture ili nil user polja znači da je tu istu funkciju sasvim sigurno instalirati čak i na renderiranje kojem nikada nije ni bio dan neki pravi token
Održavanje tokena živim (alive) kroz cijelu petlju
Kastanje pokazivača sučelja kroz jedan sirovi (raw) Pointer i natrag jest upravo ono mjesto gdje se i rađaju bugovi sa životnim vijekom (lifetime bugs). IInterface u Delphiju se broji referencom (reference counted), a broj se pomiče samo onda kada kompajler može vidjeti dodjeljivanje varijable tipa sučelja (interface-typed variable). Pohranjivanje tokena isključivo kao ogoljenog (bare) pokazivača unutar IFSDK_PAUSE.user bi ga potpuno sakrilo od brojača referenci (reference counter). Ako bi jedina druga referenca na taj token izašla iz opsega (went out of scope) dok se Continue petlja još uvijek izvodila (was still running), objekt bi bio oslobođen ispod povratnog poziva (underneath the callback), a sljedeći bi komad (chunk) dereferencirao jedan viseći pokazivač (dangling pointer)
To je razlog zašto je deskriptor jedan zapis (record) koji drži dvije stvari, a ne jednu. Polje Pause je struktura koju PDFium čita. Polje Token je prava referenca tipa sučelja koju kompajler broji, a ona postoji iz niti jednog drugog razloga osim da zabije (pin) token u memoriji onoliko dugo koliko sam zapis živi. Zapis je lokalna varijabla na stogu (stack) rutine za renderiranje, tako da on ostaje važeći (valid) za čitavo trajanje petlje i ruši se (is torn down) samo onda kada rutina izađe (exits). Ogoljeni (bare) pokazivač u user i izbrojana (counted) referenca u Token imenuju taj isti objekt; jedno je ono što PDFium može čitati, a ono drugo je ono što drži taj objekt od toga da bude prikupljen (collected)
var
Pause: TPdfProgressivePause;
EffectiveToken: IPdfCancellationToken;
begin
// ... choose EffectiveToken ...
// Strong ref first, then publish the same object to PDFium via .user.
Pause.Token := EffectiveToken;
Pause.Pause.version := 1;
Pause.Pause.NeedToPauseNow := ProgressivePauseCallback;
Pause.Pause.user := Pointer(EffectiveToken);
Zatvaranje konteksta renderiranja bez obzira na to kako petlja završila
Svaki poziv u FPDF_RenderPageBitmap_Start alocira progresivno stanje koje PDFium asocira sa stranicom, a to se stanje oslobađa samo putem FPDF_RenderPage_Close. Postoje tri puta (ways) izlaska iz pogonske petlje (drive loop). Stranica završi i posljednji status je FPDF_RENDER_DONE. Token se spotakne (trips) a petlja rano izađe prijavljujući otkazivanje. Nešto propadne (fails) a status je FPDF_RENDER_FAILED. Sva tri puta moraju pozvati Close, a put otkazivanja je onaj kod kojeg je najlakše pogriješiti (get wrong), zato što prirodni oblik od "vidi otkazivanje, prekini (break out)" teži preskočiti čišćenje (cleanup) na svom putu prema samom izlazu. Ostavljanje Close nedosegnutim (unreached) curi (leaks) stanje po stranici (per-page state), a preglednik (viewer) koji pušta korisnika da otkazuje renderiranje za renderiranjem bi akumulirao to curenje na svakoj pobačenoj (aborted) stranici
Robustan oblik stavlja petlju i klasifikaciju rezultata unutar jednog try te FPDF_RenderPage_Close u pripadajući finally. Ciljana (destination) bitmapa uništava se u tom istom bloku. Otkazivanje može napustiti petlju kroz jedan rani Exit a finally se još uvijek izvodi (runs), tako da postoji točno jedno jedino mjesto koje oslobađa progresivno stanje i ono se ne može zaobići
Status := FPDF_RenderPageBitmap_Start(PdfBmp, FPage, Left, Top,
Width, Height, Ord(Rotation), EncodeRenderOptions(Options), Pause.Pause);
try
while Status = FPDF_RENDER_TOBECONTINUED do
begin
if EffectiveToken.IsCancelled then
begin
Result := prsCancelled;
Exit;
end;
Status := FPDF_RenderPage_Continue(FPage, Pause.Pause);
end;
if EffectiveToken.IsCancelled then
Result := prsCancelled
else if Status = FPDF_RENDER_DONE then
Result := prsDone
else
Result := prsFailed;
finally
// Frees the progressive state Start allocated; mandatory on every path.
FPDF_RenderPage_Close(FPage);
FPDFBitmap_Destroy(PdfBmp);
end;
Petlja provjerava token prije svakog Continue isto kao što se i oslanja na povratni poziv unutar nje same. Povratni poziv skraćuje trenutni komad; provjera u petlji zaustavlja onaj sljedeći od samog pokretanja. Zajedno oni ograničavaju (bound) koliko dugo nekom otkazivanju treba da stupi na snagu (take effect) na otprilike trajanje jednog komada (chunk)
Tri ishoda, i što bitmapa drži nakon otkazivanja (cancel)
Javna ulazna točka jest TPdf.RenderPageProgressive, a ona vraća jedan TPdfProgressiveStatus koji je jedno od prsDone, prsCancelled, ili prsFailed. Vrijednosti zrcale PDFiumove FPDF_RENDER_* konstante u Pascal idiomu ali presavijaju (fold in) slučaj otkazivanja unutra kao jedan prvoklasni rezultat umjesto kao neku pogrešku (error)
Stvar (point) koja uhvati ljude jest to što ciljana (destination) bitmapa sadrži nakon prsCancelled. Ona nije prazna (blank). PDFium renderira progresivno u istu bitmapu komad po komad (chunk after chunk), tako da kada otkazivanje zaustavi petlju, bitmapa drži što god je bilo naslikano (painted) do tog trenutka, a to je parcijalna slika: neke trake (bands) su gotove, a ostatak još uvijek prikazuje boju ispunjavanja (fill colour). Je li taj parcijalni rezultat koristan ovisi o onome tko je to pozvao (caller). Preglednik (viewer) koji se upravo sprema baciti bitmapu ća (throw away) zato što je korisnik navigirao negdje drugdje može je jednostavno ignorirati. Preglednik koji želi prikazati jedan jeftini (low-cost) pregled (preview) može je zadržati. Ono što vi ne smijete napraviti jest pretpostaviti da prsCancelled implicira praznu (empty) ili nedefiniranu bitmapu; ono implicira jednu istinitu snimku (snapshot) jednog nedovršenog renderiranja
var
Bmp: TBitmap;
Token: IPdfCancellationToken;
Status: TPdfProgressiveStatus;
begin
Bmp := TBitmap.Create;
try
// Token starts un-cancelled; flip Token.IsCancelled from elsewhere
// (a UI action, a navigation event) to abort the render in flight.
Status := Pdf.RenderPageProgressive(Bmp, 0, 0, PageW, PageH, Token);
case Status of
prsDone: Image1.Picture.Assign(Bmp); // fully rendered
prsCancelled: ; // partial bitmap, usually discarded
prsFailed: ShowMessage('Render failed');
end;
finally
Bmp.Free;
end;
end;
Nil token i putanja povratnog poziva bez grananja (branch-free)
Otkazivanje je po vlastitom izboru (opt-in). Pozivatelj (caller) koji samo želi progresivno renderiranje zbog koristi isporučivanja poruka (message-pumping benefit), bez ikakve namjere za prekidanjem (aborting), bi trebao moći proslijediti nil za token. Naivni način da se to podrži jest da se razbacaju provjere tipa "ako je token isporučen" kroz povratni poziv i petlju, što znači grananje na svakom komadu i povratni poziv koji mora rukovati (handle) i s pravim tokenom i s njegovom odsutnošću (absence)
Implementacija to izbjegava supstitucijom (substituting) jednog singletona onda kada pozivatelj (caller) ne proslijedi ništa. Jedan nil token se mijenja (swapped) za PdfNoCancellationToken, jedno sučelje čije je IsCancelled uvijek false. Od te točke povratni poziv i petlja imaju token za preispitivanje (query) u svakom pojedinom slučaju, tako da niti jedno ne treba neku nil provjeru i niti jedno ne treba neki poseban put (path). Token koji se nikada ne otkazuje (never-cancel token) jednostavno uvijek odgovara sa false, povratni poziv uvijek vraća nulu, a renderiranje se pokreće do samog kraja (completion) točno onako kako bi i jedno ne-otkazivo (non-cancellable). Opcionalno ponašanje se modelira kao token koji se nikada ne okine (fires) umjesto kao odsustvo (absence) tokena, što vrući put (hot path) drži uniformnim
// nil -> never-cancel singleton, so the callback path is identical
// whether or not the caller opted into cancellation.
if AToken <> nil then
EffectiveToken := AToken
else
EffectiveToken := PdfNoCancellationToken;
Oblik koji se pojavljuje je mali i vrijedan ponovnog navođenja (restating), zato što to jest onaj ponovno upotrebljivi (reusable) dio. C biblioteka koja podržava povratni poziv vam daje točno jedan kanal za prosljeđivanje stanja unutar tog povratnog poziva, neprozirni (opaque) user pokazivač. Stavite jednu izbrojanu (counted) referencu Pascal sučelja iza tog pokazivača, održavajte jednu drugu pravu referencu živom pored strukture tako da se objekt ne može prikupiti (collected) u pola poziva (mid-call), i čitajte sučelje natrag prema van (back out) unutar jedne statičke cdecl funkcije. Omotajte (Wrap) čitavu pogonsku petlju (drive loop) u jedan try i oslobodite (free) nativni kontekst u finally. Taj isti predložak (template) prenosi se (carries over) na bilo koju progresivnu ili PDFium operaciju pokretanu povratnim pozivom (callback-driven) gdje Pascal kod mora ostati u kontroli nad životnim vijekom (lifetime) dok C drži pokazivač
Otkazivanje je samo jedna polovica jednog responzivnog preglednika (responsive viewer). Druga polovica je ne ponavljati renderiranje (re-rendering) stranica koje ste već iscrtali (drew), te održavati zumiranje i listanje (scroll) glatkim (smooth) servirajući predmemorirane (cached) bitmape, što je sve pokriveno u našem članku o predmemoriranju renderiranja i performansama zumiranja (our article on render cache and zoom performance). Za to kako se otkazivo renderiranje uklapa u jedan kompletni preglednik (viewer) uz samu navigaciju, selekciju i pretraživanje, pogledajte izgradnju PDF preglednika bogatog značajkama (feature-rich) uz PDFium VCL komponentu (building a feature-rich PDF viewer with the PDFium VCL component). Ovdje opisano progresivno renderiranje isporučuje se kao dio PDFium Component za Delphi i Lazarus uz API-je za učitavanje, renderiranje, i obrasce (form APIs) koji su pokriveni negdje drugdje na ovom blogu