Article technique

Chargement progressif de PDF par plages en Delphi

Une archive scannée de 2 Go vit dans un bucket S3 et l’utilisateur veut la page 900. PDFlibPas peut servir cette page sans télécharger le fichier : LoadFromRangeSource construit un flux seekable en lecture seule au-dessus de votre propre rappel de plage d’octets et le remet à TPDFDocument, si bien que l’analyseur tire les tables de références croisées, une branche de l’arbre de pages, et un flux de contenu

Le côté transport est vieux et ennuyeux. Les serveurs HTTP annoncent les plages d’octets depuis des décennies, désormais spécifiées dans RFC 9110 §14, et chaque stockage objet parle le même dialecte. Le côté PDF est tout aussi réglé : l’ISO 32000-1 §7.5.8 définit la linéarisation précisément pour qu’un lecteur puisse rendre la première page depuis l’avant du fichier. Ce qui manquait en Delphi, c’est la pièce au milieu, celle qui décide quelles plages demander, combien en garder, et comment éviter de demander deux fois

Que demande LoadFromRangeSource à votre transport ?

Deux choses, et aucune n’est un flux. PDFlibPas demande un SourceSize autoritaire et un rappel de lecture synchrone de type TPDFlibRangeReadEvent, déclaré comme function(Sender: TObject; Offset: Int64; Buffer: Pointer; Count: LongInt): LongInt of object. En interne, la paire devient un TCallbackByteRangeSource exposant SourceSize et ReadRange, enveloppé dans un flux dont la propriété passe au document. Votre cible de rappel et son backend restent à vous : le document libère l’enveloppe à la fermeture, au clear ou au rechargement, mais il ne touche jamais l’objet transport derrière le pointeur de méthode

Le contrat est délibérément indulgent dans un sens et strict dans l’autre. Une lecture courte est légale et signifie simplement que l’analyseur redemande. Un rappel qui lève est converti en lecture courte et converge par le chemin normal d’échec de chargement. Un rappel qui prétend avoir écrit plus de Count octets est borné, car un fournisseur bogué ne doit pas pouvoir dépasser le tampon de cache. Les réessais de mot de passe reconstruisent un flux de plages frais et un état d’analyse frais sur la même source de rappel, si bien qu’une tentative échouée ne peut laisser derrière elle de position, de fenêtre ni d’état de déchiffrement périmés

type
  TObjectStoreSource = class
  private
    FClient: TRangeHttpClient;
    FSize: Int64;
  public
    function ReadRange(Sender: TObject; Offset: Int64;
      Buffer: Pointer; Count: LongInt): LongInt;
    function IsResident(Sender: TObject; Offset: Int64;
      Count: LongInt): Integer;
    property Size: Int64 read FSize;
  end;

function TObjectStoreSource.ReadRange(Sender: TObject; Offset: Int64;
  Buffer: Pointer; Count: LongInt): LongInt;
begin
  { un GET bloquant avec Range: bytes=Offset-(Offset+Count-1) }
  Result := FClient.FetchInto(Offset, Count, Buffer);
end;

{ ... }
Lib := TPDFlib.Create;
Src := TObjectStoreSource.Create(BucketUrl);
try
  if Lib.LoadFromRangeSource(Src.Size, Src.ReadRange, '',
       65536, 8 * 1024 * 1024, 2, Src.IsResident) = 1 then
    Lib.SelectPage(900);
finally
  Lib.Free;  { libère le flux enveloppe }
  Src.Free;  { votre transport, votre durée de vie }
end;

Combien le cache de plages retient-il réellement ?

Par défaut 4 MiB, répartis sur des fenêtres alignées sur les chunks et évacués en LRU. L’ancienne conception à fenêtre unique grandissait jusqu’à la longueur demandée par l’appelant, si bien qu’une grande lecture séquentielle pouvait dépasser la taille nominale de chunk tandis qu’un saut aléatoire jetait immédiatement la fenêtre précédente. Le cache actuel aligne chaque décalage source sur ChunkSize, récupère exactement un chunk par miss, et applique un budget d’octets strict à travers plusieurs fenêtres. Tout budget explicite que vous passez est relevé à au moins un chunk complet, si bien qu’une seule lecture avance toujours chunk par chunk et que la charge de crête du cache reste prévisible. Un ChunkSize inférieur à 4096 retombe sur la valeur par défaut de 64 KiB

Comment PDFlibPas sert une lecture de l’analyseur en Delphi sans télécharger le PDF : le décalage absolu est aligné par le bas sur la taille de chunk, servi depuis l’une des fenêtres LRU en cas de hit, ou transformé en un seul appel de rappel borné en cas de miss
Chaque décalage source est aligné sur la taille de chunk, si bien qu’un miss récupère exactement un chunk et que la charge de crête du cache reste prévisible

La comptabilisation des relectures est la partie qui mérite d’être câblée dans votre télémétrie. PDFlibPas identifie une relecture par début de chunk aligné et garde des intervalles contigus ordonnés, ce qui sépare une vraie première récupération d’une récupération répétée après éviction tout en gardant la comptabilité loin de croître linéairement avec la taille du fichier. GetRangeSourceCacheInfo renvoie tout le tableau en JSON, SetRangeSourceCacheLimit redimensionne le budget à l’exécution, et ClearRangeSourceCache jette les fenêtres et remet les statistiques ensemble. Réduire le budget à l’exécution garde l’historique et compte les libérations dues au budget comme évictions, si bien qu’un repeatedReads montant face à des hits plats est votre signal que l’ensemble de travail ne tient plus

var
  Info: WideString;
begin
  Lib.SetRangeSourceCacheLimit(16 * 1024 * 1024);
  Lib.SelectPage(900);
  if Lib.GetRangeSourceCacheInfo(Info) = 1 then
    { "windowCount", "cacheLimitBytes", "cachedBytes", "hits", "misses",
      "evictions", "sourceReads", "sourceBytes", "repeatedReads",
      "coalescedRequests", "coalescedSourceReads" }
    LogRangeStats(Info);
end;

Que se passe-t-il quand plusieurs threads veulent le même chunk ?

Ils attendent sur une seule requête, pas plusieurs. Un TStream classique a un seul curseur de position, et deux threads qui verrouillent chacun correctement peuvent quand même voir cette position réécrite entre un Seek et un Read, si bien que les objets paresseux et les lectures segmentées de PDFlibPas utilisent un ReadAt absolu qui ne déplace jamais le curseur. Chaque chunk aligné reçoit une seule requête en vol que chaque appelant pour ce chunk partage, les chunks adjacents en file sont fusionnés avant que la lecture source ne démarre, et une lecture physique est plafonnée à 16 MiB, si bien qu’une rafale de travail parallèle sur les pages ne se démultiplie ni en requêtes petites dupliquées ni en une seule absurde géante. La fenêtre de fusion est de 2 ms par défaut et ne s’applique qu’au premier chunk manquant de chaque ReadAt ; le Read positionnel ne l’attend jamais, et passer zéro supprime entièrement le délai de collecte initial, ce qui compte pour les longs scans séquentiels qui accumuleraient sinon l’attente chunk par chunk. Position, métadonnées de cache et lectures source siègent derrière trois verrous séparés, et le rappel source lui-même est sérialisé, ce qui permet à un adaptateur de base de données ou de stockage objet sans protection de threads interne d’être utilisé tel quel. Les serveurs reçoivent chacun leur copie des données, si bien qu’une éviction LRU ultérieure ne peut invalider un tampon déjà remis

Fusion de requêtes dans le chargement par plages de PDFlibPas pour Delphi : deux threads demandant le même chunk partagent une requête en vol, les chunks adjacents en file sont fusionnés dans une fenêtre de deux millisecondes, et une seule lecture source sérialisée les sert tous
Une rafale de travail parallèle sur les pages se replie en une requête partagée par chunk, et chaque serveur reçoit quand même sa propre copie des octets

Pouvez-vous demander si la page 900 est prête sans la récupérer ?

Oui, et c’est exactement le rôle du rappel de disponibilité optionnel. Un rappel de lecture ordinaire ne peut pas distinguer les octets déjà arrivés des octets qui exigent un aller-retour bloquant, et sonder par une lecture d’essai déclencherait le téléchargement même que vous essayez d’éviter. TPDFlibRangeAvailabilityEvent ne répond qu’à une question, à savoir si une plage complète peut être lue immédiatement, et il lui est interdit de récupérer quoi que ce soit ; les octets déjà couverts par le cache comptent toujours comme disponibles. GetRangeSourceDataAvailability mappe les objets indirects vers les plages de stockage physique enregistrées dans les entrées de références croisées, résout les objets compressés vers leur conteneur object stream, corrige un en-tête PDF décalé, et ne parse un objet qu’après que la plage complète passe la sonde sans récupération, si bien que le chemin manquant n’appelle jamais votre rappel de lecture

La traversée est délimitée plutôt qu’exhaustive. Une requête de page ne parcourt que la branche de l’arbre de pages contenant la page cible puis ajoute contenu de page, ressources, annotations et attributs de page hérités, en sautant les liens arrière Parent et P pour qu’une seule page ou un widget ne puisse se déployer en arrière dans tout le document. Le graphe d’objets est borné à 100000 objets demandés et une profondeur de 256, les objets flux sont parsés dictionnaire d’abord, et un repli de parse complet n’est autorisé que pour les objets stockés jusqu’à 4 MiB. Le rapport JSON fusionne les intervalles qui se chevauchent et les adjacents avant de compter, si bien que requiredBytes et missingBytes sont calculés depuis les tableaux fusionnés requiredRanges et missingRanges, dont le end est un point final inclusif. Interroger un objet déjà disponible peut peupler le cache de plages ; interroger un objet manquant laisse les statistiques de lecture intactes

var
  Report: WideString;
  Status: Integer;
begin
  Status := Lib.GetRangeSourceDataAvailability(PDF_RANGE_DATA_PAGE, 900,
    Report);
  if Status = PDF_RANGE_DATA_AVAILABLE then
    RenderPageNow
  else if Status = PDF_RANGE_DATA_NOT_AVAILABLE then
    { Report porte "missingBytes" plus les "missingRanges" fusionnés }
    ShowProgress(Report)
  else if Status = PDF_RANGE_DATA_NOT_PRESENT then
    ShowMissingFeature;  { ex. le fichier n'a aucun AcroForm du tout }
end;

Pourquoi le préchargement doit itérer

Parce que lire les missingRanges courants une fois ne rend pas la page disponible. Un nœud d’arbre de pages manquant ou un object stream ne révèle la couche suivante de dépendances qu’après son arrivée, si bien qu’un travail de préchargement PDFlibPas exécute une boucle requête, récupération, requête jusqu’à ce que la page, le formulaire ou le graphe d’objets soit complètement disponible ou qu’une limite d’octets ou de passes l’arrête. Le travail utilise son propre lecteur et un petit cache secondaire dont la source de données transmet les lectures absolues au flux de plages original, ce qui garde l’état d’analyse isolé du TSmartPDFReader de premier plan pendant que les octets qu’il télécharge réellement atterrissent quand même dans le cache principal partagé. Un thread de travail existe par flux de plages, calquant la sérialisation que le rappel source exige déjà, et la file choisit par quatre niveaux de priorité puis par ordre de soumission dans un niveau. MaxBytes est facturé en octets de chunks physiques, si bien qu’un analyseur qui demande un seul octet à l’intérieur d’un chunk non mis en cache paie quand même le chunk entier, tandis que les chunks déjà dans le cache partagé ne coûtent rien au travail. Annuler un travail en file atteint un état terminal avec zéro lecture source ; un travail en cours est vérifié avant chaque passe de dépendances et chaque chunk source, et libérer le flux de plages attend qu’un rappel en vol revienne au lieu d’essayer de l’interrompre

La boucle de préchargement PDFlibPas en Delphi : un travail interroge la disponibilité, récupère les plages manquantes et réinterroge, car chaque nœud d’arbre de pages ou object stream qui arrive révèle la couche suivante de dépendances, jusqu’à ce que le graphe se complète ou qu’une limite l’arrête
Un travail de préchargement itère car un nœud manquant ne nomme ses propres enfants qu’une fois arrivé, et il facture chaque passe en chunks physiques entiers
var
  Job: Integer;
  Info: WideString;
begin
  Job := Lib.StartRangeSourcePrefetch(PDF_RANGE_DATA_PAGE, 901,
    PDF_RANGE_PREFETCH_PRIORITY_HIGH, 8 * 1024 * 1024, 65536);
  if Lib.WaitForRangeSourcePrefetch(Job, 5000) =
       PDF_RANGE_PREFETCH_STATE_COMPLETED then
    PrepareNextPage
  else
    Lib.CancelRangeSourcePrefetch(Job);
  { "passes", "plannedRanges", "sourceReads", "fetchedBytes" et le dernier
    rapport de disponibilité complet, si bien que LIMIT_REACHED reste
    distinguable de FAILED }
  Lib.GetRangeSourcePrefetchInfo(Job, Info);
end;

Où cela dégénère en téléchargement du fichier entier

Le chargement par plages est un pari sur la disposition du fichier, et certains fichiers ne l’honorant pas. Un fichier linéarisé selon l’ISO 32000-1 §7.5.8 est le bon cas : la section première page est chauffée à l’ouverture, bornée à la fois par le seuil de sécurité 4 MiB existant et par le budget de cache courant pour que l’échauffement ne puisse pas s’évincer lui-même immédiatement en grande partie. Un fichier non linéarisé se résout quand même par le trailer et la chaîne de références croisées près de la fin, ce qui coûte quelques allers-retours supplémentaires plutôt qu’une catastrophe. La vraie falaise est un fichier endommagé qui force le chemin de réparation, car reconstruire une table de références croisées signifie chercher les en-têtes d’objets à travers tout le document, et c’est un téléchargement complet qui arrive un chunk à la fois. La latence est l’autre limite honnête : à 60 ms par requête, un parse à accès aléatoire exigeant quarante chunks non mis en cache passe plus de deux secondes en transit quelle que soit la qualité du cache, et c’est précisément ce que l’argument de lecture anticipée et la file de priorité existent pour cacher. La même discipline se retrouve dans l’approche d’accès direct pour fusionner et découper de gros PDF, et ce cache siège sous le rendu parallèle de pages et le cache de pages disque du visualiseur aussi bien

L’API de source de plages, la requête de disponibilité et l’ordonnanceur de préchargement font partie de la PDFlibPas Delphi PDF Library standard pour Delphi, C++Builder et Free Pascal ; la page produit porte la référence complète des paramètres de LoadFromRangeSource avec les constantes de priorité et d’état du préchargement