La plupart des pages PDF se rastérisent en quelques millisecondes et vous n'y pensez jamais. Puis un utilisateur ouvre un plan technique au format A1, une page truffée de dizaines de milliers de traits vectoriels, ou une affiche encombrée de groupes de transparence et de masques doux, et le seul appel qui la dessine prend deux ou trois secondes. Si cet appel s'exécute sur le thread d'interface utilisateur, la fenêtre cesse de se redessiner, la barre de titre grise, et le système d'exploitation propose de tuer l'application. Le travail est légitime. La page a vraiment besoin de tout ce temps. Le défaut, c'est que le rendu est un appel bloquant et indivisible, sans aucun moyen de reprendre son souffle ni de s'arrêter
Cet article traite exactement de l'un de ces deux problèmes : annuler le rendu d'une longue page unique sans geler l'interface utilisateur. L'utilisateur a cliqué sur la page suivante, ou zoomé, ou fermé le document, et le rendu en cours devient un travail gaspillé qui devrait s'arrêter à la prochaine occasion plutôt que d'aller jusqu'au bout. Fluidifier le défilement et le zoom en mettant en cache ce qui a déjà été rastérisé est une préoccupation distincte, avec sa propre conception, traitée dans l'article complémentaire cité en fin de page. 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 que PDFium fournit déjà
PDFium avait anticipé la moitié « gel de l'interface » du problème. À côté de l'appel unique FPDF_RenderPageBitmap, il expose une variante progressive qui découpe une page en tranches de travail. Vous appelez une fois FPDF_RenderPageBitmap_Start pour configurer le rendu vers un bitmap de destination, puis vous appelez FPDF_RenderPage_Continue de façon répétée. Chaque Continue rastérise une tranche bornée et renvoie un statut. FPDF_RENDER_TOBECONTINUED signifie qu'il reste du travail, FPDF_RENDER_DONE signifie que la page est terminée, et FPDF_RENDER_FAILED signifie que le rendu s'est arrêté sur une erreur. Lorsque la boucle se termine, vous appelez FPDF_RenderPage_Close pour libérer l'état progressif propre à la page. Comme le contrôle revient à votre code entre les tranches, vous pouvez purger la file de messages, mettre à jour un indicateur de progression, ou vérifier si le travail est toujours souhaité
Le mécanisme que PDFium fournit pour décider du moment où céder la main est une structure de rappel nommée IFSDK_PAUSE. Vous la transmettez à Start et à chaque Continue. Après chaque tranche, PDFium appelle son pointeur de fonction NeedToPauseNow, et si celui-ci renvoie une valeur non nulle, le Continue en cours s'arrête prématurément et rend la main avec FPDF_RENDER_TOBECONTINUED. La structure porte aussi un champ version, qui doit être fixé à 1, et un pointeur user à forme libre que PDFium ne touche jamais et transmet tel quel. Ce pointeur intact est le pivot de toute la conception qui suit
Détourner la pause pour en faire une annulation
L'intention initiale de NeedToPauseNow est le découpage temporel. Renvoyez une valeur non nulle quand votre budget de trame est épuisé, renvoyez zéro pour continuer à rendre, et PDFium fait une pause afin que vous puissiez faire autre chose avant de reprendre ce même rendu. Le PDFium Component réutilise ce même signal pour un verbe différent. Au lieu de répondre « dois-je mettre en pause et vous laisser reprendre », le rappel répond « ce travail a-t-il été annulé ». Les deux se recouvrent proprement en raison de ce que fait la boucle lorsqu'elle voit le drapeau. Une vraie pause attend un Continue ultérieur ; une annulation, non. Dès que la boucle appelante observe que le jeton est annulé, elle referme le contexte de rendu et n'appelle plus jamais Continue, si bien que le même retour non nul que PDFium interprète comme « arrête cette tranche » devient, en pratique, « arrête pour de bon »
L'annulation s'exprime au travers d'une interface, IPdfCancellationToken, dont la propriété IsCancelled bascule de false à true 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 simple pointeur. La référence d'interface du jeton est écrite dans IFSDK_PAUSE.user, et un rappel statique en cdecl la relit et l'interroge. C'est le problème classique consistant à laisser une bibliothèque C rappeler du code Pascal : le rappel doit être une fonction simple avec convention d'appel C, pas une méthode, parce que PDFium stocke et invoque un pointeur de fonction nu qui ne connaît rien des objets Pascal ni de Self
type
TPdfProgressivePause = record
Pause: IFSDK_PAUSE; // PDFium lit ceci ; .user contient le jeton
Token: IPdfCancellationToken; // référence forte qui maintient le jeton en vie
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 nul : PDFium arrête cette tranche
end;
Le rappel récupère le jeton en reconvertissant pThis^.user vers le type interface, puis lit IsCancelled. Rien dans cette fonction n'alloue, ne verrouille, ni ne bloque, ce qui compte parce que PDFium l'appelle sur le thread de rendu après chaque tranche, et tout travail effectué ici s'ajoute 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 sans risque, même sur un rendu auquel aucun jeton réel n'a jamais été fourni
Maintenir le jeton en vie pendant toute la boucle
Convertir un pointeur d'interface via un Pointer brut, puis le reconvertir, est exactement l'endroit où naissent les bugs de durée de vie. Une IInterface en Delphi est à comptage de références, et ce compteur ne bouge que lorsque le compilateur voit une variable de type interface se faire assigner. Stocker le jeton uniquement sous forme de pointeur nu à l'intérieur de IFSDK_PAUSE.user le soustrairait complètement au compteur de références. Si l'unique autre référence à ce jeton sortait de portée pendant que la boucle Continue tournait encore, l'objet serait libéré sous les pieds du rappel, et la tranche suivante déréférencerait un pointeur pendant
C'est pourquoi le descripteur est un enregistrement qui contient deux éléments, et non un seul. Le champ Pause est la structure que PDFium lit. Le champ Token est une véritable référence de type interface que le compilateur comptabilise, et il n'existe pour aucune autre raison que d'épingler le jeton en mémoire aussi longtemps que l'enregistrement vit. L'enregistrement est une variable locale sur la pile de la routine de rendu, donc il reste 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
// ... choisir EffectiveToken ...
// Référence forte d'abord, puis publication du même objet vers PDFium via .user.
Pause.Token := EffectiveToken;
Pause.Pause.version := 1;
Pause.Pause.NeedToPauseNow := ProgressivePauseCallback;
Pause.Pause.user := Pointer(EffectiveToken);
Fermer le contexte de rendu quelle que soit la façon dont 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 existe trois façons de sortir de la boucle de pilotage. La page se termine et le dernier statut est FPDF_RENDER_DONE. Le jeton se déclenche et la boucle sort prématurément en signalant l'annulation. Quelque chose échoue et le statut est FPDF_RENDER_FAILED. Les trois cas doivent appeler Close, et le chemin d'annulation est celui qu'il est le plus facile de mal gérer, parce que la forme naturelle de « voir l'annulation, sortir de la boucle » tend à sauter le nettoyage sur le chemin de la sortie. Laisser Close inatteint fait fuir l'état propre à la page, et un visualiseur qui laisse l'utilisateur annuler rendu après rendu accumulerait cette fuite à chaque page abandonnée
La forme robuste place la boucle et la classification du résultat à l'intérieur d'un try, et FPDF_RenderPage_Close dans le finally correspondant. Le bitmap de destination est détruit dans le même bloc. L'annulation peut faire sortir de la boucle via un Exit prématuré, et le finally s'exécute quand même, si bien qu'il existe exactement un seul 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
// Libère l'état progressif alloué par Start ; obligatoire sur chaque chemin.
FPDF_RenderPage_Close(FPage);
FPDFBitmap_Destroy(PdfBmp);
end;
La boucle vérifie le jeton avant chaque Continue, en plus de s'appuyer sur le rappel à l'intérieur de celui-ci. Le rappel raccourcit la tranche en cours ; la vérification de la boucle empêche la suivante de démarrer. Ensemble, ils bornent le temps que met une annulation à prendre effet à environ la durée d'une tranche
Trois issues, 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 vaut soit prsDone, soit prsCancelled, soit prsFailed. Ces valeurs reflètent les constantes FPDF_RENDER_* de PDFium dans un idiome Pascal, mais intègrent le cas d'annulation comme un résultat de premier ordre plutôt que comme une erreur
Le point qui piège les gens, c'est ce que contient le bitmap de destination après un prsCancelled. Il n'est pas vide. PDFium rend progressivement dans le même bitmap, tranche après tranche, donc quand une annulation arrête la boucle, le bitmap conserve tout ce qui a été peint jusqu'à ce moment-là, c'est-à-dire une image partielle : certaines bandes terminées, le reste affichant encore la couleur de remplissage. L'utilité de ce résultat partiel dépend de l'appelant. Un visualiseur qui s'apprête à jeter le bitmap parce que l'utilisateur a navigué ailleurs peut simplement l'ignorer. Un visualiseur qui veut afficher un aperçu à faible coût peut le conserver. Ce qu'il ne faut surtout pas faire, c'est supposer que prsCancelled implique un bitmap vide ou indéfini ; il implique un instantané fidèle d'un rendu inachevé
var
Bmp: TBitmap;
Token: IPdfCancellationToken;
Status: TPdfProgressiveStatus;
begin
Bmp := TBitmap.Create;
try
// Le jeton démarre non annulé ; faites basculer Token.IsCancelled depuis
// ailleurs (une action UI, un événement de navigation) pour interrompre le rendu en cours.
Status := Pdf.RenderPageProgressive(Bmp, 0, 0, PageW, PageH, Token);
case Status of
prsDone: Image1.Picture.Assign(Bmp); // entièrement rendu
prsCancelled: ; // bitmap partiel, généralement écarté
prsFailed: ShowMessage('Render failed');
end;
finally
Bmp.Free;
end;
end;
Le jeton nil et un chemin de rappel sans branchement
L'annulation est optionnelle. Un appelant qui veut simplement du rendu progressif pour le bénéfice de la purge de messages, sans intention d'interrompre quoi que ce soit, doit pouvoir passer nil pour le jeton. La façon naïve de prendre cela en charge consiste à disséminer des vérifications « un jeton a-t-il été fourni » dans le rappel et la boucle, ce qui signifie un branchement à chaque tranche 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 passe rien. Un jeton nil est remplacé par PdfNoCancellationToken, une interface dont IsCancelled vaut toujours false. À partir de là, le rappel et la boucle disposent d'un jeton à interroger dans tous les cas, si bien qu'aucun des deux n'a besoin d'une vérification de nil ni d'un chemin spécial. Le jeton qui n'annule jamais répond simplement toujours false, le rappel renvoie toujours zéro, et le rendu s'exécute jusqu'au bout exactement comme le ferait un rendu non annulable. Le comportement optionnel est modélisé comme un jeton qui ne se déclenche jamais plutôt que comme l'absence d'un jeton, ce qui garde le chemin critique uniforme
// nil -> singleton qui n'annule jamais, si bien que le chemin de rappel est identique
// que l'appelant ait ou non choisi l'annulation.
if AToken <> nil then
EffectiveToken := AToken
else
EffectiveToken := PdfNoCancellationToken;
La forme qui se dégage est petite et mérite d'être reformulée, car c'est la partie réutilisable. Une bibliothèque C qui prend en charge un rappel vous donne exactement un canal pour transmettre de l'état à ce rappel, le pointeur opaque user. Placez une référence d'interface Pascal comptée derrière ce pointeur, gardez une seconde référence réelle en vie à côté de la structure pour que l'objet ne puisse pas être collecté en plein appel, et relisez l'interface à l'intérieur d'une fonction statique en cdecl. Enveloppez toute la boucle de pilotage dans un try et libérez le contexte natif dans le finally. Le même schéma 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 que la moitié d'un visualiseur réactif. L'autre moitié consiste à ne pas rerendre les pages déjà dessinées, et à garder le zoom et le défilement fluides en servant des bitmaps mis en cache, ce qui est traité dans notre article sur la mise en cache du rendu et la performance du zoom. Pour voir comment le rendu annulable s'intègre dans un visualiseur complet aux côtés de la navigation, de la sélection et de la recherche, consultez la construction d'un visualiseur PDF riche en fonctionnalités avec le PDFium Component. Le rendu progressif décrit ici fait partie du PDFium Component pour Delphi et Lazarus, aux côtés des API de chargement, de rendu et de formulaires traitées ailleurs sur ce blog