Articol tehnic

Randare progresivă anulabilă a fișierelor PDF în Delphi (PDFium)

Majoritatea paginilor PDF se rasterizează în câteva milisecunde și niciodată nu vă gândiți la asta. Apoi, un utilizator deschide un desen de inginerie A1, o pagină plină cu zeci de mii de tușe vectoriale sau un poster aglomerat cu grupuri de transparență și măști moi, iar apelul unic care îl desenează durează două sau trei secunde. Dacă acel apel rulează pe firul de execuție al interfeței cu utilizatorul, fereastra încetează să se mai redeseneze, bara de titlu devine gri, iar sistemul de operare se oferă să oprească aplicația. Munca este legitimă. Pagina chiar are nevoie de atât de mult timp. Defectul este că randarea este un apel blocant indivizibil fără nicio modalitate de a lua o pauză și fără nicio modalitate de a se opri

Acest articol este exact despre una dintre aceste două probleme: anularea unei randări lungi a unei singure pagini fără a bloca interfața cu utilizatorul. Utilizatorul a făcut clic pe pagina următoare, a mărit sau a închis documentul, iar randarea în curs de desfășurare este acum muncă irosită care ar trebui să se încheie cu prima ocazie în loc să ruleze până la finalizare. Netezirea derulării și a măririi prin stocarea în cache a ceea ce a fost deja rasterizat este o preocupare separată cu propriul său design, acoperită în articolul însoțitor conectat la sfârșit. Aici singura întrebare este cum să facem ca o randare progresivă să răspundă rapid și curat la o cerere de anulare

API-ul de randare progresivă pe care PDFium îl livrează deja

PDFium a anticipat jumătatea cu blocarea a problemei. Alături de apelul unic FPDF_RenderPageBitmap, expune o variantă progresivă care împarte o pagină în bucăți de lucru. Apelați FPDF_RenderPageBitmap_Start o dată pentru a configura randarea față de un bitmap de destinație, apoi apelați FPDF_RenderPage_Continue în mod repetat. Fiecare Continue rasterizează o felie delimitată și returnează un status. FPDF_RENDER_TOBECONTINUED înseamnă că mai este de lucru, FPDF_RENDER_DONE înseamnă că pagina s-a terminat, iar FPDF_RENDER_FAILED înseamnă că s-a oprit la o eroare. Când bucla se termină, apelați FPDF_RenderPage_Close pentru a elibera starea progresivă per pagină. Deoarece controlul revine la codul dvs. între felii, puteți pompa mesaje, actualiza un indicator de progres sau puteți verifica dacă munca este încă dorită

Mecanismul pe care PDFium îl oferă pentru a decide când să cedeze controlul este un struct de apel invers numit IFSDK_PAUSE. Îl înmânați lui Start și fiecărui Continue. După fiecare bucată, PDFium își apelează indicatorul de funcție NeedToPauseNow, iar dacă acesta returnează o valoare diferită de zero, actualul Continue se oprește devreme și predă controlul înapoi cu FPDF_RENDER_TOBECONTINUED. Structura poartă, de asemenea, un câmp version, care trebuie setat la 1, și un pointer cu formă liberă user pe care PDFium nu îl atinge niciodată și îl transmite neatins. Acel pointer neatins este întreaga balama a designului care urmează

Reutilizarea pauzei ca anulare

Intenția originală a NeedToPauseNow este divizarea timpului. Returnați o valoare diferită de zero atunci când bugetul cadrului dvs. este cheltuit, returnați zero pentru a continua randarea, iar PDFium face pauză, astfel încât să puteți face altceva înainte de a relua aceeași randare. Componenta PDFium reutilizează același semnal pentru un verb diferit. În loc să răspundă la „ar trebui să fac pauză și să vă las să reluați”, apelul invers răspunde la „a fost anulată această lucrare”. Cele două se mapează curat una pe cealaltă datorită a ceea ce face bucla atunci când vede flag-ul. O pauză autentică așteaptă un Continue ulterior; o anulare nu. Odată ce bucla de apelare observă că jetonul este anulat, închide contextul de randare și nu mai apelează niciodată Continue, astfel încât aceeași returnare diferită de zero pe care PDFium o citește ca „oprește această bucată” devine, de fapt, „oprește pentru totdeauna.”

Anularea este exprimată printr-o interfață, IPdfCancellationToken, a cărei proprietate IsCancelled se schimbă din false în true atunci când o altă parte a programului solicită oprirea randării. Puntea dintre acea interfață Pascal și apelul invers C al PDFium este un singur pointer. Referința de interfață a jetonului este scrisă în IFSDK_PAUSE.user, iar un apel invers static cdecl o citește înapoi și o interoghează. Aceasta este problema clasică a permiterii unei biblioteci C să apeleze înapoi în Pascal: apelul invers trebuie să fie o funcție simplă cu convenția de apelare C, nu o metodă, deoarece PDFium stochează și invocă un simplu indicator de funcție care nu știe nimic despre obiectele Pascal sau 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;

Apelul invers recuperează jetonul prin convertirea pThis^.user înapoi la tipul de interfață și citește IsCancelled. Nimic din el nu alocă, blochează sau blochează, ceea ce contează pentru că PDFium îl apelează pe firul de randare după fiecare bucată, iar orice lucrare făcută aici se adaugă la costul randării în sine. Protecția împotriva unei structuri nil sau a unui câmp user nil înseamnă că aceeași funcție este sigură de instalat chiar și pe o randare căreia nu i s-a dat niciodată un jeton real

Menținerea jetonului în viață peste buclă

Convertirea unui pointer de interfață printr-un Pointer brut și înapoi este locul unde se nasc erorile legate de durata de viață. Un IInterface în Delphi este numărat prin referință, iar numărătoarea se mișcă doar atunci când compilatorul poate vedea o variabilă de tip interfață fiind atribuită. Stocarea jetonului exclusiv ca un pointer simplu în interiorul IFSDK_PAUSE.user l-ar ascunde complet de contorul de referință. Dacă singura altă referință la acel jeton ar ieși din domeniul de aplicare în timp ce bucla Continue încă rula, obiectul ar fi eliberat sub apelul invers, iar următoarea bucată ar dereferenția un indicator atârnat

Acesta este motivul pentru care descriptorul este o înregistrare care conține două lucruri, nu unul. Câmpul Pause este structura pe care PDFium o citește. Câmpul Token este o referință reală de tip interfață pe care compilatorul o numără și nu există pentru niciun alt motiv decât pentru a fixa jetonul în memorie atâta timp cât trăiește înregistrarea. Înregistrarea este o variabilă locală pe stiva rutinei de randare, deci rămâne validă pe întreaga durată a buclei și este distrusă doar la ieșirea din rutină. Pointerul simplu din user și referința numărată din Token denumesc același obiect; unul este ceea ce poate citi PDFium, celălalt este ceea ce împiedică acel obiect să fie colectat

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);

Închiderea contextului de randare, indiferent de modul în care se termină bucla

Fiecare apel către FPDF_RenderPageBitmap_Start alocă o stare progresivă pe care PDFium o asociază cu pagina, iar acea stare este eliberată doar de FPDF_RenderPage_Close. Există trei căi de ieșire din bucla de acționare. Pagina se termină și ultima stare este FPDF_RENDER_DONE. Jetonul se declanșează și bucla iese mai devreme raportând anularea. Ceva eșuează și starea este FPDF_RENDER_FAILED. Toate trei trebuie să apeleze Close, iar calea de anulare este cel mai ușor de greșit, deoarece forma naturală de „vede anulare, ieși” tinde să omită curățarea în drum spre ieșire. Lăsarea lui Close neatins duce la scurgerea stării per pagină, iar un vizualizator care îi permite utilizatorului să anuleze randare după randare ar acumula acea scurgere pe fiecare pagină abandonată

Forma robustă pune bucla și clasificarea rezultatelor într-un try și FPDF_RenderPage_Close în finally corespunzător. Bitmap-ul de destinație este distrus în același bloc. Anularea poate părăsi bucla printr-un Exit timpuriu, iar blocul finally încă rulează, așa că există exact un loc care eliberează starea progresivă și nu poate fi ocolit

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;

Bucla verifică jetonul înainte de fiecare Continue, precum și bazându-se pe apelul invers din interiorul său. Apelul invers scurtează bucata curentă; verificarea buclei o împiedică pe următoarea să înceapă. Împreună, acestea limitează timpul necesar pentru ca o anulare să aibă efect la aproximativ durata unei bucăți

Trei rezultate și ce conține bitmap-ul după o anulare

Punctul public de intrare este TPdf.RenderPageProgressive și returnează un TPdfProgressiveStatus care este unul dintre prsDone, prsCancelled sau prsFailed. Valorile reflectă constantele PDFium FPDF_RENDER_* în expresia Pascal, dar pliază cazul de anulare ca rezultat de primă clasă, mai degrabă decât o eroare

Punctul care prinde oamenii este ceea ce conține bitmap-ul de destinație după prsCancelled. Nu este gol. PDFium randează progresiv în același bitmap bucată cu bucată, așa că atunci când o anulare oprește bucla, bitmap-ul conține tot ceea ce a fost pictat până în acel moment, adică o imagine parțială: unele benzi terminate, restul arătând încă culoarea de umplere. Dacă acel rezultat parțial este util sau nu, depinde de apelant. Un vizualizator care este pe cale să arunce bitmap-ul deoarece utilizatorul a navigat în altă parte, poate pur și simplu să-l ignore. Un vizualizator care dorește să afișeze o previzualizare cu cost redus o poate păstra. Ceea ce nu trebuie să faceți este să presupuneți că prsCancelled implică un bitmap gol sau nedefinit; implică un instantaneu fidel al unei randări neterminate

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;

Jetonul nil și o cale de apel invers fără ramificare

Anularea este opțională. Un apelant care dorește doar randare progresivă pentru beneficiul pompării mesajelor, fără nicio intenție de avort, ar trebui să poată transmite nil pentru jeton. Modul naiv de a sprijini acest lucru este de a împrăștia verificări de genul „dacă un jeton a fost furnizat” prin apelul invers și buclă, ceea ce înseamnă o ramificare pe fiecare bucată și un apel invers care trebuie să gestioneze atât un jeton real, cât și absența acestuia

Implementarea evită acest lucru prin înlocuirea cu un singleton atunci când apelantul nu transmite nimic. Un jeton nil este schimbat cu PdfNoCancellationToken, o interfață al cărei IsCancelled este întotdeauna fals. Din acel moment, apelul invers și bucla au un jeton de interogat în fiecare caz, deci niciunul nu are nevoie de o verificare nil și niciunul nu are nevoie de o cale specială. Jetonul de anulare-niciodată răspunde pur și simplu întotdeauna fals, apelul invers returnează întotdeauna zero, iar randarea rulează până la finalizare exact cum ar face-o una neanulabilă. Comportamentul opțional este modelat mai degrabă ca un jeton care nu se declanșează niciodată decât ca absența unui jeton, ceea ce menține calea fierbinte uniformă

// 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;

Forma care apare este mică și merită reiterată, deoarece este partea reutilizabilă. O bibliotecă C care acceptă un apel invers vă oferă exact un singur canal pentru a trece starea în acel apel invers, indicatorul opac pentru utilizator. Puneți o referință de interfață Pascal numărată în spatele acelui indicator, păstrați o a doua referință reală vie lângă structură, astfel încât obiectul să nu poată fi colectat la mijlocul apelului, și citiți interfața înapoi în interiorul unei funcții statice cdecl. Încadrați întreaga buclă de acționare într-un try și eliberați contextul nativ în finally. Același șablon se aplică oricărei operațiuni PDFium progresive sau acționate de apeluri inverse unde codul Pascal trebuie să dețină controlul duratei de viață în timp ce C deține un pointer

Anularea este doar o jumătate a unui vizualizator receptiv. Cealaltă jumătate nu randează din nou paginile pe care le-ați desenat deja și păstrează mărirea și derularea netede, servind imagini bitmap aflate în cache, ceea ce este acoperit în articolul nostru despre memorarea în cache a randării și performanța zoom-ului. Pentru a afla cum randarea anulabilă se încadrează într-un vizualizator complet alături de navigare, selecție și căutare, consultați construirea unui vizualizator PDF bogat în caracteristici cu componenta PDFium. Randarea progresivă descrisă aici este livrată ca parte a Componentei PDFium pentru Delphi și Lazarus alături de API-urile de încărcare, randare și formare acoperite în alte părți ale acestui blog