Technisch artikel

Annuleerbare progressieve PDF-rendering in Delphi (PDFium)

De meeste PDF-pagina's rasteriseren in een paar milliseconden en u denkt er nooit over na. Dan opent een gebruiker een A1 technische tekening, een pagina vol met tienduizenden vectorstreken, of een poster vol met transparantiegroepen en zachte maskers, en de enkele aanroep die deze schildert duurt twee of drie seconden. Als die aanroep op de UI-thread draait, stopt het venster met opnieuw tekenen, wordt de titelbalk grijs en biedt het besturingssysteem aan om de applicatie te beëindigen. Het werk is legitiem. De pagina heeft echt zo lang nodig. Het defect is dat het renderen één ondeelbare blokkerende aanroep is zonder mogelijkheid om even op adem te komen en geen manier om te stoppen

Dit artikel gaat precies over één van die twee problemen: het annuleren van een langdurige rendering van een enkele pagina zonder de UI te bevriezen. De gebruiker klikte op de volgende pagina, of zoomde, of sloot het document, en de render die aan de gang is, is nu verspild werk dat bij de volgende gelegenheid zou moeten eindigen in plaats van tot het einde door te gaan. Het vloeiender maken van scrollen en zoomen door het in het cachegeheugen opslaan van wat al was gerasteriseerd, is een afzonderlijke zorg met zijn eigen ontwerp, wat wordt behandeld in het begeleidende artikel waarnaar aan het einde wordt gelinkt. Hier is de enige vraag hoe men één progressieve weergave snel en netjes kan laten reageren op een annuleringsverzoek

De API voor progressieve rendering die PDFium al levert

PDFium voorzag de bevriezende helft van het probleem. Naast de one-shot FPDF_RenderPageBitmap, stelt het een progressieve variant beschikbaar die een pagina in brokken (chunks) werk splitst. U roept FPDF_RenderPageBitmap_Start eenmaal aan om het renderen op te zetten tegen een doelbitmap, waarna u herhaaldelijk FPDF_RenderPage_Continue aanroept. Elke Continue rasteriseert een begrensde strook en retourneert een status. FPDF_RENDER_TOBECONTINUED betekent dat er nog meer te doen is, FPDF_RENDER_DONE betekent dat de pagina klaar is, en FPDF_RENDER_FAILED betekent dat het is gestopt bij een fout. Wanneer de lus eindigt, roept u FPDF_RenderPage_Close aan om de progressieve staat per pagina vrij te geven. Omdat de controle tussen de stroken terugkeert naar uw code, kunt u berichten doorgeven (pump messages), een voortgangsindicator bijwerken of controleren of het werk nog steeds gewenst is

Het mechanisme dat PDFium biedt om te beslissen wanneer toe te geven (yield), is een callback-structuur genaamd IFSDK_PAUSE. U geeft het aan Start en aan elke Continue. Na elke brok roept PDFium zijn NeedToPauseNow functieaanwijzer aan, en als dat een waarde ongelijk aan nul retourneert, stopt de huidige Continue vroegtijdig en geeft de controle terug met FPDF_RENDER_TOBECONTINUED. De structuur bevat ook een version-veld, dat op 1 moet worden ingesteld, en een vrije user-aanwijzer die PDFium nooit aanraakt en onaangeroerd doorgeeft. Die onaangeroerde aanwijzer is het hele draaipunt van het ontwerp dat volgt

Pauze herbestemmen als annulering

De oorspronkelijke bedoeling van NeedToPauseNow is tijdverdeling (time-slicing). Retourneer ongelijk aan nul wanneer uw framebudget is besteed, retourneer nul om door te gaan met renderen, en PDFium pauzeert zodat u iets anders kunt doen voordat u dezelfde rendering hervat. De PDFium Component hergebruikt hetzelfde signaal voor een ander werkwoord. In plaats van te antwoorden met "moet ik pauzeren en u laten hervatten," beantwoordt de callback "is dit werk geannuleerd." De twee passen netjes op elkaar vanwege wat de lus doet wanneer deze de markering (flag) ziet. Een echte pauze verwacht een latere Continue; een annulering niet. Zodra de aanroepende lus opmerkt dat het token is geannuleerd, sluit deze de rendercontext en roept hij nooit meer Continue aan, zodat dezelfde retour ongelijk aan nul die PDFium leest als "stop deze brok" in feite "stop voorgoed" wordt.

Annulering wordt uitgedrukt via een interface, IPdfCancellationToken, waarvan de eigenschap IsCancelled omslaat van onwaar (false) naar waar (true) wanneer een ander deel van het programma vraagt om het renderen te stoppen. De brug tussen die Pascal-interface en PDFium's C callback is een enkele aanwijzer (pointer). De interface-referentie van het token wordt in IFSDK_PAUSE.user geschreven, en een statische cdecl callback leest deze weer uit en ondervraagt deze. Dit is het klassieke probleem waarbij een C-bibliotheek terugroept naar Pascal: de callback moet een gewone functie zijn met de C-aanroepconventie, geen methode, omdat PDFium een naakte functie-aanwijzer opslaat en aanroept die niets weet van Pascal-objecten of 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;

De callback herstelt het token door pThis^.user terug te casten naar het interfacetype en leest IsCancelled. Niets daarin wijst toe (allocates), vergrendelt of blokkeert, wat ertoe doet omdat PDFium dit aanroept op de render-thread na elke brok en al het werk dat hier wordt gedaan, wordt toegevoegd aan de kosten van de rendering zelf. De beveiliging tegen een nil struct of een nil user-veld betekent dat dezelfde functie veilig te installeren is, zelfs op een weergave die nooit een echt token heeft gekregen

Het token in leven houden tijdens de lus

Het via een ruwe Pointer en terug casten van een interface-aanwijzer is waar levensduur-bugs worden geboren. Een IInterface in Delphi is referentie-geteld (reference counted), en de telling verschuift alleen wanneer de compiler ziet dat er een met interface getypeerde variabele wordt toegewezen. Het opslaan van het token louter als een kale aanwijzer in IFSDK_PAUSE.user zou het volledig verbergen voor de referentieteller. Als de enige andere referentie naar dat token buiten bereik zou raken terwijl de Continue lus nog liep, zou het object onder de callback worden vrijgemaakt, en zou de volgende brok een bungelende aanwijzer dereferencen

Dat is waarom de descriptor een record is die twee dingen bevat, niet één. Het Pause-veld is de structuur die PDFium leest. Het Token-veld is een echte met interface getypeerde referentie die de compiler telt, en het bestaat om geen andere reden dan om het token in het geheugen vast te pinnen voor zo lang als de record leeft. De record is een lokale variabele op de stack van de render-routine, dus hij blijft geldig voor de volledige duur van de lus en wordt pas afgebroken wanneer de routine eindigt. De kale aanwijzer in user en de getelde referentie in Token benoemen hetzelfde object; de ene is wat PDFium kan lezen, de andere is wat voorkomt dat dat object wordt verzameld

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

De rendercontext sluiten, ongeacht hoe de lus eindigt

Elke aanroep van FPDF_RenderPageBitmap_Start wijst progressieve status toe die PDFium koppelt aan de pagina, en die status wordt alleen vrijgegeven door FPDF_RenderPage_Close. Er zijn drie manieren om uit de besturingslus te komen. De pagina eindigt en de laatste status is FPDF_RENDER_DONE. Het token valt en de lus verlaat vroegtijdig de lus en meldt annulering. Er mislukt iets en de status is FPDF_RENDER_FAILED. Alle drie moeten Close aanroepen, en het annuleringspad is het gemakkelijkst om verkeerd te hebben, omdat de natuurlijke vorm van "zie annulering, breek uit" de neiging heeft om het opruimen (cleanup) over te slaan op weg naar de uitgang (exit). Door Close onbereikt te laten, lekt de status per pagina weg, en een viewer die de gebruiker de ene render na de andere laat annuleren, zou dit lek op elke afgebroken pagina opstapelen

De robuuste vorm plaatst de lus en de resultaatclassificatie binnen een try en FPDF_RenderPage_Close in de bijbehorende finally. De doelbitmap wordt in hetzelfde blok vernietigd. Annulering kan de lus verlaten via een vroege Exit en de finally wordt nog steeds uitgevoerd, dus er is precies één plaats die de progressieve staat vrijmaakt en deze kan niet worden omzeild

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;

De lus controleert het token vóór elke Continue, naast het vertrouwen op de callback die erin zit. De callback verkort de huidige brok; de luscontrole zorgt ervoor dat de volgende niet begint. Samen beperken ze de tijd die een annulering nodig heeft om effect te sorteren tot ongeveer de duur van één brok

Drie uitkomsten, en wat de bitmap na een annulering bevat

Het publieke toegangspunt is TPdf.RenderPageProgressive, en het retourneert een TPdfProgressiveStatus dat een van de volgende is: prsDone, prsCancelled of prsFailed. De waarden weerspiegelen de FPDF_RENDER_* constanten van PDFium in Pascal-idioom, maar vouwen de annuleringszaak in als een eersteklas resultaat in plaats van een fout

Het punt dat mensen overvalt, is wat de doelbitmap bevat na prsCancelled. Het is niet blanco. PDFium rendert progressief in dezelfde bitmap, brok na brok, dus wanneer een annulering de lus stopt, bevat de bitmap wat er tot dat moment was geschilderd, wat een gedeeltelijke afbeelding is: sommige banden zijn klaar, de rest toont nog de vulkleur. Of dat gedeeltelijke resultaat bruikbaar is, hangt af van de beller. Een viewer die op het punt staat de bitmap weg te gooien omdat de gebruiker ergens anders heen is genavigeerd, kan het eenvoudigweg negeren. Een viewer die een goedkope preview wil tonen, kan het behouden. Wat u niet mag doen, is aannemen dat prsCancelled een lege of ongedefinieerde bitmap impliceert; het impliceert een waarheidsgetrouwe momentopname van een onvoltooide rendering

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;

Het nil token en een callback-pad zonder vertakkingen

Annulering is opt-in. Een aanroeper die alleen progressieve weergave wil voor het voordeel van message-pumping, zonder de bedoeling om af te breken, zou nil voor het token moeten kunnen doorgeven. De naïeve manier om dat te ondersteunen, is om "als een token werd geleverd"-controles te verspreiden door de callback en de lus, wat betekent dat er bij elke brok een aftakking (branch) is en een callback die zowel een echt token als de afwezigheid ervan moet afhandelen

De implementatie vermijdt dat door een singleton te vervangen wanneer de aanroeper niets doorgeeft. Een nil token wordt verwisseld voor PdfNoCancellationToken, een interface waarvan IsCancelled altijd onwaar is. Vanaf dat punt hebben de callback en de lus in elk geval een token om te ondervragen, dus geen van beide heeft een nil-controle nodig en geen van beide heeft een speciaal pad nodig. Het never-cancel-token antwoordt simpelweg altijd onwaar, de callback retourneert altijd nul, en het renderen loopt precies door tot de voltooiing, net als een niet-annuleerbare rendering zou doen. Optioneel gedrag wordt gemodelleerd als een token dat nooit wordt geactiveerd (fires) in plaats van als de afwezigheid van een token, waardoor het hot path uniform blijft

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

De vorm die tevoorschijn komt, is klein en de moeite waard om opnieuw te formuleren, omdat het het herbruikbare deel is. Een C-bibliotheek die een callback ondersteunt, geeft u precies één kanaal om de status door te geven aan die callback: de ondoorzichtige (opaque) user pointer. Zet een getelde Pascal interface referentie achter die aanwijzer, houd een tweede echte referentie in leven naast de structuur zodat het object niet midden in de aanroep kan worden verzameld, en lees de interface terug uit in een statische cdecl functie. Wikkel de hele aandrijvende lus in een try en maak de native context vrij in de finally. Hetzelfde sjabloon kan worden overgedragen naar elke progressieve of callback-gedreven PDFium operatie waarbij Pascal code de controle over de levensduur moet behouden terwijl C een aanwijzer (pointer) vasthoudt

Annulering is slechts één helft van een responsieve viewer. De andere helft is het niet opnieuw renderen van pagina's die u al had getekend, en het soepel houden van zoomen en scrollen door bitmaps in de cache te serveren, wat wordt behandeld in ons artikel over rendercaching en zoomprestaties. Voor hoe het annuleerbare renderen past in een complete viewer naast navigatie, selectie en zoeken, zie het bouwen van een functierijke PDF-viewer met de PDFium VCL-component. Het hier beschreven progressieve renderen wordt geleverd als onderdeel van de PDFium Component voor Delphi en Lazarus, naast de API's voor laden, renderen en formulieren die elders op deze blog worden behandeld