Večina PDF strani se rastrira (rasterise) v zgolj nekaj milisekundah in sploh ne pomislite na to. Potem pa uporabnik odpre inženirsko risbo formata A1, stran natrpano z več deset tisoč vektorskimi potezami, ali pa plakat, prenatrpan s skupinami prosojnic (transparency groups) in mehkimi maskami (soft masks), nakar en sam klic, ki takšno stran nariše, vzame dve ali tri sekunde. Če se ta klic izvaja na niti uporabniškega vmesnika (UI thread), se okno neha na novo izrisovati, naslovna vrstica posivi, operacijski sistem pa ponudi, da ubije aplikacijo (kill the application). Delo, ki se izvaja, je legitimno. Stran resnično potrebuje tako dolgo za izris. Napaka je v tem, da je upodabljanje (render) zgolj en nedeljiv blokirajoč klic brez vsakršne možnosti, da bi zajel zrak, in nima nobenega načina za zaustavitev
Ta članek obravnava točno enega izmed teh dveh problemov: kako preklicati dolgo upodabljanje posamezne strani, ne da bi pri tem zamrznil uporabniški vmesnik. Uporabnik je morda kliknil na naslednjo stran, uporabil povečavo ali zaprl dokument, upodabljanje, ki je še vedno v teku, pa sedaj predstavlja zapravljeno delo in se mora končati ob prvi priložnosti, namesto da bi se izvajalo vse do konca. Glajenje pomikanja (scroll) in povečave (zoom) z uporabo predpomnilnika za stvari, ki so že bile rastrirane, je povsem ločena skrb s svojim lastnim načrtovanjem in je obravnavana v spremljevalnem članku na koncu tega prispevka. Tukaj nas zanima zgolj vprašanje, kako doseči, da eno progresivno upodabljanje odgovori na zahtevo po preklicu hitro in čisto
API za progresivno upodabljanje, ki je že priložen v PDFium
PDFium je predvidel tisto polovico problema, ki povzroča zamrzovanje. Ob enkratnem klicu FPDF_RenderPageBitmap razkriva še progresivno različico, ki stran razdeli v več kosov dela (chunks of work). Metodo FPDF_RenderPageBitmap_Start pokličete enkrat, da z njo nastavite upodabljanje proti ciljni bitni sliki, nato pa večkrat zaporedoma kličete FPDF_RenderPage_Continue. Vsak klic Continue rastrira en zamejen reženj in vrne status. Status FPDF_RENDER_TOBECONTINUED pomeni, da je ostalo še več dela, FPDF_RENDER_DONE pomeni, da je stran dokončana, FPDF_RENDER_FAILED pa, da se je postopek ustavil ob napaki. Ko se zanka zaključi, pokličete FPDF_RenderPage_Close in na ta način sprostite progresivno stanje posamezne strani (per-page progressive state). Ker se nadzor med izvajanjem režnjev vrača nazaj k vaši kodi, lahko brez težav črpate sporočila (pump messages), osvežite indikator napredka ali pa preverite, ali je izvajano delo sploh še zaželeno
Mehanizem, ki ga PDFium ponuja za odločanje o tem, kdaj prepustiti izvajanje (yield), predstavlja povratnoklicna (callback) struktura po imenu IFSDK_PAUSE. Izročite jo metodam Start in vsaki Continue. Po vsakem kosu dela PDFium pokliče njen funkcijski kazalec (function pointer) NeedToPauseNow, in če ta vrne neničelno vrednost (non-zero value), se trenutni Continue prehitro zaustavi in z vrednostjo FPDF_RENDER_TOBECONTINUED preda nadzor nazaj. Struktura nosi s sabo še polje version, ki mora biti nastavljeno na 1, ter kazalec proste oblike (free-form) z imenom user, ki se ga PDFium nikoli ne dotakne in se takšen nedotaknjen zgolj pretaka skozi sistem. Prav ta nedotaknjeni kazalec predstavlja celoten tečaj zasnove, ki sledi
Spreminjanje namena pavze v preklic (cancel)
Prvotni namen funkcije NeedToPauseNow je rezanje časa (time-slicing). Ko vaš proračun sličic (frame budget) poide, vrnete neničelno vrednost in PDFium naredi premor, da lahko naredite kaj drugega, preden ponovno nadaljujete (resume) z istim upodabljanjem; če vrnete ničlo, se upodabljanje nemoteno nadaljuje. Komponenta PDFium ta isti signal znova uporabi za povsem drug glagol. Namesto da bi povratni klic (callback) odgovarjal na vprašanje "naj naredim premor in ti dopustim, da nadaljuješ," zdaj odgovarja na "ali je bilo to delo preklicano." Eden in drugi scenarij se povsem čisto preslikata, zaradi načina, na katerega reagira zanka ob prejetju zastavice. Pristna pavza bo kasneje vsekakor pričakovala klic Continue, preklic (cancel) pač ne. Ko zanka, iz katere se proži klic, opazi, da je žeton preklican (cancelled), preprosto zapre kontekst upodabljanja in klica Continue ne bo izvedla nikoli več, zato se isti neničelni odgovor, ki ga PDFium bere kot "ustavi ta kos," v resnici spremeni v "ustavi se za vedno."
Preklic je izražen prek vmesnika IPdfCancellationToken, katerega lastnost IsCancelled preklopi iz neresnično na resnično vsakič, ko nek drug del programa zahteva, naj se upodabljanje ustavi. Most med tem Pascal vmesnikom in C povratnim klicem orodja PDFium je en sam kazalec. Referenca na vmesnik žetona se zapiše v polje IFSDK_PAUSE.user, statični cdecl povratni klic pa to nato prebere in poizveduje pri njemu. To je povsem klasičen problem pri poskusih, da bi knjižnica v jeziku C izvedla povratni klic (call back) v Pascal: povratni klic mora biti navadna funkcija in imeti mora C konvencijo klicev (calling convention), ne pa metoda, saj PDFium shranjuje in prikliče povsem goli funkcijski kazalec, ki ne ve absolutno ničesar o Pascal objektih ali kazalcu 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 klic (callback) pridobi žeton nazaj tako, da pretvori (cast) pThis^.user nazaj v tip vmesnika in prebere IsCancelled. Prav nič v njem ne alocira pomnilnika, ne zaklepa in ne blokira, kar je izjemnega pomena, saj ga PDFium pokliče na upodabljalni niti (rendering thread) po čisto vsakem kosu dela, in kakršno koli delo, opravljeno na tej točki, se prišteje k ceni samega upodabljanja. Zaščita pred ničelno (nil) strukturo ali ničelnim poljem user pomeni, da je povsem varno namestiti to isto funkcijo celo na upodabljanje, ki sploh nikoli ni prejelo pravega žetona
Ohranjanje žetona pri življenju skozi celotno zanko
Pretvorba vmesniškega kazalca prek surovega tipa Pointer in nazaj je prostor, kjer se rodijo hrošči (bugs) življenjske dobe. IInterface v Delphiju uporablja štetje referenc (reference counted) in števec se premakne samo takrat, ko prevajalnik lahko vidi dodeljevanje spremenljivke z vmesniškim tipom. Če bi žeton shranili izključno kot goli kazalec (bare pointer) znotraj polja IFSDK_PAUSE.user, bi ga na ta način popolnoma skrili pred števcem referenc. Če bi edina druga referenca na ta žeton izpadla iz obsega veljavnosti (went out of scope), medtem ko bi se zanka s Continue še vedno izvajala, bi bil objekt sproščen kar med samim izvajanjem povratnega klica (callback), naslednji kos dela pa bi tako dereferenciral viseči kazalec (dangling pointer)
Ravno zato je deskriptor pravzaprav zapis (record), ki drži v sebi dve stvari in ne le ene. Polje Pause je struktura, ki jo bere PDFium. Polje Token pa je prava referenca vmesniškega tipa, ki jo prevajalnik beleži in obstaja zgolj zato, da pripne žeton v pomnilnik za tako dolgo, dokler živi sam zapis. Zapis je sicer lokalna spremenljivka na skladu (stack) upodabljalne rutine, zato ostane veljaven skozi celotno trajanje zanke in je porušen (torn down) šele ob izhodu iz rutine. Goli kazalec v polju user in pa šteta referenca v Token poimenujeta povsem isti objekt; eno je tisto, kar lahko prebere PDFium, drugo pa je to, kar objekt varuje pred smetarjem (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);
Zapiranje konteksta upodabljanja ne glede na to, kako se zanka zaključi
Vsakršen klic FPDF_RenderPageBitmap_Start alocira progresivno stanje, ki ga PDFium poveže s stranjo, to stanje pa lahko nato sprosti izključno FPDF_RenderPage_Close. Obstajajo trije načini za izhod iz pogonske zanke (drive loop). Stran je končana in zadnji status je FPDF_RENDER_DONE. Žeton se sproži in zanka predčasno izstopi s poročanjem o preklicu. Nekaj odpove in status je FPDF_RENDER_FAILED. Vsi trije primeri morajo nujno poklicati Close in ravno pot s preklicem je tista, kjer je najlažje narediti napako, saj naravna oblika "obvesti o preklicu, prekini" pogosto na svoji poti do izhoda preskoči čiščenje. Če pa Close ostane nedosežen, povzroči puščanje (leaks) progresivnega stanja posamezne strani in pregledovalnik, ki bi uporabniku dovoljeval en preklic upodabljanja za drugim, bi z vsako prekinjeno stranjo takšno puščanje kopičil
Robustna oblika postavi zanko in pa klasifikacijo rezultatov znotraj bloka try, FPDF_RenderPage_Close pa v pripadajoči finally. Ciljna bitna slika se uniči v povsem istem bloku. Preklic (cancellation) lahko na ta način zapusti zanko prek predčasnega Exit in blok finally se vseeno izvede, zato sedaj obstaja natanko eno samo mesto, ki poskrbi za sprostitev progresivnega stanja, in tega ni mogoče zaobiti
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;
Zanka pa tudi preveri žeton pred vsakim klicem Continue, poleg tega, da se pri tem zanaša na povratni klic (callback) znotraj njega. Povratni klic skrajša trenutni kos dela; preverjanje zanke pa zaustavi zagon naslednjega. Skupaj tako omejujeta, kako dolgo traja, da preklic (cancel) stopi v veljavo, na približno eno trajanje samega kosa dela
Trije izidi in kaj bitna slika drži v sebi po preklicu
Javna vstopna točka je metoda TPdf.RenderPageProgressive, ki pa vrne TPdfProgressiveStatus in sicer enega izmed prsDone, prsCancelled ali pa prsFailed. Te vrednosti zrcalijo konstante FPDF_RENDER_* orodja PDFium v Pascalovem idiomu, vendar primer preklica (cancellation) združijo kot prvorazredni (first-class) rezultat in ne kot napako
Točka, ki pa ljudi večkrat zmede, pa je ta, kaj točno ciljna bitna slika vsebuje po statusu prsCancelled. Ni prazna. PDFium progresivno upodablja v isto bitno sliko kos za kosom, zato ko preklic ustavi zanko, bitna slika preprosto drži v sebi tisto, kar je bilo do tistega trenutka narisano in to je delna slika: nekateri pasovi so končani, ostali pa še vedno prikazujejo barvo polnila. Ali je ta delni rezultat sploh uporaben, je seveda odvisno od klicatelja. Pregledovalnik, ki bo pravkar odvrgel bitno sliko, ker se je uporabnik že pomaknil drugam, ga lahko preprosto ignorira. Pregledovalnik, ki želi prikazati cenovno ugoden (low-cost) predogled, pa ga lahko brez težav obdrži. Tisto, česar pa absolutno ne smete storiti, je predvidevati, da prsCancelled kakorkoli nakazuje na prazno ali nedefinirano bitno sliko; pomeni zgolj povsem resničen posnetek nedokončanega upodabljanja
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;
Ničelni (nil) žeton in pot povratnega klica brez vej (branch-free)
Preklic je izbiren (opt-in). Klicatelj, ki želi zgolj progresivno upodabljanje za prednosti črpanja sporočil (message-pumping), nima pa nobenega namena prekinjati, bi moral imeti možnost, da namesto žetona posreduje nil. Naiven način za podporo temu bi bil razpršiti preverjanja tipa "če je bil žeton posredovan" skozi povratni klic (callback) in zanko, kar bi pomenilo vejitev pri vsakem kosu dela ter povratni klic, ki bi moral obravnavati tako pravi žeton kot tudi njegovo odsotnost
Izvedba se temu izogne tako, da vsakič, ko klicatelj ne posreduje ničesar, preprosto podtakne singleton. Ničelni (nil) žeton zamenja za PdfNoCancellationToken, vmesnik, katerega lastnost IsCancelled je vedno in povsod neresnična. Od te točke naprej imata povratni klic in zanka žeton, po katerem lahko v vsakem primeru poizvedujeta, tako da nobeden od njiju ne potrebuje preverjanja za nil in prav tako noben ne potrebuje kakšne posebne poti. Žeton "nikoli ne prekliči" preprosto vedno odgovori z neresnično vrednostjo (false), povratni klic vedno vrne ničlo, upodabljanje pa se tako povsem nemoteno izvede do konca, točno tako, kot bi se nepreklicno (non-cancellable). Opcijsko vedenje je tako modelirano kot žeton, ki se nikoli ne sproži, namesto kot odsotnost žetona, to pa ohranja vročo pot (hot path) enakomerno
// 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;
Oblika, ki nastane, je precej majhna in je vredna ponovitve, saj je ravno tisti del, ki ga je mogoče znova uporabiti. C knjižnica, ki podpira povratni klic, vam zagotavlja natanko en kanal za prenos stanja v tisti povratni klic, neprosojen (opaque) uporabniški kazalec (user pointer). Postavite štet Pascal vmesniški kazalec za ta kazalec, obdržite drugo resnično referenco živo ob strukturi, da se prepreči zbiranje objekta s strani smetarja sredi klica, in preberite vmesnik nazaj znotraj statične metode z definicijo cdecl. Ovijte celotno pogonsko zanko v blok try in sprostite izvorni (native) kontekst v pripadajoči zanki finally. Ista predloga se prenaša naprej na katero koli progresivno operacijo PDFium ali na tisto, ki jo poganja povratni klic in pri kateri mora Pascalova koda ohraniti popoln nadzor nad življenjsko dobo, medtem ko C enostavno drži kazalec
Preklic je zgolj prva polovica zelo odzivnega pregledovalnika. Druga polovica predstavlja to, da ne upodabljamo znova tistih strani, ki ste jih že narisali, in ohranjanje gladkega pomikanja s posredovanjem že predpomnjenih bitnih slik (cached bitmaps), kar je obravnavano v našem članku o predpomnjenju upodabljanja in zmogljivosti povečave. Za to, kako se preklicno upodabljanje prilega povsem celovitemu pregledovalniku skupaj z navigacijo, izbiranjem in iskanjem, pa si poglejte izdelava obsežnega pregledovalnika PDF s PDFium komponento. Progresivno upodabljanje, opisano tukaj, je sicer na voljo kot del orodja PDFium Component za Delphi in Lazarus poleg samega nalaganja, upodabljanja in pa API-jev za obrazce, ki so sicer obravnavani nekje drugje na tem spletnem dnevniku (blog)