Tehnički članak

Progresivno PDF renderovanje u Delphiju (PDFium) uz mogućnost otkazivanja

Većina PDF stranica se rasterizuje u par milisekundi i o tome nikada ne razmišljate. Zatim korisnik otvori A1 inženjerski crtež, stranicu krcatu desetinama hiljada vektorskih poteza, ili poster pretrpan grupama transparentnosti i mekim maskama (soft masks), i jedan jedini poziv koji ga iscrtava traje dve ili tri sekunde. Ukoliko taj poziv radi u UI thread-u, prozor prestaje da se osvežava (repainting), naslovna traka postaje siva, a operativni sistem nudi da ubije aplikaciju. Posao je pritom legitiman. Stranici je zaista potrebno toliko vremena. Defekt leži u tome što je renderovanje jedan nedeljiv blokirajući poziv bez načina da se uzme vazduh i bez načina da se zaustavi

Ovaj članak se bavi tačno jednim od ta dva problema: otkazivanjem dugog iscrtavanja (render) jedne stranice bez zamrzavanja UI-a. Korisnik je kliknuo na sledeću stranicu, zumirao, ili zatvorio dokument, a renderovanje koje je u letu (in flight) sada je uzaludan posao koji bi trebalo da se završi prvom prilikom radije nego da radi do samog kraja. Omekšavanje (smoothing) skrolovanja i zumiranja putem keširanja onoga što je već rasterizovano predstavlja poseban problem sa sopstvenim dizajnom, pokriven u pratećem članku na koji je data veza (link) na kraju. Ovde je jedino pitanje kako da jedno progresivno renderovanje odgovori na zahtev za otkazivanje na brz i čist način

API za progresivno renderovanje koji se već isporučuje sa PDFium-om

PDFium je anticipirao polovinu problema koja se tiče zamrzavanja. Pored FPDF_RenderPageBitmap koji iscrtava sve u jednom cugu (one-shot), on izlaže (exposes) progresivnu varijantu koja deli stranicu na komadiće posla. Jednom pozivate FPDF_RenderPageBitmap_Start da podesite iscrtavanje prema ciljnoj bitmapi, a zatim u više navrata pozivate FPDF_RenderPage_Continue. Svaki Continue rasterizuje ograničeni presek (slice) i vraća status. FPDF_RENDER_TOBECONTINUED znači da ima još toga da se uradi, FPDF_RENDER_DONE znači da je stranica završena, a FPDF_RENDER_FAILED da je zaustavljena zbog greške. Kada se petlja završi, vi pozivate FPDF_RenderPage_Close da bi oslobodili progresivno stanje po stranici (per-page progressive state). Budući da se kontrola vraća vašem kodu između preseka, vi možete da pumpate poruke, osvežavate indikator napretka, ili proverite da li je započeti rad i dalje uopšte poželjan

Mehanizam koji PDFium pruža za odlučivanje kada da prepusti (yield) izvršavanje je povratna struktura (callback struct) po imenu IFSDK_PAUSE. Nju predajete u Start i svaki Continue. Nakon svakog komadića, PDFium poziva svoj NeedToPauseNow pokazivač na funkciju, i ukoliko on vrati ne-nula (non-zero) vrednost, trenutni Continue se prevremeno zaustavlja i vraća kontrolu nazad uz status FPDF_RENDER_TOBECONTINUED. Struktura takođe nosi polje version, koje mora biti postavljeno na 1, i pokazivač user slobodne forme (free-form) kog PDFium nikada ne dodiruje i koji prolazi netaknut. Taj netaknuti pokazivač je šarka (hinge) za ceo dizajn koji sledi

Pauza prenamenjena kao otkazivanje

Prvobitna namera NeedToPauseNow jeste vremensko seckanje (time-slicing). Vratite ne-nula vrednost kada je vaš frejm-budžet (frame budget) istrošen, vratite nulu da nastavite renderovanje, i PDFium pauzira tako da možete obaviti nešto drugo pre nego što nastavite isti taj render. Komponenta PDFium Component koristi isti taj signal za drugi glagol. Umesto da odgovori sa "treba li da pauziram i dozvolim ti da nastaviš kasnije," povratni poziv (callback) odgovara "da li je ovaj rad otkazan." Ova dva se preslikavaju jedan na drugog sasvim čisto zbog onog što petlja uradi kada vidi zastavicu. Originalna, prava pauza očekuje kasniji Continue; otkazivanje ne očekuje. Kada petlja koja poziva primeti da je token otkazan, ona zatvara kontekst renderovanja i više nikada ne poziva Continue, tako da ista ona ne-nula povratna vrednost koju PDFium čita kao "zaustavi ovaj komadić" zapravo postaje "zaustavi zauvek."

Otkazivanje se izražava kroz interfejs IPdfCancellationToken, čije se svojstvo IsCancelled prebacuje (flips) sa false na true kada neki drugi deo programa zatraži da se renderovanje zaustavi. Most između tog Pascal interfejsa i PDFium-ovog C callback-a jeste jedan obični pokazivač. Referenca interfejsa na token zapisuje se u IFSDK_PAUSE.user, a statički cdecl callback ga čita odatle i ispituje. Ovo je klasični problem dopuštanja C biblioteci da pozove nazad u Pascal: povratni poziv (callback) mora biti prosta funkcija sa pozivnom konvencijom iz C-a, a ne metoda, zato što PDFium skladišti i aktivira goli (bare) pokazivač na funkciju koji ne zna ništa o Pascal objektima ili Self ključnoj reči

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;

Callback povrati token tako što kastuje (casting) pThis^.user nazad u tip interfejsa i čita IsCancelled. Unutar njega se ništa ne alocira (allocates), ne zaključava (locks), niti blokira, što je važno zato što PDFium to poziva na render thread-u posle svakog komadića i svaki rad urađen ovde se dodaje na trošak samog renderovanja. Gardijent (guard) protiv nil strukture ili nil polja za user znači da je potpuno bezbedno instalirati tu istu funkciju čak i na iscrtavanje kom nikada nije dat pravi token

Održavanje tokena u životu tokom petlje

Kastovanje (casting) pokazivača na interfejs preko sirovog Pointer tipa i nazad je mesto na kom se rađaju bagovi sa životnim vekom. Interfejs tipa IInterface u Delphiju prati svoje brojanje referenci (reference counted), a taj broj (count) se menja samo kada kompajler može da vidi da je varijabli koja je tipa interfejsa dodeljena vrednost. Skladištenje tokena isključivo kao golog pokazivača unutar IFSDK_PAUSE.user u potpunosti bi ga sakrilo od brojača referenci. Ukoliko bi jedina preostala referenca na taj token izašla iz obima (went out of scope) dok Continue petlja još uvek radi, objekat bi se oslobodio tik ispod povratnog poziva, a sledeći komadić bi dereferencirao viseći (dangling) pokazivač

Zbog toga je deskriptor ustvari zapis (record) koji drži dve stvari, a ne jednu. Polje Pause je struktura koju PDFium čita. Polje Token je prava referenca koja nosi tip interfejsa koju kompajler broji, a koja ne postoji iz drugih razloga nego samo da fiksira (pin) token u memoriji sve dok taj zapis (record) živi. Zapis je lokalna varijabla na steku rutine za renderovanje, pa tako on ostaje validan čitavim trajanjem petlje i uništava se samo kada se rutina završi. Goli (bare) pokazivač u user polju i brojana (counted) referenca u polju Token imenuju isti objekat; jedan je onaj što PDFium može da pročita, dok drugi drži taj objekat tako da ne može biti sakupljen kao đubre (garbage 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 iscrtavanja bez obzira na to kako se petlja završava

Svaki poziv ka FPDF_RenderPageBitmap_Start dodeljuje progresivno stanje koje PDFium povezuje sa tom stranicom, a to stanje oslobađa isključivo FPDF_RenderPage_Close. Postoje tri puta kojima se izlazi iz glavne pogonske petlje (drive loop). Stranica se završava i poslednji status je FPDF_RENDER_DONE. Token se okine i petlja izađe rano prijavljujući otkazivanje. Nešto ne uspe i status bude FPDF_RENDER_FAILED. Sva tri scenarija moraju da pozovu Close, a na putanji sa otkazivanjem je najlakše pogrešiti, jer prirodni oblik "vidim da je otkazano, bežim" ima tendenciju preskakanja raščišćavanja na svom putu ka izlazu. Ako ostavite Close nedostupnim (unreached) napravićete curenje stanja (leaks the per-page state) za dotičnu stranicu, a pregledač (viewer) koji korisniku dopušta da otkaže seriju iscrtavanja gomilao bi to curenje sa svakom prekinutom stranicom

Robustan oblik pakuje petlju i klasifikaciju rezultata u try a FPDF_RenderPage_Close u odgovarajući finally. Ciljna bitmapa (destination bitmap) se uništava u istom bloku. Otkazivanje može da napusti petlju preko ranog Exit koda, a finally blok i dalje radi, tako da postoji tačno jedno mesto 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 proverava token pre svakog poziva Continue pored toga što se oslanja na povratni poziv (callback) unutar nje. Povratni poziv skraćuje tekući komadić; provera unutar petlje sprečava početak onog sledećeg. Zajedničkim snagama ograničavaju (bound) koliko je vremena potrebno da otkazivanje ostvari efekat na otprilike dužinu trajanja samo jednog komadića

Tri ishoda i šta bitmapa drži nakon otkazivanja

Javna ulazna tačka je TPdf.RenderPageProgressive, i ona vraća TPdfProgressiveStatus koja je jedno od: prsDone, prsCancelled, ili prsFailed. Vrednosti oslikavaju PDFium-ove FPDF_RENDER_* konstante u Pascal idiomu, ali ugrađuju (fold) slučaj sa otkazivanjem kao prvoklasni (first-class) rezultat pre nego grešku

Ono mesto na kom se ljudi zapetljaju je to šta ciljna bitmapa sadrži nakon prsCancelled. Nije prazna. PDFium iscrtava progresivno (renders progressively) u istu bitmapu, komadić po komadić, pa kada otkazivanje zaustavi petlju, bitmapa drži sve ono što je iscrtano do tog trenutka, što je delimična slika (partial image): neke trake završene, ostatak koji i dalje pokazuje boju pozadine (fill colour). Da li je taj delimični rezultat koristan zavisi od klienta (caller). Pregledač (viewer) koji se upravo sprema da odbaci tu bitmapu jer je korisnik odnavigirao na neko drugo mesto može jednostavno da je ignoriše. Pregledač koji želi da prikaže jeftin pretpregled (low-cost preview) može je sačuvati. Ono što ne smete (must not) je da pretpostavite da prsCancelled implicitno povlači praznu ili nedefinisanu bitmapu; ono samo povlači istinit snimak iz nezavršenog iscrtavanja (unfinished render)

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 callback-a bez grananja (branch-free)

Otkazivanje je opt-in (nešto što sami morate da uključite). Klijent koji samo želi progresivno iscrtavanje (render) zbog pogodnosti pumpanja poruka, bez ikakve namere abortiranja procesa, trebalo bi da može da prosledi nil za token. Naivan način da to podržite je da rasturite (scatter) provere forme "ukoliko je token prosleđen" kroz callback i petlju, što znači skretanje (granu, branch) na svakom komadiću i callback koji mora da obradi i pravog tokena i njegovo odsustvo

Implementacija ovo izbegava podmetanjem singleton-a kada klijent ne prosledi ništa. nil token se menja za PdfNoCancellationToken, interfejs čije je svojstvo IsCancelled uvek false. Odatle pa na dalje i callback i petlja uvek imaju token koji mogu da upitaju u svakom scenariju, pa ni jednom ni drugom nije potrebna nil-provera niti posebna putanja. Nikad-ne-otkaži-token (never-cancel token) naprosto na svaki zahtev daje false odgovor, callback uvek daje povratnu vrednost jednaku nuli, i iscrtavanje radi do svog kraja baš onako kako bi radio i onaj kom nije dozvoljeno otkazivanje. Opciono ponašanje modelovano je kao token koji ne opaljuje rađe nego odsustvo samog tokena, što drži uniformnost vruće putanje (hot path)

// 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 izroni iz ovoga je mali i vredi ga ponovo izneti jer je on zapravo deo koji se može ponovo koristiti. C biblioteka (C library) koja pruža podršku za povratni poziv (callback) daje vam tačno jedan kanal da progurate stanje u povratni poziv, a to je mutni pokazivač korisnika (opaque user pointer). Stavite prebrojanu Pascal interfejs referencu (counted Pascal interface reference) iza tog pokazivača, držite drugu stvarnu referencu živom blizu te strukture (struct) pa se onda ne može pokupiti objekat negde nasred poziva (mid-call), i onda u statičkoj cdecl funkciji očitajte taj interfejs. Umotajte čitavu pogonsku petlju (drive loop) u jednom try-u i onda se u pripadajućem finally rešite konteksta za taj sistem. Isti predložak (template) se prenosi i na svaki mogući PDFium proces baziran na callback operacijama tamo gde Pascal kod mora zadržati prevlast i kontrolu nad životnim vekom sve dotle dok C drži jedan pokazivač

Otkazivanje je samo jedna polovina rešivog (responsive) pregledača. Druga polovina je ne-renderovanje stranica koje ste već iscrtali, kao i držanje zuma (zoom) i skrolovanja mekim serviranjem keširanih bitmapa, što je pokriveno u našem članku o keširanju rendera i performansi zuma. Na pitanje o tome kako se otkaziv render (cancellable render) uklapa u celokupni pregledač uz navigaciju, selekciju, i potragu, pogledajte o građenju PDF pregledača bogatog funkcijama korišćenjem komponente PDFium Component. Progresivni render opisan ovde isporučuje se kao deo komponente PDFium Component namenjene u Delphi i Lazarus okruženja pored mnogobrojnih API-ja za učitavanje, iscrtavanje i upravljanje formama koji su opisani negde na ovom blogu