Tehnički članak

Otkaživo progresivno renderiranje PDF-a u Delphiju (PDFium)

Većina PDF stranica rasterizira se u svega nekoliko milisekundi i o tome nikada niti ne razmišljate. Ali tada korisnik otvori inženjerski nacrt formata A1, stranicu prepunu desetaka tisuća vektorskih poteza, ili pak plakat pretrpan grupama za transparentnost i mekim maskama, i taj jedini poziv zadužen za njezino crtanje potraje dvije do tri sekunde. Ako se taj poziv izvrši u UI dretvi, prozor se prestaje iscrtavati, naslovna traka posijedi, a operacijski sustav ponudi da ugasi aplikaciju. Rad sam po sebi je legitiman. Stranici zaista treba toliko dugo. Pravi nedostatak je to što je renderiranje jedan nedjeljiv, blokirajući poziv bez ikakvog načina da udahne zrak i bez načina da se zaustavi

Ovaj se članak bavi upravo jednim od ta dva problema: otkazivanjem dugačkog renderiranja jedne stranice, a da se pritom ne zamrzne korisničko sučelje. Korisnik je kliknuo na sljedeću stranicu, zumirao je ili pak zatvorio dokument, stoga je trenutno aktivno renderiranje sada samo gubitak rada koji treba završiti prvom prilikom, a ne raditi sve do samoga kraja. Izglađivanje pomicanja i zumiranja pomoću spremanja onoga što je već rasterizirano u predmemoriju sasvim je odvojen problem s vlastitim dizajnom, obrađen u pratećem članku na koji se nalazi poveznica na samom kraju. Ovdje je jedino pitanje kako natjerati jedan progresivni render da na zahtjev za otkazivanjem odgovori brzo i čisto

API za progresivno renderiranje koji PDFium već isporučuje

PDFium je predvidio onu polovinu problema koja se tiče zamrzavanja. Paralelno sa jednokratnim FPDF_RenderPageBitmap, izlaže i progresivnu varijantu koja dijeli stranicu na dijelove rada. Zovete FPDF_RenderPageBitmap_Start samo jednom kako biste postavili render prema odredišnoj bitnoj mapi, a nakon toga ponavljano pozivate FPDF_RenderPage_Continue. Svaki Continue rasterizira omeđenu krišku i vraća status. FPDF_RENDER_TOBECONTINUED znači da ima još posla za obaviti, FPDF_RENDER_DONE označava da je stranica dovršena, dok FPDF_RENDER_FAILED znači da se posao zaustavio zbog pogreške. Kada petlja završi, zovete FPDF_RenderPage_Close kako biste oslobodili progresivno stanje po svakoj stranici. Zato što se kontrola između svakog odsječka vraća vašem kodu, možete pumpati poruke, ažurirati indikator napretka ili pak provjeriti je li taj rad i dalje uopće potreban

Mehanizam koji PDFium pruža za odlučivanje o tome kada treba popustiti jest callback struktura nazvana IFSDK_PAUSE. Proslijeđujete je metodi Start i svakom Continue. Nakon svakog komada posla, PDFium poziva svoj pokazivač funkcije NeedToPauseNow, i ako on vrati vrijednost različitu od nule, trenutni Continue rano se zaustavlja te vraća kontrolu uz status FPDF_RENDER_TOBECONTINUED. Struktura također nosi polje version, koje mora biti postavljeno na 1, te pokazivač slobodnog oblika naziva user koji PDFium nikada ni ne dira već ga provlači netaknutog. Upravo je taj netaknuti pokazivač ključni okov dizajna koji slijedi

Pretvaranje pauze u otkazivanje

Izvorna nakana metode NeedToPauseNow jest raspodjela vremena. Vratite različito od nule kada se vaš proračun za iscrtavanje kadrova potroši, vratite nulu za nastavak renderiranja, a PDFium pauzira kako biste mogli raditi nešto drugo prije nego nastavite s istim tim renderom. Komponenta PDFium Component ponovno koristi taj isti signal, samo za drukčiji glagol. Umjesto da daje odgovor "trebam li pauzirati i prepustiti tebi nastavak", povratni poziv odgovara na "je li ovaj rad otkazan". Ta dva koncepta sasvim se čisto mapiraju jedan na drugi zbog onoga što petlja zapravo napravi kada vidi zastavicu. Prava pauza i dalje očekuje kasniji Continue; otkazivanje ne očekuje. Jednom kada pozivajuća petlja primijeti da je token otkazan, ona zatvara kontekst renderiranja i nikada više ne zove Continue, pa stoga taj isti rezultat različit od nule koji PDFium čita kao "zaustavi ovaj komad" efektivno postaje "zaustavi zauvijek"

Otkazivanje se iskazuje putem sučelja pod nazivom IPdfCancellationToken, čije se svojstvo IsCancelled mijenja iz lažnog u istinito kada neki drugi dio programa zatraži zaustavljanje rendera. Most između tog Pascalovog sučelja i PDFiumovog C povratnog poziva je samo jedan jedini pokazivač. Referenca sučelja navedenog tokena ispisuje se u IFSDK_PAUSE.user, dok ga statički cdecl povratni poziv čita nazad van i ispituje. Ovo je klasičan problem u kojemu C biblioteci omogućujete pozivanje Pascala: taj povratni poziv mora biti najobičnija funkcija s C konvencijom pozivanja, a nikako ne metoda, upravo stoga što PDFium pohranjuje te poziva goli funkcijski pokazivač koji uostalom i ne zna baš ništa o Pascal objektima niti 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 vraća token tako što pThis^.user vraća natrag na tip sučelja te zatim očitava IsCancelled. Ništa u njemu ne obavlja alokaciju, zaključavanje ili blokiranje, što je bitno jer PDFium to poziva u niti za renderiranje i to poslije svakog odrađenog komada, tako da se bilo kakav ovdje obavljeni posao dodaje na trošak samog rendera. Zaštita od nil strukture ili od nil user polja znači da je ovu istu funkciju posve sigurno postaviti i na renderu kojemu nikada nije ni dan pravi token

Održavanje tokena na životu kroz petlju

Pretvaranje pokazivača sučelja putem sirovog Pointer i natrag mjesto je gdje se rađaju greške životnog vijeka. IInterface u Delphiju prati broj svojih referenci, a taj broj raste isključivo kada prevoditelj može vidjeti dodjelu varijable tipa sučelja. Kada biste pohranili token isključivo kao goli pokazivač unutar IFSDK_PAUSE.user, to bi ga u potpunosti sakrilo od brojača referenci. Ako bi pak jedina preostala referenca na taj token izašla iz opsega dok se petlja Continue još uvijek izvršavala, sami bi se objekt oslobodio podno povratnog poziva, pa bi sljedeći komad posla dereferencirao viseći pokazivač

I to je razlog zašto je taj deskriptor zapravo zapis koji sadrži dvije stvari, a ne tek jednu. Polje Pause je struktura koju PDFium iščitava. Polje Token prava je referenca tipa sučelja koju kompilator broji, i ista ne postoji iz nijednog drugog razloga osim da bi učvrstila token u memoriji onoliko dugo koliko zapis živi. Taj zapis je pak lokalna varijabla na stogu same rutine za renderiranje, pa zato ostaje valjan tijekom čitavog trajanja petlje i uklanja se isključivo tek na izlasku iz rutine. Goli pokazivač u user kao i brojana referenca u Token imenuju jedan te isti objekt; jedno je ono što PDFium može čitati, a drugo je ono što zapravo sprečava da se taj objekt sakupi

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ši

Svaki pojedini poziv na FPDF_RenderPageBitmap_Start dodjeljuje progresivno stanje koje PDFium pridružuje određenoj stranici, a to se stanje oslobađa isključivo putem metode FPDF_RenderPage_Close. Postoje tri načina izlaza iz pogonske petlje. Stranica se završava i posljednji status postane FPDF_RENDER_DONE. Token se okine te petlja izlazi rano, pritom prijavljujući otkazivanje. Nešto pođe po zlu pa status postane FPDF_RENDER_FAILED. Sva tri scenarija jednostavno moraju pozvati Close, a pritom je put otkazivanja baš onaj gdje ćete najlakše pogriješiti, prvenstveno zbog toga što prirodni oblik postupka "primijeti poništavanje pa izađi" obično teži ka preskakanju čišćenja pri samome putu prema izlazu. Ako ostavite Close nedostupnim propustit ćete stanje po stranici, a svaki onaj preglednik koji korisniku dopušta poništavati iznova i iznova render za renderom nakupljao bi propuštanje te memorije na baš svakoj prekinutoj stranici

Robustan oblik postavlja petlju te klasifikaciju njezinih rezultata unutar bloka try, dok se poziv metode FPDF_RenderPage_Close nalazi u pripadajućem dijelu finally. Odredišna bitna mapa uništava se točno u tom istom bloku. Otkazivanje slobodno može napustiti petlju putem ranog poziva Exit jer se dio finally svakako ipak odvija do kraja, tako da imamo isključivo točno jedno jedino mjesto zaduženo za oslobađanje progresivnog stanja, i ono se ni u kojem slučaju 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 svakoga poziva Continue, no istodobno se oslanja i na povratni poziv smješten unutar nje. Povratni poziv time će skratiti sami trenutni radni komad; s druge strane, provjera unutar petlje pobrinut će se da idući sploh i ne započne. Kad rade skupa, uspješno obuzdavaju vrijeme potrebno za aktivaciju samoga poništavanja te ga ograničavaju otprilike na trajanje jednoga pojedinoga radnoga komada

Tri ishoda i što bitna mapa sadržava nakon poništavanja

Javna točka ulaska je TPdf.RenderPageProgressive, a ona izbacuje izlaznu vrijednost tipa TPdfProgressiveStatus u jednom obliku od sljedećih tri: prsDone, prsCancelled, ili prsFailed. Te vrijednosti preslikavaju PDFiumove FPDF_RENDER_* konstante u Pascalov idiom, no pritom same uvode stanje poništavanja u obliku rezultata koji je ravno na onoj istoj, prvoj klasi, a ne ga gledaju tek usputno kao običnu i puku grešku

Ono što obično zbunjuje ljude je pitanje što odredišna bitna mapa sadržava nakon prsCancelled. Ona nije prazna. PDFium renderira progresivno u istu bitnu mapu dio po dio, stoga kada otkazivanje zaustavi petlju, bitna mapa sadrži sve što je bilo nacrtano do tog trenutka, što je djelomična slika: neke trake su gotove, dok ostatak i dalje prikazuje boju ispune. Hoće li taj djelomični rezultat biti koristan ovisi isključivo o pozivatelju. Preglednik koji će upravo odbaciti tu bitnu mapu jer je korisnik prešao negdje drugdje, jednostavno je može ignorirati. Preglednik koji želi prikazati jeftini pretpregled može je zadržati. Ono što ne smijete učiniti jest pretpostaviti da prsCancelled implicira praznu ili nedefiniranu bitnu mapu; on implicira istinitu snimku nedovršenog rendera

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 callback putanja bez grananja

Otkazivanje je opcionalno. Pozivatelj koji želi progresivno renderiranje samo zbog pogodnosti pumpanja poruka, bez ikakve namjere prekidanja, trebao bi moći proslijediti nil za token. Naivan način da se to podrži bio bi raspršiti provjere "je li token isporučen" posvuda po povratnom pozivu i petlji, što bi značilo grananje na svakom komadu kao i to da povratni poziv mora znati raditi sa stvarnim tokenom, kao i s njegovom odsutnošću

Implementacija to izbjegava korištenjem zamjene putem singleton instancije kada pozivatelj ne proslijedi ništa. Token vrste nil zamjenjuje se s PdfNoCancellationToken, sučeljem čije je IsCancelled uvijek lažno. Od te točke nadalje povratni poziv i petlja u svakom pojedinom slučaju imaju token za ispitivanje, tako da niti jednome nije potrebna provjera na nil te ne zahtijevaju posebnu putanju. Taj nikad-otkazani token jednostavno uvijek odgovara s netočno, povratni poziv zauvijek vraća nulu, a render stiže do završetka u potpunosti isto kao što bi to učinio onaj neotkazivi. Opcionalno ponašanje tako je modelirano kao token koji se nikada ne aktivira, a ne kao sama odsutnost tokena, što drži najzahtjevniju putanju posve ujednačenom

// 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 iz toga proizlazi je malen i vrijedan ponovnog naglašavanja, budući da se radi o ponovno iskoristivom dijelu. C biblioteka koja podržava povratni poziv daje vam točno jedan kanal za prosljeđivanje stanja u taj povratni poziv, a to je neprozirni korisnički pokazivač. Stavite preračunatu Pascal sučeljnu referencu iza takvog pokazivača, sačuvajte drugu i jednako stvarnu referencu živom uz samu strukturu tako da se objekt ne bi mogao sakupiti usred poziva, i potom očitajte sučelje ponovno van unutar jedne statične funkcije cdecl. Omotajte cijelu pogonsku petlju unutar try a potom oslobodite nativni kontekst pomoću finally. Potpuno isti ovaj predložak se slobodno prenosi na bilo koju drugu progresivnu PDFium proceduru ili proceduru upravljanu povratnim pozivima, unutar koje Pascal kod mora ostati pod kontrolom životnog vijeka dok sam C drži pokazivač

Otkazivanje je samo jedna polovina responzivnog preglednika. Druga polovina je izbjegavanje ponovnog renderiranja stranica koje ste već jednom iscrtali, kao i održavanje zumiranja i pomicanja posve glatkim korištenjem predmemoriranih bitnih mapa, što je uvelike pokriveno u našem članku o renderiranju u predmemoriju i performansama zumiranja. Da biste shvatili kako se otkazivi render uklapa u cjelokupni gotovi preglednik uz samu navigaciju, selekciju i pretragu, posjetite izgradnja značajkama bogatog PDF preglednika uz pomoć PDFium komponente. Progresivno renderiranje opisano ovdje redovito se isporučuje kao sastavni dio PDFium Component komponente za programski jezik Delphi kao i Lazarus u paketu s API-jima za učitavanje, renderiranje i obradu formi detaljnije obuhvaćenima na nekim drugim mjestima diljem ovoga bloga