Technical Article

Diffusion de PDF volumineux à la demande avec PDFium dans Delphi

Une archive numérisée peut atteindre plusieurs gigaoctets dans un seul fichier PDF. Un lecteur qui ouvre ce fichier souhaite généralement afficher une seule page, par exemple la table des matières ou une page cible atteinte depuis un signet. Charger l'intégralité du fichier en mémoire pour n'en afficher que deux pages est inefficace sous tous les aspects : cela consomme de l'espace d'adressage, fait patienter l'utilisateur pendant une longue lecture initiale, et sur un processus Delphi 32 bits, cela peut tout simplement échouer avant même qu'une seule page ne s'affiche. PDFium a été conçu dans cette optique. Il sait charger un document via un rappel (callback) qui réclame les plages d'octets spécifiques dont il a besoin au moment opportun, sans jamais exiger la totalité du fichier en une fois.

Le composant expose cette possibilité à travers un adaptateur de flux. Vous lui transmettez n'importe quel TStream, et PDFium extrait les blocs de ce flux à la demande. Le fichier peut résider sur le disque, dans un champ Blob de base de données ou derrière tout autre descendant de TStream, sans qu'aucune portion ne soit copiée en mémoire au préalable.

Comment PDFium demande ses octets

L'API C de PDFium charge un document à partir d'un objet fourni par l'appelant et décrit par la structure FPDF_FILEACCESS. Cette structure comporte trois parties importantes ici : un champ de longueur, un rappel de lecture et un paramètre utilisateur opaque. Le point d'entrée qui la consomme est FPDF_LoadCustomDocument. Dès que PDFium dispose de cette structure, il analyse le trailer, la table de références croisées et ne lit ensuite que ce qu'une opération donnée requiert. L'ouverture du document lit la fin du fichier et quelques objets du catalogue. Le rendu de la page 400 lit uniquement les flux de contenu et les ressources associés à cette page, rien d'autre.

C'est là toute la différence entre un chargement avec tampon (buffered) et un chargement par diffusion (streaming). Un chargement avec tampon lit le fichier de bout en bout avant même que PDFium n'accède à l'octet zéro. Un chargement par diffusion inverse cette relation : PDFium orchestre les lectures, et les octets qui ne sont jamais sollicités ne sont jamais lus. Pour un fichier de plusieurs gigaoctets consulté page par page, c'est ce qui fait la différence entre un chargement impossible et un affichage instantané.

L'adaptateur de flux

L'adaptateur servant de passerelle entre un TStream Delphi et la structure FPDF_FILEACCESS est TPdfStreamAdapter. Son constructeur prend le flux et un indicateur de propriété, capture la longueur du flux une seule fois, renseigne l'enregistrement FPDF_FILEACCESS et configure le rappel de lecture. Lorsque PDFium effectue ultérieurement un rappel avec un décalage (offset) et une taille, l'adaptateur positionne le flux à ce décalage et copie précisément cette plage dans le tampon fourni par PDFium.

// Verbatim from the component: the stream-to-FPDF_FILEACCESS bridge
constructor TPdfStreamAdapter.Create(AStream: TStream; AOwnsStream: Boolean);
begin
  inherited Create;
  if AStream = nil then
    raise EPdfError.Create('TPdfStreamAdapter: AStream is nil');
  FStream := AStream;
  FOwnsStream := AOwnsStream;

  // FPDF_FILEACCESS.m_FileLen is a 32-bit unsigned long. Refuse a stream
  // that would silently truncate past 4 GiB.
  if AStream.Size > High(FPDF_DWORD) then
    raise EPdfError.Create('TPdfStreamAdapter: stream exceeds the 4 GiB limit');

  FillChar(FFileAccess, SizeOf(FFileAccess), 0);
  FFileAccess.m_FileLen  := FPDF_DWORD(AStream.Size);
  FFileAccess.m_GetBlock := GetBlockCallback;
  FFileAccess.m_Param    := Self;
end;

L'indicateur de propriété décide de qui doit libérer le flux. Passez False et l'appelant conserve le flux et doit le maintenir actif pendant toute la durée de vie du document. Passez True et l'adaptateur prend le relais, libérant le flux à la fermeture du document. Dans tous les cas, le flux doit survivre à chaque lecture effectuée par PDFium, car celui-ci conserve le pointeur FPDF_FILEACCESS et peut l'appeler à tout moment lorsque le document est ouvert, et pas seulement durant le chargement initial.

Pourquoi le rappel est une fonction statique

Le rappel de lecture enregistré par PDFium dans m_GetBlock is a plain C function pointer avec la convention d'appel cdecl. Une méthode Delphi ne peut pas être utilisée directement, car elle transporte un argument masqué Self qu'un appelant C ne connaît pas et ne transmettra pas. L'adaptateur déclare donc le rappel comme une fonction de classe (class function) marquée cdecl; static, ce qui produit une fonction autonome respectant la structure d'appel C attendue par PDFium sans Self implicite.

Cela résout la question de la convention d'appel mais en soulève une autre : sans Self, comment le rappel accède-t-il au flux spécifique dans lequel il doit lire ? La réponse réside dans le paramètre utilisateur opaque. Lorsque l'adaptateur construit l'enregistrement, il stocke son propre pointeur d'instance dans m_Param. PDFium renvoie ce même pointeur comme premier argument de chaque rappel. La fonction statique le transtype ensuite en un TPdfStreamAdapter et effectue la lecture sur le flux de cette instance. C'est le mécanisme de redirection (trampoline) classique pour transmettre un contexte d'objet à travers une frontière C qui n'a pas la notion d'objets.

// Verbatim from the component: the cdecl trampoline back to the instance
class function TPdfStreamAdapter.GetBlockCallback(
  param   : Pointer;
  position: FPDF_DWORD;
  pBuf    : PByte;
  size    : FPDF_DWORD): Integer; cdecl;
var
  Adapter: TPdfStreamAdapter;
begin
  Result := 0;
  if (param = nil) or (pBuf = nil) or (size = 0) then
    Exit;
  Adapter := TPdfStreamAdapter(param);   // recover the instance from m_Param
  if Adapter.FStream = nil then
    Exit;
  try
    Adapter.FStream.Position := Int64(position);
    Adapter.FStream.ReadBuffer(pBuf^, Int64(size));
    Result := 1;
  except
    Result := 0;  // report failure by return value, never by raising
  end;
end;

Le plafond des 4 Gio et pourquoi il nécessite un garde-fou

Le champ de longueur m_FileLen de la structure FPDF_FILEACCESS est une valeur non signée de 32 bits. Sa plus grande longueur représentable est inférieure d'un octet à 4 Gio. Un TStream signalant sa taille sous la forme d'un Int64, un flux peut décrire beaucoup plus d'octets que ce que le champ peut contenir. Dès que la taille d'un flux dépasse ce plafond, il devient impossible d'indiquer correctement à PDFium la longueur du fichier.

La mauvaise solution consisterait à lui affecter la taille et à la laisser boucler. Tronquer une longueur de 5 Gio dans un champ de 32 bits produit un petit nombre d'apparence correcte, et PDFium analysera alors le fichier en pensant qu'il se termine vers un gigaoctet. Le trailer et la table de références croisées se trouvant à la fin réelle du fichier, bien au-delà de la longueur tronquée, l'analyse échouera d'une manière sans rapport avec la cause réelle. Vous chercheriez à résoudre une erreur de références croisées sur un fichier tout à fait valide, sans vous douter qu'un entier a débordé deux couches au-dessus.

L'adaptateur choisit plutôt de refuser l'entrée. Le constructeur compare la taille du flux à High(FPDF_DWORD) et lève une exception EPdfError dès que le flux est trop grand pour être décrit. Une erreur explicite et immédiate identifie le problème réel dès la construction. Une troncature silencieuse masquerait la cause derrière un symptôme trompeur identifié bien plus tard. La limite de 4 Gio est une contrainte réelle de ce chemin de chargement, et la bonne approche consiste à la signaler clairement plutôt que de la masquer avec des calculs arithmétiques qui compilent par hasard.

Les échecs ne doivent pas franchir la frontière

Une lecture peut échouer. Le flux peut être un objet réseau sujet à des dépassements de délai, un descripteur Blob fermé inopinément, ou un fichier tronqué après l'ouverture du document. PDFium's contract for the read callback est une valeur de retour : non nulle pour un succès, nulle pour un échec. Il s'agit d'un cadre d'exécution C, dépourvu de structure pour intercepter ou propager une exception Pascal.

C'est pourquoi le trampoline enveloppe le positionnement et la lecture dans un bloc try/except qui intercepte l'exception et renvoie zéro. Si une exception Delphi était autorisée à se propager hors du rappel, elle remonterait la pile d'appels cdecl de PDFium, laquelle n'a pas été conçue pour les mécanismes de gestion d'exceptions Pascal. Le résultat serait au mieux un comportement indéfini, au pire un plantage sec au cœur du décodeur PDF sans trace de pile exploitable. Renvoyer zéro permet de confiner l'échec dans les termes du contrat. PDFium détecte l'échec de lecture, interrompt l'opération proprement, et FPDF_LoadCustomDocument signale que le document n'a pas pu être chargé, ce que le composant traduit par une exception EPdfError du côté Pascal.

Ouvrir un document par ce moyen

La méthode du composant qui pilote la diffusion est LoadCustomDocument, déclarée de manière distincte plutôt que comme une autre surcharge de LoadDocument, afin d'éviter qu'un TMemoryStream ne bascule par erreur sur le chemin avec tampon. Elle instancie l'adaptateur, appelle FPDF_LoadCustomDocument et maintient l'adaptateur actif pendant toute la durée d'ouverture du document.

var
  Pdf: TPdf;
  FileStream: TFileStream;
begin
  Pdf := TPdf.Create(nil);
  FileStream := TFileStream.Create('Archive_4GB.pdf', fmOpenRead or fmShareDenyWrite);
  try
    // Hand stream ownership to Pdf: it frees FileStream when the document closes.
    Pdf.LoadCustomDocument(FileStream, True);
    // PDFium has read only the trailer and catalog so far.
    // Rendering a page pulls just that page's bytes through the callback.
    // ... render or inspect pages here ...
  finally
    Pdf.Free;  // closes the document, which frees the adapter and the stream
  end;
end;

Le même appel s'applique à un TMemoryStream, à un flux Blob de base de données ou à un descendant personnalisé de TStream. Le chargement à la demande prend tout son sens lorsque le fichier est volumineux et que seule une partie doit être lue : un lecteur d'archives, un générateur de vignettes échantillonnant quelques pages, ou un indexeur de recherche lisant une page à la fois. Lorsque le fichier est petit ou que vous devez de toute façon en lire l'intégralité, un chargement classique avec tampon est plus simple et le mécanisme de diffusion n'apporte rien. Le facteur déterminant est le rapport entre les octets que vous allez effectivement manipuler et ceux que le fichier contient.

Une fois les pages lues à la demande, l'étape suivante consiste à maintenir la réactivité des pages affichées lorsque l'utilisateur zoome et fait défiler le document, ce qui est traité dans notre note sur le cache de rendu et les performances du zoom. Si le document diffusé doit être affiché par un lecteur sans possibilité d'exportation ou de modification par l'utilisateur, les techniques du guide sur l'aperçu PDF sécurisé s'associent naturellement à ce mode de chargement. Ces deux objectifs s'appuient sur le chargement par diffusion décrit ici, fourni avec le composant PDFium pour Delphi et C++Builder, aux côtés des API de rendu, d'extraction de texte et d'annotation présentées par ailleurs sur ce blog.