Technický článok

Zrušiteľné progresívne vykresľovanie PDF v Delphi (PDFium)

Väčšina stránok PDF sa rastruje za niekoľko milisekúnd a nikdy o tom nepremýšľate. Potom používateľ otvorí technický výkres formátu A1, stránku plnú desiatok tisíc vektorových ťahov alebo plagát preplnený skupinami transparentnosti a jemnými maskami, a jediné volanie, ktoré ho vykreslí, trvá dve alebo tri sekundy. Ak toto volanie beží na vlákne UI, okno prestane prekresľovať, titulný panel zošedivie a operačný systém ponúkne ukončenie aplikácie. Práca je legitímna. Stránka naozaj potrebuje toľko času. Chybou je, že vykresľovanie je jedno nedeliteľné blokujúce volanie bez možnosti vzdychnutia a bez možnosti zastavenia

Tento článok sa venuje presne jednému z týchto dvoch problémov: zrušeniu dlhého vykresľovania jednej stránky bez zmrazenia UI. Používateľ klikol na ďalšiu stránku, priblížil obraz alebo zatvoril dokument a prebiehajúce vykresľovanie je teraz zbytočná práca, ktorá by mala skončiť pri najbližšej príležitosti namiesto toho, aby dobehla do konca. Plynulé posúvanie a priblíženie ukladaním do vyrovnávacej pamäte toho, čo už bolo rastrované, je samostatná téma s vlastným návrhom, ktorá je popísaná v sprievodnom článku prepojenom na konci. Tu je jedinou otázkou, ako prinútiť jedno progresívne vykresľovanie reagovať na žiadosť o zrušenie rýchlo a čisto

Progresívne rozhranie API vykresľovania, ktoré PDFium už obsahuje

PDFium predvídal polovicu problému so zmrazovaním. Popri jednorazovom FPDF_RenderPageBitmap poskytuje progresívny variant, ktorý rozdelí stránku na bloky práce. Raz zavoláte FPDF_RenderPageBitmap_Start na nastavenie vykresľovania oproti cieľovej bitovej mape a potom opakovane voláte FPDF_RenderPage_Continue. Každé volanie Continue rastruje ohraničený úsek a vracia stav. FPDF_RENDER_TOBECONTINUED znamená, že je ešte čo robiť, FPDF_RENDER_DONE znamená, že stránka je hotová, a FPDF_RENDER_FAILED znamená, že sa zastavila kvôli chybe. Keď sa slučka skončí, zavoláte FPDF_RenderPage_Close na uvoľnenie progresívneho stavu na úrovni stránky. Keďže riadenie sa vracia do vášho kódu medzi úsekmi, môžete spracovávať správy, aktualizovať indikátor priebehu alebo skontrolovať, či je práca stále žiaduca

Mechanizmus, ktorý PDFium poskytuje na rozhodnutie o podstúpení kontroly, je štruktúra spätného volania s názvom IFSDK_PAUSE. Odovzdáte ju do Start a každého volania Continue. Po každom bloku PDFium zavolá jeho ukazovateľ na funkciu NeedToPauseNow, a ak vráti nenulová hodnotu, aktuálne volanie Continue sa predčasne zastaví a vráti riadenie s hodnotou FPDF_RENDER_TOBECONTINUED. Štruktúra obsahuje aj pole version, ktoré musí byť nastavené na 1, a voľný ukazovateľ user, ktorého sa PDFium nikdy nedotýka a prechádza ním nezmeneným. Tento nedotknutý ukazovateľ je celým kĺbom nasledujúceho návrhu

Prepoužitie pauzy ako zrušenia

Pôvodným zámerom funkcie NeedToPauseNow je časové rozdeľovanie. Vrátite nenulová hodnotu, keď je váš rámcový rozpočet vyčerpaný, vrátite nulu, aby ste pokračovali vo vykresľovaní, a PDFium sa pozastaví, aby ste mohli urobiť niečo iné pred obnovením toho istého vykresľovania. PDFium Component opätovne používa ten istý signál pre iné sloveso. Namiesto odpovede „mám sa pozastaviť a nechať vás pokračovať" spätné volanie odpovedá „bola táto práca zrušená". Obe sa na seba mapujú čisto kvôli tomu, čo slučka robí, keď vidí príznak. Skutočná pauza očakáva neskoršie volanie Continue; zrušenie nie. Keď volajúca slučka zistí, že token je zrušený, uzavrie kontext vykresľovania a nikdy viac nevolá Continue, takže tá istá nenulová návratová hodnota, ktorú PDFium číta ako „zastaviť tento blok", sa fakticky stáva „zastaviť navždy"

Zrušenie je vyjadrené prostredníctvom rozhrania IPdfCancellationToken, ktorého vlastnosť IsCancelled sa prepne z false na true, keď iná časť programu požiada o zastavenie vykresľovania. Mostom medzi týmto pascalovým rozhraním a spätným volaním C jazyka PDFium je jeden ukazovateľ. Odkaz na rozhranie tokenu je zapísaný do IFSDK_PAUSE.user a statické spätné volanie cdecl ho číta späť a dotazuje sa naň. Toto je klasický problém toho, ako nechať knižnicu C volať späť do Pascalu: spätné volanie musí byť prostou funkciou s konvenciou volania C, nie metódou, pretože PDFium ukladá a vyvoláva holý ukazovateľ na funkciu, ktorý nič nevie o pascalových objektoch ani 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;

Spätné volanie obnoví token pretypovaním pThis^.user späť na typ rozhrania a prečíta IsCancelled. Nič v ňom neprideľuje, neblokuje ani nezdržiava, čo je dôležité, pretože PDFium ho volá na vlákne vykresľovania po každom bloku a akákoľvek práca vykonaná tu sa pripočítava k cene samotného vykresľovania. Ochrana pred štruktúrou nil alebo poľom user s hodnotou nil znamená, že tá istá funkcia je bezpečná na inštaláciu aj pri vykresľovaní, ktorému nebol nikdy daný skutočný token

Udržiavanie tokenu nažive počas celej slučky

Pretypovanie ukazovateľa rozhrania cez holý Pointer a späť je miestom, kde sa rodia chyby životnosti. IInterface v Delphi je počítaný odkazmi a počet sa pohybuje iba vtedy, keď kompilátor vidí priradenie premennej s typom rozhrania. Ukladanie tokenu výhradne ako holého ukazovateľa vo vnútri IFSDK_PAUSE.user by ho úplne skrylo pred počítadlom odkazov. Ak by jediný iný odkaz na tento token vyšiel z rozsahu, kým slučka Continue stále bežala, objekt by bol uvoľnený pod spätným volaním a nasledujúci blok by odkazoval na visiacu smerník

Preto je deskriptor záznam obsahujúci dve veci, nie jednu. Pole Pause je štruktúra, ktorú PDFium číta. Pole Token je skutočný odkaz s typom rozhrania, ktorý kompilátor počíta, a existuje len preto, aby ukotvil token v pamäti po dobu životnosti záznamu. Záznam je lokálna premenná na zásobníku rutiny vykresľovania, takže zostáva platný po celú dobu trvania slučky a je zrušený iba vtedy, keď rutina skončí. Holý ukazovateľ v poli user a počítaný odkaz v poli Token pomenúvajú rovnaký objekt; jeden je to, čo PDFium môže čítať, druhý je to, čo zabraňuje zhromažďovaniu tohto objektu

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

Uzatváranie kontextu vykresľovania bez ohľadu na to, ako sa slučka skončí

Každé volanie FPDF_RenderPageBitmap_Start prideľuje progresívny stav, ktorý PDFium priraďuje stránke, a tento stav je uvoľnený iba volaním FPDF_RenderPage_Close. Zo slučky riadenia existujú tri cesty von. Stránka sa dokončí a posledný stav je FPDF_RENDER_DONE. Token sa spustí a slučka predčasne skončí s hlásením zrušenia. Niečo zlyhá a stav je FPDF_RENDER_FAILED. Všetky tri musia volať Close, a cesta zrušenia je najľahšia na chybu, pretože prirodzený tvar „uvidieť zrušenie, vyskočiť" má tendenciu preskočiť čistenie na ceste k výstupu. Ponechanie Close nedosiahnutého uvoľňuje stav na úrovni stránky a prehliadač, ktorý umožňuje používateľovi zrušiť vykresľovanie za vykresľovaním, by akumuloval tento únik pri každej prerušenej stránke

Robustný tvar vloží slučku a klasifikáciu výsledku do bloku try a FPDF_RenderPage_Close do zodpovedajúceho bloku finally. Cieľová bitová mapa je zničená v rovnakom bloku. Zrušenie môže opustiť slučku cez predčasný Exit a blok finally sa stále spustí, takže existuje práve jedno miesto, ktoré uvoľní progresívny stav a nemožno ho obísť

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;

Slučka kontroluje token pred každým volaním Continue a zároveň sa spolieha na spätné volanie vo vnútri neho. Spätné volanie skracuje aktuálny blok; kontrola slučky zabraňuje spusteniu nasledujúceho. Spoločne ohraničujú dobu, za ktorú zrušenie nadobudne účinnosť, na približne trvanie jedného bloku

Tri výsledky a čo bitová mapa obsahuje po zrušení

Verejným vstupným bodom je TPdf.RenderPageProgressive, ktorý vracia TPdfProgressiveStatus, čo je jedna z hodnôt prsDone, prsCancelled alebo prsFailed. Hodnoty zrkadlia konštanty PDFium FPDF_RENDER_* v pascalovej idiome, ale zahŕňajú prípad zrušenia ako výsledok prvej triedy, nie ako chybu

Bod, ktorý ľudí zaskočí, je to, čo cieľová bitová mapa obsahuje po výsledku prsCancelled. Nie je prázdna. PDFium vykreslí progresívne do tej istej bitovej mapy blok po bloku, takže keď zrušenie zastaví slučku, bitová mapa obsahuje všetko, čo bolo namaľované až do tohto okamihu — čo je čiastočný obrázok: niektoré pásy sú hotové, zvyšok stále zobrazuje farbu výplne. To, či je tento čiastočný výsledok užitočný, závisí od volajúceho. Prehliadač, ktorý chystá zahodiť bitovú mapu, pretože používateľ prešiel na iné miesto, ju môže jednoducho ignorovať. Prehliadač, ktorý chce zobraziť lacný náhľad, ju môže ponechať. Čo nesmie urobiť, je predpokladať, že prsCancelled naznačuje prázdnu alebo nedefinovanú bitovú mapu; naznačuje pravdivý snímok nedokončeného vykresľovania

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;

Token nil a cesta spätného volania bez vetvenia

Zrušenie je voliteľné. Volajúci, ktorý chce len progresívne vykresľovanie kvôli výhode spracovania správ bez úmyslu prerušiť, by mal byť schopný odovzdať nil pre token. Naivným spôsobom, ako to podporiť, je rozptýliť kontroly „ak bol token poskytnutý" v spätnom volaní a slučke, čo znamená vetvu pri každom bloku a spätné volanie, ktoré musí zvládnuť skutočný token aj jeho absenciu

Implementácia sa tomu vyhne nahradením singletonu, keď volajúci nič neodovzdá. Token nil je nahradený za PdfNoCancellationToken, rozhranie, ktorého IsCancelled je vždy false. Od tohto bodu má spätné volanie aj slučka token na dopytovanie v každom prípade, takže ani jedno nepotrebuje kontrolu na nil a ani jedno nepotrebuje špeciálnu cestu. Token „nikdy nezrušiť" jednoducho vždy odpovedá false, spätné volanie vždy vracia nulu a vykresľovanie prebehne do konca presne tak, ako by to urobilo nevypínateľné. Voliteľné správanie je modelované ako token, ktorý sa nikdy nespustí, nie ako absencia tokenu, čo udržuje horúcu cestu jednotnou

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

Výsledný tvar je malý a stojí za zopakovanie, pretože je to opakovateľne použiteľná časť. Knižnica C, ktorá podporuje spätné volanie, vám poskytuje presne jeden kanál na odovzdanie stavu do tohto spätného volania — nepriehľadný ukazovateľ user. Vložte počítaný odkaz na pascalové rozhranie za tento ukazovateľ, udržte druhý skutočný odkaz nažive vedľa štruktúry, aby objekt nemohol byť zhromažďovaný počas volania, a prečítajte rozhranie späť vo vnútri statickej funkcie cdecl. Obaľte celú slučku riadenia do bloku try a uvoľnite natívny kontext v bloku finally. Rovnaká šablóna sa prenáša na akúkoľvek progresívnu alebo spätným volaním riadenú operáciu PDFium, kde pascalový kód musí zostať v kontrole životnosti, kým C drží ukazovateľ

Zrušenie je len jednou polovicou responzívneho prehliadača. Druhou polovicou je nevykreslenie znova stránok, ktoré ste už nakreslili, a plynulé udržiavanie priblíženia a posúvania pomocou obsluhovania uložených bitových máp, čo je popísané v našom článku o vyrovnávacej pamäti vykresľovania a výkone priblíženia. Ako zrušiteľné vykresľovanie zapadá do kompletného prehliadača spolu s navigáciou, výberom a vyhľadávaním, pozrite si vytvorenie funkciami bohatého prehliadača PDF s PDFium VCL komponentom. Progresívne vykresľovanie popísané tu je dodávané ako súčasť PDFium Component pre Delphi a Lazarus spolu s rozhraním API načítavania, vykresľovania a formulárov, ktoré sú popísané inde na tomto blogu