Tehnički članak

Otkazivo progresivno PDF renderovanje u Delphi-ju (PDFium)

Većina PDF stranica se rasterizuje za nekoliko milisekundi i nikad ne mislite o tome. Zatim korisnik otvori A1 inženjerski crtež, stranicu nabijenu desetinama hiljada vektorskih poteza, ili poster prepun transparency grupa i mekih maski, i jedan poziv koji ga slika traje dvije ili tri sekunde. Ako taj poziv teče na UI niti, prozor prestaje repaintovati, naslovnа traka sijedi, i operativni sistem nudi da ubije aplikaciju. Posao je legitiman. Stranica zaista treba toliko dugo. Nedostatak je što je renderovanje jedan nedjeljiv blokirajući poziv bez načina da dođe do daha i bez načina da stane

Ovaj članak je o tačno jednom od ta dva problema: otkazivanju dugog renderovanja jedne stranice bez smrzavanja UI-a. Korisnik je kliknuo sljedeću stranicu, ili zumirao, ili zatvorio dokument, i renderovanje u letu je sada izgubljen posao koji bi trebalo završiti na sljedećoj prilici umjesto da trči do kraja. Zaglađivanje skrolanja i zuma keširanjem onoga što je već rasterizovano je zasebna briga s vlastitim dizajnom, obrađena u pratećem članku linkovanom na kraju. Ovdje je jedino pitanje kako natjerati jedno progresivno renderovanje da brzo i čisto odgovori na zahtjev za otkazivanjem

API progresivnog renderovanja koji PDFium već isporučuje

PDFium je predvidio problem smrzavanja. Uz jednokratni FPDF_RenderPageBitmap, izlaže progresivnu varijantu koja dijeli stranicu na dijelove posla. Pozovete FPDF_RenderPageBitmap_Start jednom da postavite renderovanje prema odredišnoj bitmapu, zatim pozivate FPDF_RenderPage_Continue ponavljano. Svaki Continue rasterizuje ograničeni isječak i vraća status. FPDF_RENDER_TOBECONTINUED znači da ima još posla, FPDF_RENDER_DONE znači da je stranica završena, i FPDF_RENDER_FAILED znači da je stala na grešci. Kada petlja završi pozovete FPDF_RenderPage_Close da oslobodite progresivno stanje po stranici. Jer se kontrola vraća vašem kodu između isječaka, možete pumpati poruke, ažurirati indikator napretka, ili provjeriti da li je posao još uvijek potreban

Mehanizam koji PDFium pruža za odlučivanje kada popustiti je callback struktura nazvana IFSDK_PAUSE. Predajete je Start-u i svakom Continue-u. Nakon svakog isječka PDFium poziva njen pokazivač funkcije NeedToPauseNow, i ako taj vrati nenultu vrijednost, trenutni Continue staje rano i predaje kontrolu natrag s FPDF_RENDER_TOBECONTINUED. Struktura takođe nosi polje version, koje mora biti postavljeno na 1, i slobodan pokazivač user koji PDFium nikad ne dodiruje i proslijeđuje netaknutim. Taj nedotaknuti pokazivač je cijela šarka dizajna koji slijedi

Prenamjena pauze kao otkazivanja

Originalna namjena NeedToPauseNow je vremensko rezanje. Vrati nenultu vrijednost kada je vaš budžet frejmova potrošen, vrati nulu da nastaviš renderovanje, i PDFium pauzira da možeš nešto drugo raditi prije nastavka istog renderovanja. PDFium Component ponovo koristi taj isti signal za drugačiji glagol. Umjesto odgovaranja "treba li pauzirati i pustiti vas da nastavite," callback odgovara "je li ovaj posao otkazan." Ova dva se čisto mapiraju jedan na drugi zbog onoga što petlja radi kada vidi zastavicu. Prava pauza očekuje kasniji Continue; otkazivanje ne. Čim pozivna petlja primijeti da je token otkazan, ona zatvara kontekst renderovanja i nikad više ne poziva Continue, pa isti nenulti povrat koji PDFium čita kao "zaustavi ovaj isječak" postaje, u efektu, "zaustavi zauvijek."

Otkazivanje je izraženo kroz interfejs, IPdfCancellationToken, čije svojstvo IsCancelled prelazi s netačnog na tačno kada neki drugi dio programa zatraži da renderovanje stane. Most između tog Pascal interfejsa i PDFium-ovog C callback-a je jedan jedini pokazivač. Referenca interfejsa tokena je upisana u IFSDK_PAUSE.user, i statički cdecl callback je čita natrag i upituje ga. Ovo je klasičan problem dopuštanja C biblioteci da pozove natrag u Pascal: callback mora biti obična funkcija s C konvencijom pozivanja, ne metoda, jer PDFium pohranjuje i poziva goli pokazivač funkcije koji ne zna ništa o Pascal objektima ili Self-u

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 oporavlja token kastovanjem pThis^.user natrag na tip interfejsa i čita IsCancelled. Ništa u njemu ne alocira, ne zaključava, niti blokira, što je bitno jer ga PDFium poziva na niti renderovanja nakon svakog isječka i svaki posao obavljen ovdje se dodaje na cijenu samog renderovanja. Zaštita od nil strukture ili nil polja user znači da je ista funkcija sigurna za instalaciju čak i na renderovanju koje nikada nije dobilo pravi token

Držanje tokena živim tokom petlje

Kastovanje pokazivača interfejsa kroz sirovi Pointer i natrag je gdje se rađaju greške životnog vijeka. IInterface u Delphi-ju je referentno brojan, i broj se kreće samo kada kompajler može vidjeti varijablu tipa interfejsa koja se dodjeljuje. Pohrana tokena isključivo kao goli pokazivač unutar IFSDK_PAUSE.user bi ga potpuno sakrila od referentnog brojača. Da bi jedina druga referenca na taj token izašla iz opsega dok je petlja Continue još bila u toku, objekt bi bio oslobođen ispod callback-a, i sljedeći isječak bi dereferencirao viseći pokazivač

Zato je deskriptor zapis koji drži dvije stvari, ne jednu. Polje Pause je struktura koju PDFium čita. Polje Token je prava referenca tipa interfejsa koju kompajler broji, i postoji iz nikakvog drugog razloga nego da prikoviče token u memoriji dok zapis živi. Zapis je lokalna varijabla na stogu rutine renderovanja, pa ostaje valjan za cijelo trajanje petlje i urušava se tek kada rutina izađe. Goli pokazivač u user-u i prebrojana referenca u Token-u imenuju isti objekt; jedan je ono što PDFium može čitati, drugi je ono što sprečava taj objekt od sakupljanja

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 renderovanja bez obzira kako petlja završi

Svaki poziv FPDF_RenderPageBitmap_Start alocira progresivno stanje koje PDFium asocira sa stranicom, i to stanje se oslobađa samo putem FPDF_RenderPage_Close. Postoje tri izlaza iz petlje pogona. Stranica završi i posljednji status je FPDF_RENDER_DONE. Token se okine i petlja izlazi rano prijavljujući otkazivanje. Nešto ne uspije i status je FPDF_RENDER_FAILED. Svi tri moraju pozvati Close, i putanja otkazivanja je najlakša za pogriješiti, jer prirodni oblik "vidi otkaz, izađi" teži preskočiti čišćenje na putu prema izlazu. Ostavljanje Close-a nedosegnutim propušta stanje po stranici, i preglednik koji korisniku dozvoli otkazivanje renderovanja za renderovanjem bi akumulirao to propuštanje na svakoj prekinutoj stranici

Robusni oblik stavlja petlju i klasifikaciju rezultata unutar try-a i FPDF_RenderPage_Close u odgovarajući finally. Odredišna bitmapa se uništava u istom bloku. Otkazivanje može napustiti petlju kroz rani Exit i finally se još uvijek pokreće, pa postoji tačno jedno mjesto koje oslobađa progresivno stanje i ne može biti zaobiđeno

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-a kao i oslanjajući se na callback unutar njega. Callback skraćuje trenutni isječak; provjera petlje sprečava sljedeći od pokretanja. Zajedno ograničavaju koliko dugo otkaz traje da stupi na snagu na otprilike trajanje jednog isječka

Tri ishoda, i šta bitmapa drži nakon otkazivanja

Javna ulazna tačka je TPdf.RenderPageProgressive, i vraća TPdfProgressiveStatus koji je jedan od prsDone, prsCancelled, ili prsFailed. Vrijednosti zrcale PDFium-ove FPDF_RENDER_* konstante u Pascal idiomi ali uvode slučaj otkazivanja kao prvoklasni rezultat umjesto greške

Tačka koja uhvati ljude je šta odredišna bitmapa sadrži nakon prsCancelled. Nije prazna. PDFium renderuje progresivno u istu bitmapu isječak za isječkom, pa kada otkaz zaustavi petlju, bitmapa drži sve što je bilo naslikano do tog trenutka, što je djelomična slika: neke trake gotove, ostalo još uvijek prikazuje boju punjenja. Da li je taj djelomični rezultat koristan ovisi o pozivaocu. Preglednik koji namjerava baciti bitmapu jer je korisnik navigirao drugdje može je jednostavno ignorisati. Preglednik koji želi pokazati jeftini pregled je može zadržati. Ono što ne smijete raditi je pretpostavljati da prsCancelled implicira praznu ili nedefinianu bitmapu; implicira vjerni snimak nedovršenog renderovanja

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 grana

Otkazivanje je opt-in. Pozivalac koji samo želi progresivno renderovanje zbog koristi pumpanja poruka, bez namjere prekidanja, treba moći proslijediti nil za token. Naivni način podrške ovome je rasipanje provjera "ako je token bio snabdjeven" kroz callback i petlju, što znači granu na svakom isječku i callback koji mora rješavati i pravi token i njegovo odsustvo

Implementacija to izbjegava zamjenom singletona kada pozivalac ne proslijedi ništa. nil token se zamjenjuje s PdfNoCancellationToken, interfejsom čiji je IsCancelled uvijek netačan. Od te točke callback i petlja imaju token za upitivanje u svakom slučaju, pa niti jedan ne treba nil provjeru i niti jedan ne treba posebnu putanju. Token koji nikad ne otkazuje jednostavno uvijek odgovara netačno, callback uvijek vraća nulu, i renderovanje ide do kraja tačno kao što bi neotkazivo renderovanje radilo. Opcionalno ponašanje je modelirano kao token koji se nikad ne okida umjesto kao odsustvo tokena, što drži toplu putanju uniformnom

// 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 vrijedi ponoviti, jer je to dio koji se može ponovo koristiti. C biblioteka koja podržava callback daje vam tačno jedan kanal za prolaz stanja u taj callback, neprozirni korisnički pokazivač. Stavite prebrojanu Pascal referencu interfejsa iza tog pokazivača, zadržite drugu pravu referencu živom pored strukture da objekt ne može biti sakupljen usred poziva, i pročitajte interfejs natrag unutar statičke cdecl funkcije. Omotajte cijelu petlju pogona u try i oslobodite nativni kontekst u finally. Isti šablon se prenosi na bilo koju progresivnu ili callback-vođenu PDFium operaciju gdje Pascal kod mora ostati u kontroli životnog vijeka dok C drži pokazivač

Otkazivanje je samo polovina responzivnog preglednog programa. Druga polovina je ne-rerenderovanje stranica koje ste već nacrtali, i zaglađivanje zuma i skrolanja serviranjem keširanih bitmapa, što je obrađeno u našem članku o keširanju renderovanja i performansama zuma. Za to kako se otkazivo renderovanje uklapa u kompletan preglednik uz navigaciju, selekciju i pretragu, pogledajte izgradnju preglednika PDF-a bogatog funkcijama s PDFium VCL komponentom. Progresivno renderovanje opisano ovdje isporučuje se kao dio PDFium Component-a za Delphi i Lazarus uz učitavanje, renderovanje i form API-je obrađene drugdje na ovom blogu