Articol tehnic

HotPDF RenderCacheFolder: cache de pagini pe disc în Delphi

HotPDF RenderCacheFolder transformă cache-ul de pagini randate în memorie al componentei HotPDF Delphi într-un cache persistent de pagini pe disc: paginile randate sunt scrise ca fișiere PNG sub un folder ales de dvs., iar data viitoare când aceeași sursă PDF e deschisă, RenderLoadedPageToBitmapCached le citește înapoi în loc să rasterizeze din nou. Ordinea de căutare e memoria, apoi discul, apoi renderer-ul

Stratul de disc e în API din v2.416.0, dar până la v2.770.140 nu a servit niciodată de fapt o pagină pentru un apel obișnuit LoadFromFile sau LoadFromStream. Repararea a forțat o întrebare la care trebuie să răspundă orice cache persistent: cum știi că fișierul deschis azi e documentul randat ieri și ce se întâmplă cu paginile din cache când nu e? Mai jos sunt răspunsurile pe care li s-a oprit HotPDF, inclusiv locurile unde refuză în mod deliberat să pună în cache

Cum funcționează cache-ul de randare pe disc din HotPDF?

Cache-ul de randare pe disc din HotPDF e un al doilea strat în spatele cache-ului raster din memorie și participă doar când RenderCacheFolder e o cale non-gol. Un apel RenderLoadedPageToBitmapCached(PageIndex, DPI) scanează întâi intrările din memorie, cheiate după indice de pagină, DPI și o variantă de setări de randare. La un miss întreabă stratul de disc; un hit de disc decodează PNG-ul, îl promovează înapoi în memorie și întoarce o copie deținută de apelant. Doar când ambele straturi ratează pagina trece prin interpretorul de content-stream descris în randarea unei pagini PDF încărcate într-un TBitmap, iar bitmap-ul proaspăt e scris apoi și pe disc

Diagramă HotPDF a căutării în cache-ul de randare pentru RenderLoadedPageToBitmapCached: stratul din memorie cheiat după pagină, DPI și variantă de randare e verificat primul, apoi stratul de disc RenderCacheFolder de fișiere PNG cu înlocuire atomică, apoi interpretorul de content-stream, iar fiecare hit întoarce o copie deținută de apelant
HotPDF privește întâi în memorie, apoi pe disc, și abia apoi rasterizează; un hit de disc e promovat înapoi în memorie, iar fiecare cale vă pune în mână o copie pe care o dețineți și pe care trebuie s-o eliberați

Pe disc layout-ul e deliberat plictisitor. Fiecare document primește un subfolder numit dintr-o cheie de document de 16 caractere hex plus o variantă de randare de 16 caractere hex, fiecare pagină e stocată ca <page>@<dpi>.png, iar un index.txt la rădăcină ține documentele în ordinea ultimei utilizări în spatele unui tag de schemă. O nepotrivire de schemă golește folderul la prima folosire. Scrierile merg întâi într-un fișier temporar și sunt schimbate la loc cu o înlocuire atomică, astfel încât o blocare în mijlocul scrierii lasă ori pagina veche, ori nimic, niciodată jumătate de PNG. Un PNG care eșuează la decodare e șters și numărat ca miss

Trei limite mărginesc folderul:

  • RenderCacheMaxDocuments (implicit 20) plafonează numărul de subfoldere de document; folderul cel mai puțin recent folosit e evacuat primul
  • RenderCacheMaxBytes (implicit 524288000, adică 500 MB) plafonează mărimea totală a tuturor fișierelor PNG sub rădăcină
  • Fiecare folder de document păstrează cel mult 200 de imagini de pagină; plafonul acela per document e fixat de THotPDF și nu e o proprietate publicată

RenderCacheCapacity (implicit 8) e un buton separat: el setează câte pagini randate păstrează stratul din memorie și nu are nicio legătură cu amprenta de disc

uses
  SysUtils, Graphics, HPDFDoc;

procedure WarmThumbnails(const FileName: string);
var
  Pdf: THotPDF;
  Bmp: TBitmap;
  I: Integer;
begin
  Pdf := THotPDF.Create(nil);
  try
    // Configurați stratul de disc înaintea primei randări din cache:
    // folderul și ambele limite se citesc când stratul e folosit prima dată
    Pdf.RenderCacheFolder := IncludeTrailingPathDelimiter(
      GetEnvironmentVariable('LOCALAPPDATA')) + 'MyViewer\PageCache';
    Pdf.RenderCacheMaxDocuments := 50;
    Pdf.RenderCacheMaxBytes := Int64(1024) * 1024 * 1024; // 1 GiB
    Pdf.RenderCacheCapacity := 16;                        // pagini în memorie

    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
          // Predați copia benzii de miniaturi aici
        finally
          Bmp.Free; // apelul din cache întoarce mereu o copie deținută de apelant
        end;
      end;
  finally
    Pdf.Free; // din v2.770.140 asta nu mai șterge intrările de disc
  end;
end;

Rulați aceeași procedură de două ori, iar a doua rulare nu rasterizează niciodată o pagină care a încăput în cache. Obiectul de cache de disc e creat leneș la prima randare din cache și trăiește până când instanța THotPDF e eliberată, deci schimbarea lui RenderCacheFolder, RenderCacheMaxDocuments sau RenderCacheMaxBytes după acel moment nu mută și nu redimensionează un cache deja deschis. Paginile prea mari pentru politica de admitere în memorie (implicit o singură intrare nu poate depăși 64 MiB de pixeli pe 32 de biți) nu sunt păstrate nici ele, iar stratul de disc e consultat doar cât timp RenderFallbackPolicy își păstrează implicitul rfpIgnore, pentru că diagnosticile de fallback nu sunt stocate lângă PNG

De ce nu a funcționat niciodată RenderCacheFolder înainte de v2.770.140?

RenderCacheFolder nu avea niciun efect înainte de v2.770.140 pentru că stratul de disc cheia documentele după un hash de octeți sursă pe care încărcările obișnuite nu-l păstrau niciodată. Cheia de document venea dintr-un SHA-256 peste o copie internă a octeților PDF brute, dar LoadFromFile și LoadFromStream parsează sursa la loc și nu păstrează o asemenea copie; câmpul era umplut doar temporar pe o cale de recuperare criptată și curățat imediat după. Fără octeți, cheia era mereu goală, iar o cheie goală înseamnă că stratul de disc e ocolit. Nicio eroare, niciun avertisment, doar un folder care rămânea gol

A face cheia non-gol a expus un al doilea bug care se ascunsese în spatele primului. Vechiul InvalidateRenderedPageCache ștergea folderul de disc al documentului, iar InvalidateRenderedPageCache rulează la începutul fiecărei încărcări, la fiecare editare și în interiorul lui Free. Deci în momentul în care cheia mergea, fiecare sesiune de viewer și-ar fi distrus propriul cache la ieșire, iar sesiunea următoare ar fi pornit rece oricum. Mai rău, cheia era recalculată din aceeași sursă după o editare, deci randările documentului editat ar fi fost stocate sub cheia fișierului original și servite sesiunii următoare care deschidea PDF-ul nemodificat. v2.770.140 repară identitatea și invalidarea împreună; repararea doar uneia ar fi livrat ori un cache mort, ori unul care minte

Cum identifică HotPDF un PDF fără a citi tot fișierul

HotPDF identifică un PDF încărcat dintr-un fișier local printr-o amprentă a mărimii lui, a timpului ultimei scrieri și a primilor și ultimilor 64 KiB ai lui, iar un stream sau o sursă cu acces aleator printr-un SHA-256 al întregului conținut. Ambele sunt capturate o singură dată, când o încărcare reușește, iar primii 16 caractere hex ai digest-ului SHA-256 (64 de biți) devin cheia de document

SursăIdentitateCostCapturat când
LoadFromFileMărime + LastWriteTime + primii și ultimii 64 KiB, hashed cu SHA-256Cel mult 128 KiB citiți, independent de mărimea fișieruluiFiecare încărcare reușită, chiar dacă RenderCacheFolder e setat mai târziu
LoadFromStreamSHA-256 al tot stream-uluiO trecere completă peste sursăDoar dacă RenderCacheFolder era setat înaintea încărcării
LoadFromRandomAccessSourceSHA-256 al întregii surseO trecere completă peste sursăDoar dacă folderul era setat primul și tot intervalul e disponibil
Orice sursă cu o intrare /EncryptNicioNiciunNiciodată; stratul de disc e ocolit
Harta identității surselor din HotPDF pentru cache-ul de randare pe disc: LoadFromFile hash-uiește mărimea, LastWriteTime și primii și ultimii 64 KiB, LoadFromStream și LoadFromRandomAccessSource hash-uiesc tot conținutul doar când RenderCacheFolder era setat primul, iar orice trailer /Encrypt nu capturează nicio identitate
fișierele sunt amprtentate după capetele lor pentru că acolo trăiesc header-ul, xref-ul și trailer-ul, stream-urile plătesc un hash complet doar când ați cerut cache-ul întâi, iar documentele criptate nu sunt scrise niciodată pe disc

Amprenta de fișier e un compromis deliberat. Hash-uirea completă a unei arhive scanate de 400 MB la fiecare deschidere poate costa mai mult decât randarea celor două pagini pe care utilizatorul le privește de fapt. Regiunile eșantionate nu sunt arbitrare: header-ul stă la începutul fișierului, iar trailer-ul și ultima secțiune de cross-reference stă la capăt (ISO 32000-1 §7.5). Un update incremental adaugă un corp nou, o secțiune de cross-reference și un trailer (§7.5.6), deci schimbă mărimea și coada deodată. O rescriere completă de către orice unealtă obișnuită schimbă timpul ultimei scrieri. Pentru fișiere până la 128 KiB cele două eșantioane acoperă fiecare octet, deci documentele mici sunt efectiv hash-uite complet

Riscul rezidual e o schimbare la aceeași mărime, la loc, în mijlocul unui fișier mare, al cărei scriitor restaurează apoi timestamp-ul original. Asta cere o unealtă care păstrează în mod deliberat timpii de modificare în timp ce editează conținutul, ceea ce e rar, dar nu imposibil, iar în cazul acela cache-ul servește pagini învechite. Fața bună e benignă: copiatul unui fișier pe Windows păstrează de obicei timpul ultimei scrieri, deci o copie a unui document deja în cache lovește aceleași intrări, ceea ce e corect pentru că octeții sunt identici

Stream-urile nu au deloc timp de modificare, deci singura identitate onestă e conținutul. HotPDF plătește trecerea completă SHA-256 doar când ați cerut un cache de disc înainte de încărcare; orice alt apelant al lui LoadFromStream nu vede niciun cost în plus. Asta face ca ordinea de asignare a proprietății să fie importantă structural:

procedure OpenDownloadedPdf(Pdf: THotPDF; Data: TStream;
  const CacheRoot: string);
begin
  // Ordine greșită pentru stream-uri: hash-ul de conținut e calculat doar când
  // folderul e deja setat, deci acest document ar ocoli stratul de disc
  //   Pdf.LoadFromStream(Data);
  //   Pdf.RenderCacheFolder := CacheRoot;

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

O sursă cu acces aleator care se descarcă încă (unele intervale încă indisponibile) nu primește nicio identitate, în locul unui hash al conținutului parțial, iar dacă calculul identității eșuează din orice motiv încărcarea reușește în continuare; documentul pur și simplu randează fără stratul de disc

Ce invalidează o intrare din cache-ul de disc HotPDF?

O intrare din cache-ul de disc HotPDF nu e invalidată niciodată prin ștergerea ei la editare; în schimb, editarea documentului încărcat aruncă identitatea de document, astfel încât stratul de disc e ocolit pentru restul încărcării aceleia, iar paginile stocate rămân valide pentru sursa nemodificată. Intrările părăsesc discul doar prin limitele LRU și de octeți, printr-un PNG corupt sau printr-o schimbare de schemă

Cheia descrie o sursă pe disc, nu graful de obiecte din memorie. Întrucât ați ștampilat o pagină sau ați schimbat o adnotare, documentul nu se mai potrivește cu sursa aceea, deci nici cititul, nici scrisul sub cheia ei nu ar fi corect. Din v2.770.140, atât invalidarea la nivel de document, cât și cea la nivel de pagină, curăță identitatea în loc să atingă folderul, iar există o a doua gardă pentru editările care nu au apelat InvalidateRenderedPageCache: înainte să folosească stratul de disc, THotPDF verifică dacă vreun obiect încărcat e dirty și tratează un document dirty ca fiind fără identitate

Setările de randare lucrează invers. Schimbarea lui PageRenderBackend (sau apelul UseNativeGDIRenderBackend) și apelul lui ConfigureRenderICCWorkflow sau ClearRenderICCWorkflow golesc paginile din memorie, dar păstrează identitatea, pentru că documentul se potrivește încă cu sursa lui. Acele setări schimbă pixelii fără să fie parte din varianta din memorie, deci cheia de disc îmbină numele backend-ului, flag-ul de black-point compensation și digest-urile SHA-256 ale profilelor ICC de proof și de output. Varianta în sine acoperă deja intenția de culoare, dithering-ul de ieșire, preview-ul de overprint, modul de luminanță, politica de fallback și vizibilitatea fiecărui grup de conținut opțional, deci comutarea unui strat randează într-un folder diferit în loc să suprascrie vederea implicită

Semantica de invalidare din HotPDF pentru cache-ul de disc RenderCacheFolder: editarea documentului încărcat sau a oricărui obiect dirty aruncă identitatea sursei, astfel încât stratul e ocolit, schimbarea backend-ului de randare sau a workflow-ului ICC păstrează identitatea sub o cheie de variantă nouă, iar salvatul plus reîncărcatul re-cheiază documentul
o editare nu șterge niciodată folderul stocat, o schimbare de setări randează sub o altă cheie, iar doar salvatul plus reîncărcatul îi câștigă documentului editat o identitate proaspătă

Ca să readuceți un document editat pe stratul de disc, dați-i o identitate de sursă nouă salvându-l și încărcând rezultatul:

procedure CommitEditsAndRekey(Pdf: THotPDF; const EditedFile: string);
begin
  // După editarea documentului încărcat: reîmprospătați paginile din memorie.
  // Identitatea sursei e deja plecată, deci nimic nu e citit din folderul
  // de disc al documentului original și nimic nu e scris în el
  Pdf.InvalidateRenderedPageCache;

  // Un fișier salvat are o mărime nouă și un last-write time nou, deci o
  // identitate nouă; randările de după această încărcare intră în cache sub cheia nouă
  Pdf.SaveLoadedDocument(EditedFile);
  if Pdf.LoadFromFile(EditedFile) <= 0 then
    raise Exception.Create('Could not reload the edited document');
end;

Folderul documentului original e lăsat în pace și îmbătrânește prin RenderCacheMaxDocuments și RenderCacheMaxBytes ca orice altă intrare. Dacă utilizatorul redeschide originalul needitat, paginile lui sunt tot acolo

Frontiere de securitate: surse criptate și foldere legate

Cache-ul de randare pe disc din HotPDF refuza deliberat două feluri de input: nu scrie niciodată paginile unui PDF criptat pe disc și nu urmează niciodată un subfolder de document care e un junction sau alt reparse point. Ambele regulă schimbă hit-uri de cache pe faptul de a nu scurge date sau de a nu șterge fișierele greșite

PDF-urile criptate nu sunt puse niciodată în cache pe disc

O pagină randată e conținut decriptat. Scrierea ei ca PNG simplu într-un folder de cache ar lăsa o copie lizibilă a unui document protejat prin parolă pe disc, în afara protecției alese de autor (ISO 32000-1 §7.6). HotPDF capturează deci nicio identitate pentru orice sursă al cărei trailer cară o intrare /Encrypt, inclusiv fișierele deschise cu o parolă sau cu o parolă de utilizator goală. Documentele acelea folosesc în continuare stratul din memorie, care moare odată cu procesul

Subfolderele junction sunt respinse din v2.770.173

Rădăcina cache-ului e alegerea dvs., iar a o arăta către un junction e permis. Subfolderele de document de sub ea sunt altă poveste: cache-ul le creează, citește, atinge și șterge pe cont propriu, în timpul recuperării la pornire (care elimină fișierele temporare rămase), la căutare (care actualizează timpii), la stocare, la invalidare și la cele trei limite de evacuare. Dacă cineva cu drept de scriere la rădăcina cache-ului înlocuiește un folder de document cu un junction către alt director, fiecare dintre căile acelea l-ar urma, iar evacuarea ar șterge fișiere undeva unde cache-ul nu a deținut niciodată nimic. Din v2.770.173 fiecare dintre aceste puncte de intrare verifică atributul de reparse point și sărite un folder de document legat: o căutare numără un miss, o stocare numără un eșec de scriere, iar evacuarea îl lasă în pace

Căi Unicode și rădăcini partajate

Două reparări înrudite contează dacă livrați către profiluri de utilizator. Înainte de v2.770.135, RenderCacheFolder era un AnsiString, deci un folder în afara code page-ului de sistem (un nume de utilizator chinezesc pe o instalare Windows engleză, de pildă) era convertit cu pierderi înainte să-l vadă cache-ul; proprietatea e acum un string Unicode, iar înlocuirea atomică folosește API-ul Windows wide. Din v2.770.52, mai multe instanțe THotPDF într-un singur proces care arată către aceeași rădăcină (după expansiunea căii, comparat case-insensitive) partajează un singur index și un singur lock cu reference counting. Înainte, fiecare instanță suprascria index.txt cu copia ei și impunea limitele contra vederii ei parțiale, deci folderul putea crește de mai multe ori peste bugetul lui

Partajarea aceasta se oprește la frontiera de proces. Două procese separate pe aceeași rădăcină țin încă indecși de memorie separați, deci dați fiecărei aplicații care rulează concurrent propriul rădăcin de cache. Viewer-ele care randează pe worker thread-uri stau bine în interiorul unui proces: PrefetchLoadedPages și coada descrisă în randarea în fundal cu o coadă de cereri trec ambele prin aceeași cale din cache și același lock

Referință rapidă: lista de verificare RenderCacheFolder

  • Setați RenderCacheFolder, RenderCacheMaxDocuments și RenderCacheMaxBytes înaintea primului apel RenderLoadedPageToBitmapCached; pentru încărcări din stream și cu acces aleator, setați folderul înainte de încărcare
  • Faceți upgrade la v2.770.140 sau mai nou dacă vă bazați pe stratul de disc; versiunile mai vechi acceptă proprietatea, dar nu servesc niciodată o pagină de pe disc pentru încărcări obișnuite
  • Nu vă așteptați la cache pe disc pentru PDF-uri criptate, pentru documente editate după încărcare sau cât timp RenderFallbackPolicy nu e rfpIgnore
  • Eliberați instanța THotPDF în mod normal; din v2.770.140 nici Free, nici InvalidateRenderedPageCache nu șterg intrările de disc
  • Schimbarea lui PageRenderBackend sau a workflow-ului ICC păstrează documentul pe stratul de disc sub o altă cheie
  • Folosiți o rădăcină de cache per aplicație care rulează; instanțele dintr-un singur proces partajează indexul din v2.770.52
  • Păstrați rădăcina cache-ului într-o locație per utilizator; subfolderele de document care sunt junction-uri sunt sărite din v2.770.173

Un cache persistent de pagini plătește cel mai mult într-un viewer care redeschide aceleași documente toată ziua, exact forma arhitecturii de viewer PDF personalizat în Delphi descrisă în altă parte pe blogul acesta. RenderCacheFolder, cache-ul raster din memorie și renderer-ul de pagini vin cu componenta HotPDF Delphi PDF pentru Delphi și C++Builder