Technický článek

Zrušitelné progresivní renderování PDF v Delphi (PDFium)

Většina stránek v PDF se vyrenderuje během několika milisekund, takže nad tím ani nepřemýšlíte. Pak ale uživatel otevře strojírenský výkres ve formátu A1, stránku napěchovanou desítkami tisíc vektorových tahů nebo plakát plný skupin průhlednosti a měkkých masek, a to jediné volání, které to celé má vykreslit, zabere rázem dvě nebo tři sekundy. Pokud takové volání běží ve vlákně uživatelského rozhraní (UI), okno se přestane překreslovat, titulní pruh zešedne a operační systém nabídne ukončení aplikace. Tato práce je přitom legitimní. Stránka opravdu potřebuje tolik času. Chybou je to, že renderování je zde pouze jedno nedělitelné blokující volání bez jakékoli možnosti na „nadechnutí“ a bez jakékoli cesty, jak ho zastavit

Tento článek se týká právě jednoho z těchto dvou problémů: jak zrušit dlouhé renderování jedné stránky, aniž by došlo k zamrznutí UI. Uživatel například klikl na další stránku, změnil měřítko (zoom) nebo dokument zavřel, a právě probíhající renderování je nyní zbytečnou prací, která by měla skončit při nejbližší příležitosti a neměla by běžet až do samého konce. Plynulé posouvání a přibližování pomocí mezipaměti (cachování) již vyrenderovaného obsahu představuje samostatný problém s vlastním návrhem, kterému se věnuje doplňující článek, na který odkazujeme v závěru. Zde se zabýváme pouze otázkou, jak zajistit, aby jedno progresivní renderování rychle a čistě zareagovalo na požadavek na zrušení

API pro progresivní renderování, které PDFium už dávno obsahuje

PDFium část problému se zamrzáním předvídalo. Vedle jednorázové funkce FPDF_RenderPageBitmap nabízí také progresivní variantu, která vykreslování stránky rozděluje na menší kousky práce. Funkci FPDF_RenderPageBitmap_Start zavoláte jednou, čímž nastavíte renderování do cílové bitmapy, a následně opakovaně voláte FPDF_RenderPage_Continue. Každé volání Continue provede rasterizaci jednoho ohraničeného kousku a vrátí stav. FPDF_RENDER_TOBECONTINUED znamená, že zbývá ještě nějaká práce, FPDF_RENDER_DONE indikuje, že je stránka hotová, a FPDF_RENDER_FAILED znamená, že se proces zastavil kvůli chybě. Po skončení smyčky zavoláte FPDF_RenderPage_Close, čímž uvolníte progresivní stav vázaný na danou stránku. Vzhledem k tomu, že se mezi jednotlivými kousky vrací řízení vašemu kódu, můžete zpracovávat zprávy systému, aktualizovat indikátor průběhu nebo kontrolovat, zda je tato práce stále vůbec žádoucí

Mechanismus, který PDFium poskytuje pro rozhodnutí, kdy předat řízení, je callbacková struktura pojmenovaná IFSDK_PAUSE. Tu předáváte do volání Start a do každého Continue. Po zpracování každého bloku dat zavolá PDFium ukazatel na funkci NeedToPauseNow, a pokud tato funkce vrátí nenulovou hodnotu, aktuální operace Continue se předčasně zastaví a předá řízení zpět se stavem FPDF_RENDER_TOBECONTINUED. Tato struktura také obsahuje pole version, které musí být nastaveno na hodnotu 1, a volný ukazatel user, kterého se PDFium nikdy nedotkne a jen jej propustí dále bez jakékoliv změny. Právě onen nedotčený ukazatel pak tvoří ústřední kloub celého následujícího návrhu

Znovuvyužití pauzy jakožto zrušení

Původním záměrem NeedToPauseNow je time-slicing (rozdělování na časové úseky). Vrátíte nenulovou hodnotu, jakmile vyčerpáte svůj snímkový rozpočet (frame budget), nebo vrátíte nulu, pokud chcete pokračovat v renderování, a PDFium se pozastaví, abyste mohli udělat něco jiného, než totéž renderování opět obnovíte. Knihovna PDFium Component však využívá tentýž signál pro jiný účel. Namísto odpovědi na otázku „mám to pauznout a nechat vás to pak obnovit?“, odpovídá callback na to, „byla už tato práce zrušena?“. Tyto dvě koncepce na sebe hladce nasedají právě díky tomu, co dělá smyčka, když onen příznak (flag) uvidí. Skutečná pauza by totiž očekávala pozdější spuštění Continue, ale zrušení nic takového neočekává. Jakmile volající smyčka zpozoruje, že je token zrušen, uzavře kontext renderování a už nikdy více nezavolá Continue. Z toho vyplývá, že ta samá nenulová návratová hodnota, kterou PDFium interpretuje jako „zastav tento kousek“, se ve skutečnosti stává pokynem „zastav to už nadobro“

Zrušení je vyjádřeno prostřednictvím rozhraní IPdfCancellationToken, jehož vlastnost IsCancelled se překlopí z hodnoty false na true v momentě, kdy jiná část programu požádá o zastavení renderování. Oním pomyslným mostem mezi tímto pascalovským rozhraním a céčkovým callbackem knihovny PDFium je jeden jediný ukazatel. Odkaz na rozhraní tohoto tokenu (poukázky) je zapsán do položky IFSDK_PAUSE.user a statický callback s konvencí cdecl jej poté zpětně načte a dotáže se ho. To ukazuje na zcela klasický problém související s povolením céčkové knihovně zavolat zpět do Pascalu: takový callback musí být obyčejnou funkcí s volací konvencí jazyka C (cdecl), nikoli metodou. PDFium totiž ukládá a vyvolává holý ukazatel na funkci, který nemá ani potuchy o pascalovských objektech či parametru 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;

Callback získá token zpět tak, že přetypuje pThis^.user zpět na typ rozhraní a načte hodnotu z IsCancelled. Nic v něm se nealokuje, neuzamyká ani neblokuje, což je nesmírně důležité, protože PDFium jej volá na renderovacím vlákně po každičkém dokončeném kousku a jakákoliv práce navíc se tu přičítá k samotné ceně renderování. Ochrana proti nil struktuře nebo chybějícímu poli user znamená, že je tuto stejnou funkci bezpečné nainstalovat dokonce i při renderování, kterému nikdy nebyl přidělen žádný skutečný token

Udržení poukázky (tokenu) naživu napříč celou smyčkou

Přetypování ukazatele rozhraní na surový (raw) Pointer a zpět je místem, kde se rodí všechny chyby související s životností. Rozhraní (IInterface) v Delphi je počítáno pomocí referencí (reference counted) a toto číslo se mění jen tehdy, když kompilátor vidí, že se přiřazuje proměnná patřící k typu rozhraní. Pokud byste token uložili pouze coby holý ukazatel uvnitř IFSDK_PAUSE.user, zcela byste jej tak skryli před počítadlem referencí. Kdyby se pak stalo, že ten jediný další odkaz na tento token zanikne mimo rozsah platnosti v době, kdy by smyčka Continue stále běžela, samotný objekt by se pod rukama callbacku uvolnil z paměti a ten následně zpracovávaný kousek by se tak rázem pokusil dereferencovat již takzvaně visící ukazatel (dangling pointer)

Z tohoto důvodu je deskriptor vytvořen coby záznam (record) obsahující dvě věci, nikoliv jenom jednu. Pole Pause představuje strukturu, kterou PDFium dokáže číst. Pole Token je plnohodnotným odkazem typu rozhraní, jejž kompilátor úspěšně započítává, a existuje jen a pouze za tím účelem, aby token takříkajíc připíchlo do paměti po celou dobu, co daný záznam žije. Onen záznam je totiž navíc lokální proměnnou uloženou v zásobníku (stack) naší renderovací rutiny, takže zůstává platný po celou dobu trvání smyčky a zanikne až teprve ve chvíli, kdy tuto rutinu kompletně opustíte. Obyčejný holý ukazatel v položce user a počítaný odkaz v položce Token zkrátka sdílí a pojmenovávají týž samotný objekt; jeden však slouží k tomu, aby jej mohlo číst PDFium, zatímco ten druhý ho celou tu dobu chrání před neúprosným sběrem z paměti

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

Uzavření kontextu renderování bez ohledu na to, jak smyčka skončí

Každé jedno volání funkce FPDF_RenderPageBitmap_Start alokuje progresivní stav, který PDFium sváže přímo s danou stránkou, a tento progresivní stav lze uvolnit pouze skrze FPDF_RenderPage_Close. Z této řídící smyčky vedou celkem tři možné cesty ven. Stránka se úspěšně dokončí a posledním navráceným stavem je tak FPDF_RENDER_DONE. Token se zachytí, smyčka se předčasně ukončí a ohlásí se proces zrušení. Něco selže a vypsaný stav ohlásí FPDF_RENDER_FAILED. Všechny tři varianty bezpodmínečně musí zavolat Close. A právě cesta vedoucí skrze zrušení bývá tím úplně nejzrádnějším místem k pochybení, neboť onen přirozený vzorec na způsob "vidím cancel, prolamuji ven" má velice silné sklony přeskočit po cestě k záchrannému východu celou čisticí rutinu. Avšak ponechání příkazu Close ladem tak pochopitelně vede k takzvanému úniku (leak) na úrovni každé ze stránek, a prohlížeč, který uživateli bezstarostně umožní rušit jedno renderování za druhým, by tím pádem nahromadil tyto úniky s každou další předčasně ukončenou stránkou

Robustní návrh tak zkrátka vkládá celou smyčku i s klasifikací výsledků pod ochranná křídla try, přičemž metodu FPDF_RenderPage_Close posléze napevno uzavírá přímo do příslušného bloku finally. V tomtéž bloku zároveň dochází ke zničení (destroy) cílové bitmapy. Zrušení tím může hladce opustit smyčku pomocí předčasného Exit, jelikož blok finally se nehledě na cokoliv vždycky spustí, čímž vznikne jedno jediné přesné a jasné místo, které navždy uvolní progresivní stav bez možnosti, aby jej cokoliv jakkoliv obešlo

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;

Zároveň si všimněte, že smyčka kontroluje token ještě dříve, než spustí příkaz Continue, a nespoléhá jen a výhradně na callback uvnitř. Zatímco callback ukončuje ten aktuální blok, tahle kontrola ze strany smyčky bezpečně zastaví samotné zpuštění toho hned následujícího bloku. Společnými silami pak tyto dva mechanismy stahují délku časového dopadu při každém pokusu o zrušení na rozumnou míru hrubě odpovídající době trvání toho jednoho konkrétního probíhajícího kousku z renderu

Tři možné výsledky a co přesně bitmapa drží po zrušení

Veřejným vstupním bodem zůstává TPdf.RenderPageProgressive. Ten navrací typ TPdfProgressiveStatus, který se zúží vždy na jeden z těchto tří stavů: prsDone, prsCancelled nebo prsFailed. Tyto hodnoty věrně zrcadlí přednastavené céčkové konstanty z edice FPDF_RENDER_* přímo od formátu PDFium, avšak převedené rovnou do Pascalu, do kterých navíc zahrnují také rovnocenný případ u zrušení renderu takříkajíc formou zcela prvotřídního plnohodnotného výsledku, a nikoliv na způsob pouhé obyčejné chyby

Klíčovým bodem, na kterém se spousta lidí nenávratně zaskočí, pak bývá to, co vlastně cílová bitmapa obsahuje ihned poté, kdy vyjde najevo hláška o prsCancelled. Nikdy v ní totiž nenajdete jen čisté plátno. Knihovna PDFium totiž renderuje zcela progresivně pěkně ten samý blok po bloku v jednom tahu, a tak když veškeré zrušení (cancel) smyčku úspěšně zastaví, na bitmapě spolehlivě uvízne cokoliv z toho, co už do ní bylo zaneseno ze samotného zpracování (paint) těsně k oné momentální chvíli, což tvoří takový dílčí (částečný) obraz (partial image): několik hotových pruhů (bands), zatímco ten zbytek stále prosvítá za přítomnosti pouze základní výplňové barvy. Jestli onen polovičatý render nakonec představuje přínosný počin, už pak naplno spočívá na samotném zadavateli dotazu (volajícím). Zobrazovač, který se právě snaží stávající bitmapu zahodit jen proto, že uživatel už dávno překlikl úplně někam jinam, jej nakonec zřejmě plně a bez mrknutí oka nechá ignorovat. A naopak zobrazovač, toužící nanejvýš jen ukázat velice nenáročnou úspornou ukázku (předpremiéru) takzvaně formou nízkonákladového preview, si ho může bez potíží ponechat. A na co zaručeně zkrátka pamatujte: onen výstup s výsledkem u prsCancelled nenese absolutně žádné poselství o prázdné nebo nedefinované bitmapě; spíše plnohodnotně přináší a představuje jen zcela ryzí momentku s pravdivou výpovědí o rozpracovaném a zdaleka nedokončeném vykreslení

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;

Poukázka (token) s hodnotou nil a zpětné volání bez větvení

Zrušení je pochopitelně volitelnou záležitostí. Volající, jemuž jde primárně jenom o získání samotného progresivního renderování pro usnadnění při onom předávání zpráv, avšak bez špetky reálného úmyslu jej jakkoliv reálně přerušovat, by tak měl mít přirozeně dostat možnost předat do políčka s tokenem hodnotu nil. Naivní cesta, jak takovou podporu vnutit, představuje zběsilé rozestavění mnoha záchytných kontrol (na styl „pokud tu přece jen nějaký dodaný token byl“) skrze veškerý callback a samotnou obslužnou smyčku, což s sebou ovšem nese nepříjemné větvení na úkor naprosto každého doručovaného úseku i následné nutnosti ze strany callbacku popasovat se s podáním pravého doručeného tokenu i pochopitelně se stavy pod úředně neschválenou prázdnotou s absencí dodávky

Představená implementace se tomuto problému umně vyhýbá tím, že nahradí chybějící položku za singleton ve chvíli, jakmile volající doručí velké nic. Hodnotu nil u tokenu bleskurychle vymění rovnou za PdfNoCancellationToken, tedy rozhraní, jehož funkce IsCancelled bez okolků pokaždé spolehlivě zaručeně hlásí false. A právě počínaje tímto stěžejním bodem už pak samotné zpětné volání (callback) i ústřední smyčka disponují jasně viditelným dodaným tokenem ke svým dotazům ve všech nastalých případech, takže ani jedno nepotřebuje jakoukoli obranu v ohledu ke kontrole na výsledek přes nil, natož dokonce nějakou extra zvláštní obslužnou záchrannou cestu. Nikdy se nerušící token (never-cancel token) tím zkrátka vytrvale dál odpovídá onou formulí na falešně ohlašovaný false, samotný callback zvesela bez oddechu a nepřetržitě k návratům odpovídá zpět rovnou s přesně cílenou nulou a celý ten spuštěný render doběhne neporušeně až do svého závěru naprosto navlas tak totožně, jako u oné nedotknutelné varianty tvořící se formou u těch pro nezrušitelné kousky. Samotné volitelné (dobrovolné) chování na poli chování z oněch možných variant je pak bez řečí elegantně modelováno spíše než formou u zcela prázdného pole spojeného u oné ošetřené chybějící dodané součástky spíše do šikovné zástupky za token, který zkrátka vůbec nikdy jenom takzvaně nepálí (nevystřelí), což nesmírně moc dopomáhá a zcela neochvějně dokáže nadlouho ochránit onu vytíženou horkou dráhu kódů (tzv. hot path) s cennou garancí formou jednolité uniformity

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

Tvar, který se z toho celého nakonec vyklube, je celkově drobný, nicméně rozhodně stojí za zopakování, neboť se s jistotou blýskne jako vskutku bravurní znovupoužitelná část. Céčková knihovna poskytující podporu pro zpětná volání (callback) vám pro odevzdání stavu (parametrů) skrze onen úkon dává přesně jeden jediný kanál v odevzdání, tedy onen pro prokazatelné oko naprosto zcela plně neprůhledný uživatelský ukazatel (opaque user pointer). Proto k němu uschovejte bez obav odkaz namířený rázem ze spočítaného pascalovského rozhraní za pomoci spjatého odkazu na onen zmíněný ukazatel. Posléze si zároveň bedlivě uložte a přichystejte druhý opravdu žijící pravý kousek s reálným odkazem bok po boku se zanechanou strukturou tak, aby zkrátka ten váš odložený nasazený kousek v objektu nedošel ke shození a vyčištění uprostřed probíhajícího polovičního zanořeného zapomenutí ve voláních (tzv. zkáze u mid-call), a dané plnohodnotné spojení z onoho navrátivšího formátu bez skrupulí nazpátek z nitra naložené nasazené zpětně obnovte. Následovně tu na čtení plně zpět odvolejte do nasazené struktury, kde jej v statické funkci cdecl neomylně načtete. A celý proces doručené hrací pohánějící smyčky rovnou zahalte k plnému balení se s klauzulí uvnitř pod klauzule do try, čímž bez prodlení a potíží na závěr vykliďte všechen daný nativní zanechaný obal s kontextem o patro níž a zřetelně bez výmluv bez odrazování pro příkaz k dodaném bloku pro u finally. Totožná nasazená předloha zkrátka bez zbytečných a bez komplikací se neomezeně prolne s přesuny na vrub pro veškerá další i možná operování řízená pod vlivem zpětných volání či k nasazení s progresivními operacemi nasazené přes PDFium, kde logicky za všeho neústupného snažení u o do na z kódu v na o platformě k u pod o s přes o dodání i dodávaný pro s nasazením kódů a kódu na pod platformu pro s Delphi či nasazenou i spjaté Pascalu z Pascalových na ústrojích doručené na ze z do z musí dál pod plně s o po za i a k i pod nutností o nad i za udržet na kontrolou nad s držením z moci pod a o do držením za moc a s do moc nad plnou bez moci nad moci a na pod nasazovanou na se kontrolou ze k kontrolu ze zachovávané na do a k moci nad do a se k drženou s moci na pod moc hned ze na u i za za pod životnost po nasazované na o a nasazované z z po nasazované do doby a dobu na dodanou k plnou pro i u po o nad životností (či i po u nasazované na ze nad k nasazenou a do a k u o nad do po s do drženou u po z moci se pod nasazenou k s do drženou do s u na ze plně z životnost k u z u životnosti). Zatímco a během a po po onou dobou a chvíle a od o z ze chvíli na ze z Céčku i za z s Céčku u zapsané s Céčkem a s Céčku o ze z v C nasazovaného a s Céčko po v Céčku u z nasazované u ze o u v Céčku ze se moci na držet se moci v drží u a do z ze rázem do o a k na u z a na drží po v rukou hned z do z onen ukazatel

Zrušení je však pouze jedna polovina z responzivního prohlížeče. Tou druhou je neprovádět zbytečně opětovné vykreslování stránek, které jste už nakreslili, a zajistit plynulé přibližování a posouvání prostřednictvím zobrazení bitmap v mezipaměti. Tomu se věnuje náš článek o mezipaměti (cache) a výkonu při přibližování. Pokud vás zajímá, jak přesně toto zrušitelné renderování zapadá do celkového prohlížeče spolu s navigací, výběrem a vyhledáváním, přečtěte si článek o tvorbě funkcemi bohatého prohlížeče PDF pomocí komponenty PDFium. Zde popsané progresivní renderování se dodává jako součást balíčku PDFium Component pro Delphi a Lazarus po boku rozhraní API pro načítání, vykreslování a formuláře, kterým se věnují další články na tomto blogu