Article technique

RenderCacheFolder : le cache disque de HotPDF en Delphi

HotPDF RenderCacheFolder transforme le cache de pages rendues en mémoire du composant HotPDF Delphi en un cache de pages persistant sur disque : les pages rendues sont écrites en fichiers PNG sous un dossier de votre choix, et la prochaine fois que la même source PDF est ouverte, RenderLoadedPageToBitmapCached les relit au lieu de rastériser à nouveau. L’ordre de recherche est mémoire, puis disque, puis le moteur de rendu

Le niveau disque est dans l’API depuis v2.416.0, mais jusqu’à v2.770.140 il n’a jamais réellement servi une page pour un appel LoadFromFile ou LoadFromStream normal. La correction a forcé une question à laquelle tout cache persistant doit répondre : comment savez-vous que le fichier ouvert aujourd’hui est le document rendu hier, et qu’advient-il des pages en cache quand ce n’est pas le cas ? Voici les réponses retenues par HotPDF, y compris là où il refuse délibérément de mettre en cache

Comment fonctionne le cache de rendu disque de HotPDF ?

Le cache de rendu disque de HotPDF est un second niveau derrière le cache raster en mémoire, et il ne participe que quand RenderCacheFolder est un chemin non vide. Un appel à RenderLoadedPageToBitmapCached(PageIndex, DPI) balaie d’abord les entrées en mémoire, indexées par index de page, DPI et une variante de réglages de rendu. Sur un raté, il interroge le niveau disque ; un succès disque décode le PNG, le repromeut en mémoire et renvoie une copie dont l’appelant est propriétaire. Ce n’est que quand les deux niveaux ratent que la page passe par l’interpréteur de flux de contenu décrit dans le rendu d’une page PDF chargée vers un TBitmap, et le bitmap frais est alors aussi écrit sur disque

Schéma HotPDF de la recherche dans le cache de rendu pour RenderLoadedPageToBitmapCached : le niveau en mémoire indexé par page, DPI et variante de rendu est consulté en premier, puis le niveau disque RenderCacheFolder de fichiers PNG avec remplacement atomique, puis l’interpréteur de flux de contenu, et chaque succès renvoie une copie dont l’appelant est propriétaire
HotPDF regarde en mémoire d’abord, puis sur disque, et ne rastérise qu’ensuite ; un succès disque est repromu en mémoire et chaque chemin vous remet une copie qui vous appartient et que vous devez libérer

Sur disque, l’agencement est délibérément ennuyeux. Chaque document reçoit un sous-dossier nommé d’après une clé de document de 16 caractères hexadécimaux plus une variante de rendu de 16 caractères hexadécimaux, chaque page est stockée en <page>@<dpi>.png, et un index.txt à la racine garde les documents en ordre dernier utilisé derrière une balise de schéma. Un désaccord de schéma vide le dossier au premier usage. Les écritures vont d’abord dans un fichier temporaire et sont permutées en place par un remplacement atomique, si bien qu’un crash en pleine écriture laisse soit l’ancienne page soit rien, jamais un demi-PNG. Un PNG qui échoue au décodage est supprimé et compté comme un raté

Trois limites bornent le dossier :

  • RenderCacheMaxDocuments (défaut 20) plafonne le nombre de sous-dossiers de documents ; le dossier le moins récemment utilisé est évincé en premier
  • RenderCacheMaxBytes (défaut 524288000, soit 500 Mo) plafonne la taille totale de tous les fichiers PNG sous la racine
  • Chaque dossier de document garde au plus 200 images de pages ; ce plafond par document est figé dans THotPDF et n’est pas une propriété publiée

RenderCacheCapacity (défaut 8) est un bouton séparé : il règle combien de pages rendues le niveau en mémoire garde, et il n’a rien à voir avec l’empreinte disque

uses
  SysUtils, Graphics, HPDFDoc;

procedure WarmThumbnails(const FileName: string);
var
  Pdf: THotPDF;
  Bmp: TBitmap;
  I: Integer;
begin
  Pdf := THotPDF.Create(nil);
  try
    // Configurez le niveau disque avant le premier rendu mis en cache :
    // le dossier et les deux limites sont lus au premier usage du niveau
    Pdf.RenderCacheFolder := IncludeTrailingPathDelimiter(
      GetEnvironmentVariable('LOCALAPPDATA')) + 'MyViewer\PageCache';
    Pdf.RenderCacheMaxDocuments := 50;
    Pdf.RenderCacheMaxBytes := Int64(1024) * 1024 * 1024; // 1 Gio
    Pdf.RenderCacheCapacity := 16;                        // pages en mémoire

    if Pdf.LoadFromFile(FileName) > 0 then
      for I := 0 to Pdf.LoadedPageCount - 1 do
      begin
        Bmp := Pdf.RenderLoadedPageToBitmapCached(I, 96);
        if Bmp <> nil then
        try
          // Remettez la copie à la bande de vignettes ici
        finally
          Bmp.Free; // l’appel en cache renvoie toujours une copie à l’appelant
        end;
      end;
  finally
    Pdf.Free; // depuis v2.770.140 ceci ne supprime plus les entrées disque
  end;
end;

Exécutez la même procédure deux fois et le second passage ne rastérise jamais une page qui tenait dans le cache. L’objet de cache disque est créé paresseusement au premier rendu mis en cache et vit jusqu’à la libération de l’instance THotPDF, donc changer RenderCacheFolder, RenderCacheMaxDocuments ou RenderCacheMaxBytes après ce point ne déplace ni ne redimensionne un cache déjà ouvert. Les pages trop grandes pour la politique d’admission en mémoire (par défaut une seule entrée ne peut pas dépasser 64 Mio de pixels 32 bits) ne sont pas non plus persistées, et le niveau disque n’est consulté que tant que RenderFallbackPolicy garde son rfpIgnore par défaut, parce que les diagnostics de repli ne sont pas stockés à côté du PNG

Pourquoi RenderCacheFolder n’a-t-il jamais marché avant v2.770.140 ?

RenderCacheFolder n’avait aucun effet avant v2.770.140 parce que le niveau disque indexait les documents sur un hash des octets sources que les chargements ordinaires ne conservaient jamais. La clé de document venait d’un SHA-256 sur une copie interne des octets PDF bruts, mais LoadFromFile et LoadFromStream analysent la source sur place et ne gardent pas une telle copie ; le champ n’était rempli que temporairement sur un chemin de récupération chiffrée puis aussitôt vidé. Sans octets, la clé était toujours vide, et une clé vide veut dire que le niveau disque est contourné. Pas d’erreur, pas d’avertissement, juste un dossier qui restait vide

Rendre la clé non vide a exposé un second bug qui se cachait derrière le premier. L’ancien InvalidateRenderedPageCache supprimait le dossier disque du document, et InvalidateRenderedPageCache s’exécute au début de chaque chargement, à chaque édition et à l’intérieur de Free. Dès que la clé aurait marché, chaque session de visualiseur aurait détruit son propre cache en sortant, et la session suivante serait de toute façon repartie à froid. Pire, la clé était recalculée depuis la même source après une édition, si bien que les rendus du document édité auraient été stockés sous la clé du fichier d’origine et servis à la session suivante qui ouvrirait le PDF non modifié. v2.770.140 corrige l’identité et l’invalidation ensemble ; n’en corriger qu’une aurait livré soit un cache mort soit un cache menteur

Comment HotPDF identifie un PDF sans lire tout le fichier

HotPDF identifie un PDF chargé depuis un fichier local par une empreinte de sa taille, de sa date de dernière écriture et de ses premiers et derniers 64 Kio, et identifie une source flux ou à accès aléatoire par un SHA-256 de tout son contenu. Les deux sont capturés une fois, quand un chargement réussit, et les 16 premiers caractères hexadécimaux du condensat SHA-256 (64 bits) deviennent la clé de document

SourceIdentitéCoûtCapturé quand
LoadFromFileTaille + LastWriteTime + premiers et derniers 64 Kio, hachés en SHA-256Au plus 128 Kio lus, indépendant de la taille du fichierChaque chargement réussi, même si RenderCacheFolder est défini plus tard
LoadFromStreamSHA-256 de tout le fluxUne passe complète sur la sourceSeulement si RenderCacheFolder était défini avant le chargement
LoadFromRandomAccessSourceSHA-256 de toute la sourceUne passe complète sur la sourceSeulement si le dossier était défini d’abord et que toute la plage est disponible
Toute source avec une entrée /EncryptAucuneAucunJamais ; le niveau disque est contourné
Carte d’identité de source HotPDF pour le cache de rendu disque : LoadFromFile hache taille, LastWriteTime et premiers et derniers 64 Kio, LoadFromStream et LoadFromRandomAccessSource hachent tout le contenu seulement quand RenderCacheFolder était défini d’abord, et tout trailer /Encrypt ne capture aucune identité
les fichiers sont empreintés par leurs extrémités parce que l’en-tête, le xref et le trailer y vivent, les flux ne paient un hash complet que si vous avez demandé le cache d’abord, et les documents chiffrés ne sont jamais écrits sur disque

L’empreinte de fichier est un compromis délibéré. Hacher en entier une archive scannée de 400 Mo à chaque ouverture peut coûter plus que le rendu des deux pages que l’utilisateur regarde réellement. Les régions échantillonnées ne sont pas arbitraires : l’en-tête siège au début du fichier, et le trailer et la dernière section de références croisées siègent à la fin (ISO 32000-1 §7.5). Une mise à jour incrémentale ajoute un nouveau corps, une section de références croisées et un trailer (§7.5.6), donc elle change la taille et la queue d’un coup. Une réécriture complète par n’importe quel outil normal change la date de dernière écriture. Pour les fichiers jusqu’à 128 Kio, les deux échantillons couvrent chaque octet, donc les petits documents sont de fait hachés en entier

Le risque résiduel est une modification sur place, à taille égale, au milieu d’un gros fichier dont le rédacteur restaure ensuite l’horodatage d’origine. Il faut pour ça un outil qui préserve délibérément les dates de modification tout en éditant le contenu, ce qui est rare mais pas impossible, et dans ce cas le cache sert des pages périmées. L’envers de la médaille est bénin : copier un fichier sous Windows préserve normalement sa date de dernière écriture, si bien qu’une copie d’un document déjà en cache touche les mêmes entrées, ce qui est correct parce que les octets sont identiques

Les flux n’ont aucune date de modification du tout, donc la seule identité honnête est le contenu. HotPDF ne paie cette passe SHA-256 complète que si vous avez demandé un cache disque avant le chargement ; tout autre appelant de LoadFromStream ne voit aucun coût supplémentaire. L’ordre d’affectation des propriétés devient donc déterminant :

procedure OpenDownloadedPdf(Pdf: THotPDF; Data: TStream;
  const CacheRoot: string);
begin
  // Mauvais ordre pour les flux : le hash du contenu n’est calculé que
  // quand le dossier est déjà défini, donc ce document contournerait le niveau disque
  //   Pdf.LoadFromStream(Data);
  //   Pdf.RenderCacheFolder := CacheRoot;

  Pdf.RenderCacheFolder := CacheRoot; // défini d’abord
  Data.Position := 0;
  if Pdf.LoadFromStream(Data) <= 0 then
    raise Exception.Create('The stream is not a loadable PDF');
end;

Une source à accès aléatoire encore en téléchargement (certaines plages pas encore disponibles) ne reçoit aucune identité plutôt qu’un hash de contenu partiel, et si le calcul de l’identité échoue pour une raison quelconque, le chargement réussit quand même ; le document se rend simplement sans le niveau disque

Qu’est-ce qui invalide une entrée du cache disque HotPDF ?

Une entrée du cache disque HotPDF n’est jamais invalidée en la supprimant à l’édition ; à la place, éditer le document chargé abandonne l’identité du document, si bien que le niveau disque est contourné pour le reste de ce chargement et que les pages stockées restent valides pour la source non modifiée. Les entrées quittent le disque seulement par les limites LRU et octets, un PNG corrompu, ou un changement de schéma

La clé décrit une source sur disque, pas le graphe d’objets en mémoire. Dès que vous tamponnez une page ou changez une annotation, le document ne correspond plus à cette source, donc ni lire ni écrire sous sa clé ne serait correct. Depuis v2.770.140, l’invalidation au niveau document comme au niveau page efface l’identité au lieu de toucher au dossier, et il y a un second garde-fou pour les éditions qui n’ont pas appelé InvalidateRenderedPageCache : avant d’utiliser le niveau disque, THotPDF vérifie si un objet chargé est dirty et traite un document dirty comme sans identité

Les réglages de rendu marchent dans l’autre sens. Changer PageRenderBackend (ou appeler UseNativeGDIRenderBackend), et appeler ConfigureRenderICCWorkflow ou ClearRenderICCWorkflow, vide les pages en mémoire mais garde l’identité, parce que le document correspond toujours à sa source. Ces réglages changent les pixels sans faire partie de la variante en mémoire, si bien que la clé disque incorpore le nom du backend, le drapeau de compensation du point noir et les condensats SHA-256 des profils ICC d’épreuve et de sortie. La variante couvre déjà elle-même l’intention colorimétrique, le dithering de sortie, l’aperçu de surimpression, le mode masque de luminosité, la politique de repli et la visibilité de chaque groupe de contenu optionnel, donc basculer un calque rend dans un dossier différent au lieu d’écraser la vue par défaut

Sémantique d’invalidation HotPDF pour le cache disque RenderCacheFolder : éditer le document chargé ou tout objet dirty abandonne l’identité de source si bien que le niveau est contourné, changer le backend de rendu ou le workflow ICC garde l’identité sous une nouvelle clé de variante, et sauvegarder puis recharger re-keye le document
une édition ne supprime jamais le dossier stocké, un changement de réglages rend sous une clé différente, et seule une sauvegarde plus un rechargement vaut au document édité une identité fraîche

Pour remettre un document édité sur le niveau disque, donnez-lui une nouvelle identité de source en le sauvegardant et en chargeant le résultat :

procedure CommitEditsAndRekey(Pdf: THotPDF; const EditedFile: string);
begin
  // Après avoir édité le document chargé : rafraîchissez les pages en mémoire.
  // L’identité de source est déjà partie, donc rien n’est lu ni
  // écrit dans le dossier disque du document d’origine
  Pdf.InvalidateRenderedPageCache;

  // Un fichier sauvegardé a une nouvelle taille et date, donc une nouvelle
  // identité ; les rendus après ce chargement sont mis en cache sous la nouvelle clé
  Pdf.SaveLoadedDocument(EditedFile);
  if Pdf.LoadFromFile(EditedFile) <= 0 then
    raise Exception.Create('Could not reload the edited document');
end;

Le dossier du document d’origine est laissé tranquille et vieillit via RenderCacheMaxDocuments et RenderCacheMaxBytes comme n’importe quelle autre entrée. Si l’utilisateur rouvre l’original non édité, ses pages sont toujours là

Frontières de sécurité : sources chiffrées et dossiers liés

Le cache de rendu disque de HotPDF refuse deux sortes d’entrées à dessein : il n’écrit jamais les pages d’un PDF chiffré sur disque, et il ne suit jamais un sous-dossier de document qui est une jonction ou un autre reparse point. Les deux règles échangent des succès de cache contre le fait de ne pas fuiter de données ni supprimer les mauvais fichiers

Les PDF chiffrés ne sont jamais mis en cache sur disque

Une page rendue est du contenu déchiffré. L’écrire en PNG ordinaire dans un dossier de cache laisserait une copie lisible d’un document protégé par mot de passe sur disque, hors de la protection choisie par l’auteur (ISO 32000-1 §7.6). HotPDF ne capture donc aucune identité pour toute source dont le trailer porte une entrée /Encrypt, y compris les fichiers ouverts avec un mot de passe ou avec un mot de passe utilisateur vide. Ces documents utilisent toujours le niveau en mémoire, qui meurt avec le processus

Les sous-dossiers jonction sont rejetés depuis v2.770.173

La racine du cache est votre choix, et la pointer vers une jonction est permis. Les sous-dossiers de documents en dessous sont une autre affaire : le cache les crée, les lit, les touche et les supprime de son propre chef, pendant la récupération au démarrage (qui retire les fichiers temporaires restants), la recherche (qui met à jour les horodatages), le stockage, l’invalidation et les trois limites d’éviction. Si quelqu’un ayant accès en écriture à la racine du cache remplace un dossier de document par une jonction vers un autre répertoire, chacun de ces chemins le suivrait, et l’éviction supprimerait des fichiers quelque part que le cache n’a jamais possédé. Depuis v2.770.173, chacun de ces points d’entrée vérifie l’attribut de reparse point et saute un dossier de document lié : une recherche compte un raté, un stockage compte un échec d’écriture, et l’éviction le laisse tranquille

Chemins Unicode et racines partagées

Deux corrections liées comptent si vous déployez vers des profils utilisateurs. Avant v2.770.135, RenderCacheFolder était un AnsiString, si bien qu’un dossier hors de la page de code système (un nom d’utilisateur chinois sur une installation Windows anglaise, par exemple) était converti avec perte avant que le cache ne le voie ; la propriété est désormais une string Unicode, et le remplacement atomique passe par l’API Windows large. Depuis v2.770.52, plusieurs instances THotPDF dans un même processus qui pointent vers la même racine (après expansion du chemin, comparaison insensible à la casse) partagent un index et un verrou uniques à comptage de références. Avant, chaque instance réécrivait index.txt avec sa propre copie et faisait respecter les limites contre sa vue partielle, si bien que le dossier pouvait dépasser plusieurs fois son budget

Ce partage s’arrête à la frontière du processus. Deux processus séparés sur la même racine tiennent toujours des index en mémoire distincts, donc donnez à chaque application tournant concurremment sa propre racine de cache. Les visualiseurs qui rendent sur des threads de travail vont bien au sein d’un processus : PrefetchLoadedPages et la file traitée dans le rendu en arrière-plan avec une file de requêtes passent tous deux par le même chemin en cache et le même verrou

Aide-mémoire : checklist RenderCacheFolder

  • Définissez RenderCacheFolder, RenderCacheMaxDocuments et RenderCacheMaxBytes avant le premier appel à RenderLoadedPageToBitmapCached ; pour les chargements par flux et à accès aléatoire, définissez le dossier avant de charger
  • Passez à v2.770.140 ou ultérieure si vous comptez sur le niveau disque ; les versions antérieures acceptent la propriété mais ne servent jamais une page depuis le disque pour des chargements normaux
  • N’attendez aucun cache disque pour les PDF chiffrés, pour les documents édités après le chargement, ou tant que RenderFallbackPolicy n’est pas rfpIgnore
  • Libérez l’instance THotPDF normalement ; depuis v2.770.140, ni Free ni InvalidateRenderedPageCache ne suppriment les entrées disque
  • Changer PageRenderBackend ou le workflow ICC garde le document sur le niveau disque sous une clé différente
  • Utilisez une racine de cache par application en cours d’exécution ; les instances au sein d’un processus partagent l’index depuis v2.770.52
  • Gardez la racine du cache dans un emplacement par utilisateur ; les sous-dossiers de documents qui sont des jonctions sont sautés depuis v2.770.173

Un cache de pages persistant rapporte le plus dans un visualiseur qui rouvre les mêmes documents toute la journée, exactement la forme de l’architecture de visualiseur PDF personnalisé en Delphi décrite ailleurs sur ce blog. RenderCacheFolder, le cache raster en mémoire et le moteur de rendu de pages sont livrés avec le composant HotPDF Delphi PDF pour Delphi et C++Builder