La plupart des pages PDF se pixellisent en quelques millisecondes et vous n'y pensez jamais. Ensuite, un utilisateur ouvre un dessin technique A1, une page remplie de dizaines de milliers de traits vectoriels, ou une affiche encombrée de groupes de transparence et de masques flous, et le seul appel qui le peint prend deux ou trois secondes. Si cet appel s'exécute sur le thread de l'interface utilisateur, la fenêtre cesse de se repeindre, la barre de titre devient grise et le système d'exploitation propose de tuer l'application. Le travail est légitime. La page a vraiment besoin d'autant de temps. Le défaut est que le rendu est un seul appel bloquant indivisible, sans aucun moyen de reprendre son souffle et sans aucun moyen de s'arrêter
Cet article porte exactement sur l'un de ces deux problèmes : l'annulation d'un long rendu d'une seule page sans geler l'interface utilisateur. L'utilisateur a cliqué sur la page suivante, a zoomé ou a fermé le document, et le rendu en cours est maintenant un travail gaspillé qui devrait se terminer à la prochaine occasion plutôt que de s'exécuter jusqu'au bout. Le lissage du défilement et du zoom par la mise en cache de ce qui a déjà été pixellisé est un problème distinct avec sa propre conception, couvert dans l'article connexe lié à la fin. Ici, la seule question est de savoir comment faire en sorte qu'un rendu progressif réponde à une demande d'annulation rapidement et proprement
L'API de rendu progressif déjà fournie par PDFium
PDFium a anticipé la moitié du problème liée au gel. À côté de la méthode ponctuelle FPDF_RenderPageBitmap, il expose une variante progressive qui divise une page en morceaux de travail. Vous appelez FPDF_RenderPageBitmap_Start une fois pour configurer le rendu vers un bitmap de destination, puis appelez FPDF_RenderPage_Continue à plusieurs reprises. Chaque appel à Continue pixellise une tranche délimitée et renvoie un état. FPDF_RENDER_TOBECONTINUED signifie qu'il reste encore à faire, FPDF_RENDER_DONE signifie que la page est terminée et FPDF_RENDER_FAILED signifie qu'elle s'est arrêtée sur une erreur. Lorsque la boucle se termine, vous appelez FPDF_RenderPage_Close pour libérer l'état progressif par page. Étant donné que le contrôle revient à votre code entre les tranches, vous pouvez traiter les messages, mettre à jour un indicateur de progression ou vérifier si le travail est toujours souhaité
Le mécanisme fourni par PDFium pour décider quand céder est une structure de rappel nommée IFSDK_PAUSE. Vous le remettez à Start et à chaque Continue. Après chaque morceau, PDFium appelle son pointeur de fonction NeedToPauseNow, et s'il renvoie une valeur non nulle, le Continue actuel s'arrête prématurément et rend le contrôle avec FPDF_RENDER_TOBECONTINUED. La structure comporte également un champ version, qui doit être défini sur 1, et un pointeur user de forme libre que PDFium ne touche jamais et transmet tel quel. Ce pointeur intact est la charnière entière de la conception qui suit
Réaffectation de la pause en tant qu'annulation
L'intention initiale de NeedToPauseNow est le découpage temporel. Renvoyez une valeur non nulle lorsque votre budget d'image est épuisé, renvoyez zéro pour continuer le rendu, et PDFium se met en pause pour que vous puissiez faire autre chose avant de reprendre le même rendu. Le PDFium Component réutilise ce même signal pour un verbe différent. Au lieu de répondre "dois-je faire une pause et vous laisser reprendre", le rappel répond "ce travail a-t-il été annulé". Les deux correspondent parfaitement en raison de ce que fait la boucle lorsqu'elle voit l'indicateur. Une véritable pause s'attend à un Continue ultérieur ; une annulation non. Une fois que la boucle d'appel observe que le jeton est annulé, elle ferme le contexte de rendu et n'appelle plus jamais Continue, de sorte que le même retour non nul que PDFium lit comme "arrêter ce morceau" devient, en effet, "s'arrêter pour de bon"
L'annulation est exprimée via une interface, IPdfCancellationToken, dont la propriété IsCancelled passe de faux à vrai lorsqu'une autre partie du programme demande l'arrêt du rendu. Le pont entre cette interface Pascal et le rappel C de PDFium est un pointeur unique. La référence d'interface du jeton est écrite dans IFSDK_PAUSE.user, et un rappel cdecl statique la relit et l'interroge. C'est le problème classique de laisser une bibliothèque C rappeler du Pascal : le rappel doit être une fonction simple avec la convention d'appel C, et non une méthode, car PDFium stocke et invoque un simple pointeur de fonction qui ne connaît rien des objets Pascal ou de 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;
Le rappel récupère le jeton en convertissant pThis^.user vers le type d'interface et lit IsCancelled. Rien à l'intérieur n'alloue, ne verrouille ou ne bloque, ce qui importe car PDFium l'appelle sur le thread de rendu après chaque morceau et tout travail effectué ici est ajouté au coût du rendu lui-même. La protection contre une structure nil ou un champ user nil signifie que la même fonction peut être installée en toute sécurité même sur un rendu qui n'a jamais reçu de jeton réel
Maintenir le jeton en vie tout au long de la boucle
La conversion d'un pointeur d'interface via un Pointer brut et inversement est l'endroit où naissent les bogues de durée de vie. Un IInterface dans Delphi est compté par référence, et le décompte ne bouge que lorsque le compilateur peut voir une variable de type interface être affectée. Le simple fait de stocker le jeton en tant que pointeur nu à l'intérieur de IFSDK_PAUSE.user le cacherait complètement au compteur de références. Si la seule autre référence à ce jeton sortait de la portée pendant que la boucle Continue était encore en cours d'exécution, l'objet serait libéré sous le rappel et le morceau suivant déréférencerait un pointeur non valide (dangling pointer)
C'est pourquoi le descripteur est un enregistrement contenant deux choses, et non une. Le champ Pause est la structure lue par PDFium. Le champ Token est une véritable référence de type interface que le compilateur compte, et il n'existe pour aucune autre raison que d'épingler le jeton en mémoire aussi longtemps que l'enregistrement est actif. L'enregistrement est une variable locale sur la pile de la routine de rendu, il reste donc valide pendant toute la durée de la boucle et n'est détruit que lorsque la routine se termine. Le pointeur nu dans user et la référence comptée dans Token désignent le même objet ; l'un est ce que PDFium peut lire, l'autre est ce qui empêche cet objet d'être collecté
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);
Fermer le contexte de rendu, peu importe comment la boucle se termine
Chaque appel à FPDF_RenderPageBitmap_Start alloue un état progressif que PDFium associe à la page, et cet état n'est libéré que par FPDF_RenderPage_Close. Il y a trois façons de sortir de la boucle de pilotage. La page se termine et le dernier état est FPDF_RENDER_DONE. Le jeton se déclenche et la boucle se ferme prématurément pour signaler l'annulation. Quelque chose échoue et l'état est FPDF_RENDER_FAILED. Tous les trois doivent appeler Close, et le chemin d'annulation est le plus facile à rater, car la forme naturelle de "voir annuler, sortir" a tendance à sauter le nettoyage sur le chemin de la sortie. Ne pas atteindre Close entraîne une fuite de l'état par page, et une visionneuse qui laisse l'utilisateur annuler le rendu après rendu accumulerait cette fuite sur chaque page abandonnée
La forme robuste place la boucle et la classification des résultats dans un bloc try et FPDF_RenderPage_Close dans le finally correspondant. Le bitmap de destination est détruit dans le même bloc. L'annulation peut quitter la boucle via un Exit précoce et le finally s'exécute toujours, il y a donc exactement un endroit qui libère l'état progressif et il ne peut pas être contourné
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;
La boucle vérifie le jeton avant chaque Continue et s'appuie également sur le rappel à l'intérieur. Le rappel raccourcit le morceau actuel ; la vérification de la boucle empêche le suivant de démarrer. Ensemble, ils limitent le temps nécessaire pour qu'une annulation prenne effet à peu près à la durée d'un morceau
Trois résultats, et ce que contient le bitmap après une annulation
Le point d'entrée public est TPdf.RenderPageProgressive, et il renvoie un TPdfProgressiveStatus qui est l'un des prsDone, prsCancelled ou prsFailed. Les valeurs reflètent les constantes FPDF_RENDER_* de PDFium dans l'idiome Pascal, mais intègrent le cas d'annulation comme un résultat de première classe plutôt que comme une erreur
Le point qui surprend les gens est ce que le bitmap de destination contient après prsCancelled. Il n'est pas vide. PDFium effectue un rendu progressif dans le même bitmap, morceau par morceau. Ainsi, lorsqu'une annulation arrête la boucle, le bitmap contient tout ce qui a été peint jusqu'à ce moment, ce qui est une image partielle : certaines bandes terminées, le reste affichant toujours la couleur de remplissage. L'utilité de ce résultat partiel dépend de l'appelant. Une visionneuse qui est sur le point de jeter le bitmap parce que l'utilisateur a navigué ailleurs peut simplement l'ignorer. Une visionneuse qui souhaite afficher un aperçu peu coûteux peut le conserver. Ce que vous ne devez pas faire, c'est supposer que prsCancelled implique un bitmap vide ou indéfini ; cela implique un instantané fidèle d'un rendu inachevé
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;
Le jeton nil et un chemin de rappel sans branchement
L'annulation est facultative. Un appelant qui souhaite simplement un rendu progressif pour l'avantage de la transmission de messages, sans aucune intention d'abandonner, devrait pouvoir transmettre nil pour le jeton. La manière naïve de prendre en charge cela consiste à disperser les vérifications "si un jeton a été fourni" dans le rappel et la boucle, ce qui signifie une branche sur chaque morceau et un rappel qui doit gérer à la fois un jeton réel et son absence
L'implémentation évite cela en substituant un singleton lorsque l'appelant ne transmet rien. Un jeton nil est échangé contre PdfNoCancellationToken, une interface dont IsCancelled est toujours faux. À partir de là, le rappel et la boucle ont un jeton à interroger dans tous les cas, donc aucun ne nécessite une vérification nil et aucun ne nécessite de chemin spécial. Le jeton de non-annulation (never-cancel token) répond simplement toujours faux, le rappel renvoie toujours zéro, et le rendu s'exécute jusqu'à la fin exactement comme le ferait un rendu non annulable. Le comportement facultatif est modélisé sous la forme d'un jeton qui ne se déclenche jamais plutôt que comme l'absence de jeton, ce qui maintient le chemin principal (hot path) uniforme
// 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;
La forme qui émerge est petite et mérite d'être rappelée, car c'est la partie réutilisable. Une bibliothèque C qui prend en charge un rappel vous donne exactement un canal pour passer l'état dans ce rappel, le pointeur utilisateur opaque. Placez une référence d'interface Pascal comptée derrière ce pointeur, gardez une deuxième référence réelle en vie à côté de la structure afin que l'objet ne puisse pas être collecté en cours d'appel, et lisez l'interface à l'intérieur d'une fonction cdecl statique. Enveloppez l'ensemble de la boucle de pilotage dans un bloc try et libérez le contexte natif dans le bloc finally. Le même modèle se transpose à toute opération PDFium progressive ou pilotée par rappel où le code Pascal doit garder le contrôle de la durée de vie pendant que le C détient un pointeur
L'annulation n'est qu'une moitié d'une visionneuse réactive. L'autre moitié consiste à ne pas restituer les pages que vous avez déjà dessinées, et à maintenir un zoom et un défilement fluides en diffusant des bitmaps mis en cache, ce qui est couvert dans notre article sur la mise en cache du rendu et les performances de zoom. Pour savoir comment le rendu annulable s'intègre dans une visionneuse complète aux côtés de la navigation, de la sélection et de la recherche, consultez la création d'une visionneuse PDF riche en fonctionnalités avec le composant VCL de PDFium. Le rendu progressif décrit ici est livré en tant que partie intégrante du composant PDFium pour Delphi et Lazarus, aux côtés des API de chargement, de rendu et de formulaire couvertes ailleurs sur ce blog