Articolo tecnico

RenderCacheFolder HotPDF: cache di pagine su disco in Delphi

RenderCacheFolder di HotPDF trasforma la cache delle pagine renderizzate in memoria del componente HotPDF Delphi in una cache di pagine persistente su disco: le pagine renderizzate vengono scritte come file PNG in una cartella che scegli tu, e la prossima volta che la stessa sorgente PDF viene aperta, RenderLoadedPageToBitmapCached le rilegge invece di rasterizzare di nuovo. L'ordine di ricerca è memoria, poi disco, poi il renderer

Il tier disco è nell'API dalla v2.416.0, ma fino alla v2.770.140 non ha mai servito davvero una pagina per una normale chiamata LoadFromFile o LoadFromStream. La correzione ha costretto a una domanda che ogni cache persistente deve rispondere: come fai a sapere che il file aperto oggi è il documento renderizzato ieri, e che fine fanno le pagine in cache quando non lo è? Qui sotto le risposte su cui HotPDF ha deciso, incluso dove si rifiuta deliberatamente di metterle in cache

Come funziona la cache di render su disco di HotPDF?

La cache di render su disco di HotPDF è un secondo tier dietro la cache raster in memoria, e partecipa solo quando RenderCacheFolder è un percorso non vuoto. Una chiamata a RenderLoadedPageToBitmapCached(PageIndex, DPI) scandisce prima le voci in memoria, indicate per indice di pagina, DPI e una variante di impostazioni di render. In caso di mancata risposta chiede al tier disco; un successo dal disco decodifica il PNG, lo promuove di nuovo in memoria e restituisce una copia di proprietà del chiamante. Solo quando entrambi i tier mancano la pagina passa per l'interprete dei content stream descritto in renderizzare una pagina PDF caricata in un TBitmap, e la bitmap fresca viene poi scritta anche su disco

Diagramma HotPDF della ricerca nella cache di render per RenderLoadedPageToBitmapCached: il tier in memoria indicizzato per pagina, DPI e variante di render viene controllato per primo, poi il tier disco RenderCacheFolder di file PNG con sostituzione atomica, poi l'interprete dei content stream, e ogni successo restituisce una copia di proprietà del chiamante
HotPDF guarda prima in memoria, poi su disco, e solo allora rasterizza; un successo dal disco viene promosso di nuovo in memoria e ogni via ti consegna una copia che possiedi e devi liberare

Su disco il layout è volutamente noioso. Ogni documento riceve una sottocartella nominata da una chiave documento di 16 caratteri esadecimali più una variante di render di 16 caratteri esadecimali, ogni pagina è conservata come <page>@<dpi>.png, e un index.txt alla radice tiene i documenti in ordine di uso più recente dietro un tag di schema. Uno schema diverso svuota la cartella al primo uso. Le scritture vanno prima su un file temporaneo e vengono scambiate sul posto con una sostituzione atomica, così un crash a metà scrittura lascia o la vecchia pagina o niente, mai metà PNG. Un PNG che non si decodifica viene cancellato e contato come mancato

Tre limiti contengono la cartella:

  • RenderCacheMaxDocuments (default 20) limita il numero di sottocartelle documento; la cartella usata meno di recente viene espulsa per prima
  • RenderCacheMaxBytes (default 524288000, cioè 500 MB) limita la dimensione totale di tutti i file PNG sotto la radice
  • Ogni cartella documento conserva al massimo 200 immagini di pagina; quel limite per documento è fisso di THotPDF e non è una proprietà pubblicata

RenderCacheCapacity (default 8) è una manopola separata: stabilisce quante pagine renderizzate tiene il tier in memoria, e non c'entra nulla con l'impronta su disco

uses
  SysUtils, Graphics, HPDFDoc;

procedure WarmThumbnails(const FileName: string);
var
  Pdf: THotPDF;
  Bmp: TBitmap;
  I: Integer;
begin
  Pdf := THotPDF.Create(nil);
  try
    // Configura il tier disco prima del primo render in cache:
    // cartella e entrambi i limiti vengono letti al primo uso del tier
    Pdf.RenderCacheFolder := IncludeTrailingPathDelimiter(
      GetEnvironmentVariable('LOCALAPPDATA')) + 'MyViewer\PageCache';
    Pdf.RenderCacheMaxDocuments := 50;
    Pdf.RenderCacheMaxBytes := Int64(1024) * 1024 * 1024; // 1 GiB
    Pdf.RenderCacheCapacity := 16;                        // pagine in memoria

    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
          // Passa la copia alla striscia di miniature qui
        finally
          Bmp.Free; // la chiamata in cache restituisce sempre una copia del chiamante
        end;
      end;
  finally
    Pdf.Free; // dalla v2.770.140 questo non cancella più le voci su disco
  end;
end;

Esegui la stessa procedura due volte e la seconda non rasterizza mai una pagina che stava in cache. L'oggetto cache disco viene creato pigramente al primo render in cache e vive finché l'istanza THotPDF non viene liberata, così cambiare RenderCacheFolder, RenderCacheMaxDocuments o RenderCacheMaxBytes dopo quel punto non sposta né ridimensiona una cache già aperta. Le pagine troppo grandi per la politica di ammissione in memoria (per default una singola voce non può superare 64 MiB di pixel a 32 bit) non vengono nemmeno persistite, e il tier disco viene consultato solo finché RenderFallbackPolicy mantiene il proprio default rfpIgnore, perché le diagnostiche di fallback non vengono conservate accanto al PNG

Perché RenderCacheFolder non ha mai funzionato prima della v2.770.140?

RenderCacheFolder non aveva effetto prima della v2.770.140 perché il tier disco indicizzava i documenti su un hash dei byte della sorgente che i caricamenti ordinari non conservavano mai. La chiave documento arrivava da una SHA-256 su una copia interna dei byte PDF grezzi, ma LoadFromFile e LoadFromStream analizzano la sorgente sul posto e non trattengono una tale copia; il campo veniva riempito solo temporaneamente su un percorso di recupero cifrato e azzerato subito dopo. Senza byte, la chiave era sempre vuota, e una chiave vuota significa che il tier disco viene bypassato. Nessun errore, nessun avviso, solo una cartella che restava vuota

Rendere la chiave non vuota ha esposto un secondo bug che si nascondeva dietro il primo. La vecchia InvalidateRenderedPageCache cancellava la cartella disco del documento, e InvalidateRenderedPageCache gira all'inizio di ogni caricamento, a ogni modifica e dentro Free. Quindi nel momento in cui la chiave funzionava, ogni sessione del viewer avrebbe distrutto la propria cache all'uscita, e la sessione successiva sarebbe comunque partita fredda. Peggio, la chiave veniva ricalcolata dalla stessa sorgente dopo una modifica, così i render del documento modificato sarebbero finiti sotto la chiave del file originale e serviti alla sessione successiva che apriva il PDF non modificato. La v2.770.140 corregge identità e invalidazione insieme; correggerne una sola avrebbe spedito o una cache morta o una bugiarda

Come HotPDF identifica un PDF senza leggere tutto il file

HotPDF identifica un PDF caricato da un file locale con un'impronta della sua dimensione, del suo orario di ultima scrittura e dei suoi primi e ultimi 64 KiB, e identifica una sorgente stream o ad accesso casuale con una SHA-256 dell'intero contenuto. Entrambe vengono catturate una volta sola, quando un caricamento riesce, e i primi 16 caratteri esadecimali del digest SHA-256 (64 bit) diventano la chiave documento

SorgenteIdentitàCostoCatturata quando
LoadFromFileDimensione + LastWriteTime + primi e ultimi 64 KiB, sottoposti a hash con SHA-256Al massimo 128 KiB letti, indipendente dalla dimensione del fileOgni caricamento riuscito, anche se RenderCacheFolder viene impostato dopo
LoadFromStreamSHA-256 dell'intero streamUn passaggio completo sulla sorgenteSolo se RenderCacheFolder era stato impostato prima del caricamento
LoadFromRandomAccessSourceSHA-256 dell'intera sorgenteUn passaggio completo sulla sorgenteSolo se la cartella era stata impostata prima e l'intero intervallo è disponibile
Qualsiasi sorgente con una voce /EncryptNessunaNessunoMai; il tier disco viene bypassato
Mappa dell'identità delle sorgenti per la cache di render su disco di HotPDF: LoadFromFile sottopone a hash dimensione, LastWriteTime e primi e ultimi 64 KiB, LoadFromStream e LoadFromRandomAccessSource sottopongono a hash l'intero contenuto solo quando RenderCacheFolder era impostato prima, e qualsiasi trailer /Encrypt non cattura alcuna identità
i file prendono l'impronta dalle proprie estremità perché header, xref e trailer vivono lì, gli stream pagano un hash completo solo quando hai chiesto prima la cache, e i documenti cifrati non vengono mai scritti su disco

L'impronta del file è un compromesso deliberato. Sottoporre a hash intero un archivio scansionato da 400 MB a ogni apertura può costare più del renderizzare le due pagine che un utente guarda davvero. Le regioni campionate non sono arbitrarie: l'header sta all'inizio del file, e il trailer e l'ultima sezione di riferimenti incrociati stanno alla fine (ISO 32000-1 §7.5). Un aggiornamento incrementale accoda un nuovo corpo, una nuova sezione di riferimenti incrociati e un nuovo trailer (§7.5.6), quindi cambia in una volta dimensione e coda. Una riscrittura completa da parte di qualsiasi tool normale cambia l'orario di ultima scrittura. Per file fino a 128 KiB i due campionamenti coprono ogni byte, quindi i documenti piccoli vengono di fatto sottoposti a hash interi

Il rischio residuo è una modifica sul posto, alla stessa dimensione, al centro di un file grande il cui scrittore ripristina poi l'orario originale. Serve un tool che preservi deliberatamente i tempi di modifica mentre modifica il contenuto, cosa rara ma non impossibile, e in quel caso la cache serve pagine stantie. Il rovescio della medaglia è benigno: copiare un file su Windows normalmente ne conserva l'orario di ultima scrittura, così una copia di un documento già in cache colpisce le stesse voci, il che è corretto perché i byte sono identici

Gli stream non hanno alcun tempo di modifica, quindi l'unica identità onesta è il contenuto. HotPDF paga quel passaggio SHA-256 completo solo quando hai chiesto una cache su disco prima del caricamento; ogni altro chiamante di LoadFromStream non vede alcun costo extra. Questo rende l'ordine di assegnazione della proprietà portante:

procedure OpenDownloadedPdf(Pdf: THotPDF; Data: TStream;
  const CacheRoot: string);
begin
  // Ordine sbagliato per gli stream: l'hash del contenuto viene calcolato solo
  // quando la cartella è già impostata, così questo documento bypasserebbe il tier disco
  //   Pdf.LoadFromStream(Data);
  //   Pdf.RenderCacheFolder := CacheRoot;

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

Una sorgente ad accesso casuale ancora in scaricamento (qualche intervallo non ancora disponibile) non riceve identità invece di un hash di contenuto parziale, e se il calcolo dell'identità fallisce per qualsiasi motivo il caricamento riesce comunque; il documento semplicemente renderizza senza il tier disco

Che cosa invalida una voce della cache disco di HotPDF?

Una voce della cache disco di HotPDF non viene mai invalidata cancellandola a una modifica; invece, modificare il documento caricato fa cadere l'identità del documento, così il tier disco viene bypassato per il resto di quel caricamento e le pagine conservate restano valide per la sorgente non modificata. Le voci lasciano il disco solo attraverso i limiti LRU e di byte, un PNG corrotto, o un cambio di schema

La chiave descrive una sorgente su disco, non il grafo di oggetti in memoria. Appena timbri una pagina o cambi un'annotazione, il documento non corrisponde più a quella sorgente, quindi né leggere né scrivere sotto la sua chiave sarebbe corretto. Dalla v2.770.140, sia l'invalidazione a livello documento sia quella a livello pagina azzerano l'identità invece di toccare la cartella, e c'è una seconda guardia per le modifiche che non hanno chiamato InvalidateRenderedPageCache: prima di usare il tier disco, THotPDF controlla se qualche oggetto caricato è dirty e tratta un documento dirty come privo di identità

Le impostazioni di render lavorano al contrario. Cambiare PageRenderBackend (o chiamare UseNativeGDIRenderBackend), e chiamare ConfigureRenderICCWorkflow o ClearRenderICCWorkflow, svuota le pagine in memoria ma conserva l'identità, perché il documento continua a corrispondere alla propria sorgente. Quelle impostazioni cambiano i pixel senza far parte della variante in memoria, quindi la chiave disco incorpora il nome del backend, il flag di compensazione del punto nero e i digest SHA-256 dei profili ICC di prova e di uscita. La variante stessa copre già l'intento di colore, il dithering di uscita, l'anteprima overprint, la modalità luminosity mask, la politica di fallback e la visibilità di ogni gruppo di contenuto opzionale, così attivare o disattivare un livello renderizza in una cartella diversa invece di sovrascrivere la vista di default

Semantica di invalidazione di HotPDF per la cache disco RenderCacheFolder: modificare il documento caricato o qualsiasi oggetto dirty fa cadere l'identità della sorgente così il tier viene bypassato, cambiare il backend di render o il workflow ICC conserva l'identità sotto una nuova chiave di variante, e salvare più ricaricare ridà una nuova chiave al documento
una modifica non cancella mai la cartella conservata, un cambio di impostazioni renderizza sotto una chiave diversa, e solo salvare più ricaricare fa guadagnare al documento modificato una identità fresca

Per rimettere un documento modificato sul tier disco, dagli una nuova identità di sorgente salvandolo e caricando il risultato:

procedure CommitEditsAndRekey(Pdf: THotPDF; const EditedFile: string);
begin
  // Dopo aver modificato il documento caricato: rinfresca le pagine in memoria.
  // L'identità della sorgente è già andata, quindi nulla viene letto dal o
  // scritto nella cartella disco del documento originale
  Pdf.InvalidateRenderedPageCache;

  // Un file salvato ha una nuova dimensione e un nuovo last-write time, quindi
  // una nuova identità; i render dopo questo caricamento finiscono in cache con la nuova chiave
  Pdf.SaveLoadedDocument(EditedFile);
  if Pdf.LoadFromFile(EditedFile) <= 0 then
    raise Exception.Create('Could not reload the edited document');
end;

La cartella del documento originale viene lasciata in pace e invecchia attraverso RenderCacheMaxDocuments e RenderCacheMaxBytes come qualsiasi altra voce. Se l'utente riapre l'originale non modificato, le sue pagine sono ancora lì

Confini di sicurezza: sorgenti cifrate e cartelle collegate

La cache di render su disco di HotPDF si rifiuta su due generi di input di proposito: non scrive mai su disco le pagine di un PDF cifrato, e non segue mai una sottocartella documento che sia una junction o altro reparse point. Entrambe le regole scambiano successi di cache contro il non far trapelare dati o cancellare i file sbagliati

I PDF cifrati non vengono mai messi in cache su disco

Una pagina renderizzata è contenuto decifrato. Scriverla come PNG in chiaro in una cartella di cache lascerebbe sul disco una copia leggibile di un documento protetto da password, fuori dalla protezione che l'autore ha scelto (ISO 32000-1 §7.6). HotPDF quindi non cattura alcuna identità per qualsiasi sorgente il cui trailer porti una voce /Encrypt, inclusi i file aperti con una password o con una password utente vuota. Quei documenti continuano a usare il tier in memoria, che muore con il processo

Le sottocartelle junction vengono rifiutate dalla v2.770.173

La radice della cache è una tua scelta, e puntarla a una junction è permesso. Le sottocartelle documento sotto di essa sono un altro paio di maniche: la cache le crea, le legge, ne tocca i tempi e le cancella per conto proprio, durante il recupero all'avvio (che rimuove i file temporanei rimasti), la ricerca (che aggiorna i tempi), la conservazione, l'invalidazione e i tre limiti di espulsione. Se qualcuno con accesso in scrittura alla radice della cache sostituisce una cartella documento con una junction verso un'altra directory, ognuno di quei percorsi la seguirebbe, e l'espulsione cancellerebbe file da un posto che la cache non ha mai posseduto. Dalla v2.770.173 ognuno di quei punti d'ingresso controlla l'attributo reparse-point e salta una cartella documento collegata: una ricerca conta un mancato, una conservazione conta un fallimento di scrittura, e l'espulsione la lascia in pace

Percorsi Unicode e radici condivise

Due correzioni collegate contano se distribuisci sui profili utente. Prima della v2.770.135, RenderCacheFolder era un AnsiString, così una cartella fuori dalla code page di sistema (un nome utente cinese su un'installazione Windows inglese, per esempio) veniva convertita con perdita prima che la cache la vedesse; la proprietà ora è una string Unicode, e la sostituzione atomica usa la Windows API wide. Dalla v2.770.52, più istanze THotPDF in un processo che puntano alla stessa radice (dopo l'espansione del percorso, confrontate senza distinzione di maiuscole) condividono un unico indice e lock con reference counting. Prima, ogni istanza sovrascriveva index.txt con la propria copia e applicava i limiti alla propria vista parziale, così la cartella poteva crescere parecchie volte oltre il proprio budget

Quella condivisione si ferma al confine del processo. Due processi separati sulla stessa radice tengono ancora indici in memoria separati, quindi dai a ogni applicazione in esecuzione concorrente la propria radice di cache. I viewer che renderizzano su worker thread stanno bene dentro un processo: PrefetchLoadedPages e la coda coperta in render in background con una coda di richieste passano entrambi per la stessa via in cache e lo stesso lock

Riferimento rapido: checklist RenderCacheFolder

  • Imposta RenderCacheFolder, RenderCacheMaxDocuments e RenderCacheMaxBytes prima della prima chiamata a RenderLoadedPageToBitmapCached; per caricamenti da stream e ad accesso casuale, imposta la cartella prima del caricamento
  • Passa alla v2.770.140 o successiva se conti sul tier disco; le versioni precedenti accettano la proprietà ma non servono mai una pagina dal disco per caricamenti normali
  • Aspettati nessuna cache su disco per i PDF cifrati, per i documenti modificati dopo il caricamento, o mentre RenderFallbackPolicy non è rfpIgnore
  • Libera l'istanza THotPDF normalmente; dalla v2.770.140 né Free né InvalidateRenderedPageCache cancellano voci su disco
  • Cambiare PageRenderBackend o il workflow ICC mantiene il documento sul tier disco sotto una chiave diversa
  • Usa una radice di cache per applicazione in esecuzione; le istanze dentro un processo condividono l'indice dalla v2.770.52
  • Tieni la radice della cache in una posizione per utente; le sottocartelle documento che sono junction vengono saltate dalla v2.770.173

Una cache di pagine persistente ripaga di più in un viewer che riapre gli stessi documenti tutto il giorno, che è esattamente la forma dell'architettura di un viewer PDF personalizzato in Delphi descritta altrove su questo blog. RenderCacheFolder, la cache raster in memoria e il renderer di pagine viaggiano con il HotPDF Delphi PDF component per Delphi e C++Builder