Article technique

Lecture en continu (streaming) d'immenses PDF à la demande avec PDFium dans Delphi

Une archive numérisée (scanned archive) peut atteindre plusieurs gigaoctets dans un seul PDF. Une visionneuse qui ouvre un tel fichier veut généralement afficher une seule page, peut-être la table des matières, peut-être une page à laquelle l'utilisateur a accédé via un signet. Lire le fichier entier en mémoire pour afficher deux pages est un gaspillage sur tous les axes : cela brûle l'espace d'adressage, cela bloque (stalls) l'utilisateur derrière une longue lecture initiale, et sur un processus Delphi 32 bits, cela peut échouer purement et simplement avant qu'une seule page n'apparaisse. PDFium a été conçu dans cet esprit. Il peut charger un document via un rappel (callback) qui demande les plages d'octets spécifiques dont il a besoin, quand il en a besoin, et il ne demande jamais le fichier entier d'un coup. Une limite doit être posée d'emblée : ce canal de streaming (streaming channel) décrit le fichier avec une longueur de 32 bits, il sert donc un fichier unique jusqu'à 4 Gio, ce qui couvre en pratique presque toutes les archives numérisées. Un fichier au-delà de cette ligne n'est pas le territoire de cet article ; il doit plutôt être divisé en volumes au moment de la numérisation ou ouvert via une stratégie d'accès direct, et la garde (guard) qui applique ce plafond a honnêtement droit à sa propre section ci-dessous

Le composant expose ce chemin via un adaptateur de flux (stream adapter). Vous lui remettez n'importe quel TStream, et PDFium extrait (pulls) des blocs de ce flux à la demande. Le fichier peut se trouver sur le disque, dans un champ blob de base de données, ou derrière tout autre descendant de TStream, et rien n'est copié en mémoire à l'avance

Comment PDFium demande des octets

L'API C de PDFium charge un document à partir d'un objet fourni par l'appelant décrit par la structure FPDF_FILEACCESS. La structure comporte trois parties qui importent ici : un champ de longueur, un rappel de lecture (read callback) et un paramètre utilisateur opaque (opaque user parameter). Le point d'entrée qui le consomme est FPDF_LoadCustomDocument. Une fois que PDFium détient cette structure, il analyse le trailer, localise la table de références croisées (cross-reference table), et à partir de là ne lit que ce dont une opération donnée a besoin. L'ouverture du document touche la fin du fichier et une poignée d'objets de catalogue. Le rendu de la page 400 lit les flux de contenu et les ressources pour cette page et rien d'autre

C'est la différence entre un chargement mis en mémoire tampon (buffered load) et un chargement en continu (streaming load). Un chargement mis en mémoire tampon lit le fichier de bout en bout avant que PDFium ne voie l'octet zéro. Un chargement en continu inverse la relation : PDFium pilote les lectures, et les octets qui ne sont jamais touchés ne sont jamais lus. Pour un fichier de plusieurs gigaoctets visualisé une page à la fois, c'est le fossé entre un chargement inutilisable et un chargement instantané

L'adaptateur de flux (stream adapter)

L'adaptateur qui fait le pont entre un TStream Delphi et FPDF_FILEACCESS est TPdfStreamAdapter. Son constructeur prend le flux et un indicateur de propriété (ownership flag), capture la longueur du flux une fois, remplit l'enregistrement FPDF_FILEACCESS, et connecte le rappel de lecture. Lorsque PDFium rappelle plus tard avec un décalage (offset) et une taille, l'adaptateur recherche le flux à ce décalage et copie exactement cette plage dans le tampon fourni par PDFium

// Mot pour mot du composant : le pont (bridge) stream-vers-FPDF_FILEACCESS
constructor TPdfStreamAdapter.Create(AStream: TStream; AOwnsStream: Boolean);
begin
  inherited Create;
  if AStream = nil then
    raise EPdfError.Create('TPdfStreamAdapter : AStream est nil');
  FStream := AStream;
  FOwnsStream := AOwnsStream;

  // FPDF_FILEACCESS.m_FileLen est un entier long non signé (unsigned long) de 32 bits. Refuser un flux
  // qui tronquerait silencieusement au-delà de 4 Gio.
  if AStream.Size > High(FPDF_DWORD) then
    raise EPdfError.Create('TPdfStreamAdapter : le flux dépasse la limite de 4 Gio');

  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 qui libère le flux. Passez False et l'appelant conserve le flux et doit le maintenir en vie pendant toute la durée de vie du document. Passez True et l'adaptateur prend le relais, libérant le flux lorsque le document se ferme. De toute façon, le flux doit survivre à chaque lecture que PDFium effectuera, car PDFium détient le pointeur FPDF_FILEACCESS et rappellera à tout moment pendant que le document est ouvert, pas seulement pendant le chargement initial

Pourquoi le rappel (callback) est une fonction statique

Le rappel de lecture que PDFium stocke dans m_GetBlock est un simple pointeur de fonction C avec la convention d'appel cdecl. Une méthode Delphi ne peut pas être utilisée directement, car une méthode transporte un argument Self caché dont un appelant C ne sait rien et ne fournira jamais. L'adaptateur déclare donc le rappel comme une class function marquée cdecl; static, qui se compile en une fonction autonome avec la disposition de trame (frame layout) C à laquelle PDFium s'attend et sans Self implicite

Cela résout la convention d'appel mais soulève une deuxième question : sans Self, comment le rappel accède-il au flux spécifique à partir duquel il est censé lire ? La réponse est 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 caste de nouveau en un TPdfStreamAdapter et répartit (dispatches) la lecture sur le flux de cette instance. C'est le trampoline (trampoline) standard pour transmettre le contexte d'objet à travers une limite C qui n'a aucune notion des objets

// Mot pour mot du composant : le trampoline cdecl de retour à l'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);   // récupérer l'instance à partir de 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;  // rapporter l'échec par valeur de retour, jamais en levant une exception
  end;
end;

Le plafond de 4 Gio et pourquoi il a besoin d'une garde (guard)

C'est de là que vient la limite énoncée dans l'introduction. Le champ de longueur m_FileLen dans FPDF_FILEACCESS est une valeur non signée (unsigned value) de 32 bits. Sa plus grande longueur représentable est à un octet de 4 Gio. Un TStream signale sa taille en tant qu'Int64, un flux peut donc décrire beaucoup plus d'octets que le champ ne peut en contenir. À partir du moment où la taille d'un flux dépasse ce plafond, il n'y a pas de moyen honnête de dire à PDFium la longueur du fichier

La mauvaise réponse est d'assigner la taille et de la laisser se replier (wrap). Tronquer une longueur de 5 Gio à un champ de 32 bits produit un petit nombre d'apparence plausible, et PDFium analysera alors le fichier en croyant qu'il se termine à environ un gigaoctet. Le trailer et la table des références croisées se trouvent à la véritable fin du fichier, bien au-delà de la longueur tronquée, de sorte que l'analyse échoue d'une manière qui n'a rien à voir avec la cause réelle. Vous débogueriez une erreur de référence croisée sur un fichier parfaitement valide, sans aucune indication qu'un entier s'est replié (wrapped) deux couches plus haut

L'adaptateur refuse plutôt l'entrée. Le constructeur compare la taille du flux par rapport à High(FPDF_DWORD) et lève une exception (raises) EPdfError dès l'instant où le flux est trop grand pour être décrit. Une erreur explicite et immédiate nomme le vrai problème au point de construction. Une troncature (truncation) silencieuse le cache derrière un symptôme trompeur que vous poursuivriez bien plus tard. La limite de 4 Gio est une véritable contrainte de ce chemin de chargement, et la chose honnête à faire est de la signaler (surface) bruyamment plutôt que de la masquer (paper over it) avec une arithmétique qui se trouve être compilable. Lorsqu'une archive franchit véritablement la ligne, les remèdes promis en haut se situent en dehors de cette API : divisez l'analyse (scan) en fichiers par volume qui restent chacun sous le plafond, ou laissez le document sur le disque et servez-le via une conception d'accès direct construite sur des décalages (offsets) de 64 bits plutôt que via FPDF_FILEACCESS

Les défaillances ne doivent pas franchir la limite

Une lecture peut échouer. Le flux peut être un objet sauvegardé sur le réseau (network-backed) qui expire, un handle blob qui a été fermé sous vous, ou un fichier qui a été tronqué après l'ouverture du document. Le contrat de PDFium pour le rappel de lecture est une valeur de retour : non nul pour un succès, zéro pour un échec. C'est une trame (frame) C, et il n'y a pas de mécanisme pour attraper ou propager une exception Pascal

C'est pourquoi le trampoline enveloppe la recherche (seek) et la lecture dans un try/except qui avale l'exception et renvoie zéro. Si une exception Delphi était autorisée à se propager hors du rappel, elle se déroulerait (unwind) à travers les trames de pile (stack frames) cdecl de PDFium, qui n'ont jamais été conçues pour être déroulées par la machinerie d'exception Pascal. Le résultat est un comportement non défini (undefined behavior) au mieux et un plantage brutal (hard crash) au pire, au plus profond de l'analyseur PDF sans pile (stack) utilisable. Renvoyer zéro maintient l'échec dans le contrat. PDFium voit un échec de lecture de bloc, annule l'opération proprement, et FPDF_LoadCustomDocument signale que le document n'a pas pu être chargé, ce que le composant remonte sous forme de EPdfError côté Pascal, là où il a sa place

Ouvrir un document de cette façon

La méthode du composant qui pilote le chemin de streaming est LoadCustomDocument, déclarée comme une méthode distincte plutôt qu'une autre surcharge (overload) de LoadDocument de sorte que passer un TMemoryStream n'atterrisse jamais accidentellement sur le chemin mis en mémoire tampon. Elle construit l'adaptateur, appelle FPDF_LoadCustomDocument, et maintient l'adaptateur en vie pendant la durée de vie du document chargé

var
  Pdf: TPdf;
  FileStream: TFileStream;
begin
  Pdf := TPdf.Create(nil);
  FileStream := TFileStream.Create('Archive_4GB.pdf', fmOpenRead or fmShareDenyWrite);
  try
    // Transférer la propriété du flux à Pdf : il libère FileStream à la fermeture du document.
    Pdf.LoadCustomDocument(FileStream, True);
    // PDFium n'a lu que le trailer et le catalogue jusqu'à présent.
    // Le rendu d'une page n'extrait (pulls) que les octets de cette page via le rappel (callback).
    // ... rendre ou inspecter des pages ici ...
  finally
    Pdf.Free;  // ferme le document, ce qui libère l'adaptateur et le flux
  end;
end;

Le même appel fonctionne pour un TMemoryStream, un flux blob à partir d'un ensemble de données de base de données, ou un descendant TStream personnalisé. Le chargement à la demande mérite d'être conservé lorsque le fichier est volumineux et que seule une partie sera lue : une visionneuse d'archives, un générateur de vignettes (thumbnail generator) qui échantillonne quelques pages, un index de recherche qui extrait (pulls) une page à la fois. Lorsque le fichier est petit ou que vous allez le lire en entier de toute façon, un chargement mis en mémoire tampon est plus simple et la machinerie de streaming ne vous apporte rien. Le facteur décisif est le ratio entre les octets que vous toucherez réellement et les octets que contient le fichier

Une fois que les pages sont diffusées en continu à la demande, la prochaine préoccupation est de garder les pages rendues réactives lorsque l'utilisateur zoome et fait défiler, ce qui est couvert dans notre note sur la mise en cache du rendu et les performances de zoom. Lorsque le document diffusé en continu est un document qu'une visionneuse doit afficher mais ne pas laisser l'utilisateur exporter ou modifier, les techniques de la visite guidée (walkthrough) de l'aperçu PDF sécurisé se marient (pair) naturellement avec ce chemin de chargement. Les deux s'appuient sur le chargement en continu décrit ici, qui est 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 couvertes ailleurs sur ce blog