HotPDF RenderCacheFolder macht aus dem In-Memory-Cache gerenderter Seiten der HotPDF Delphi Component einen persistenten Disk-Seitencache: Gerenderte Seiten werden als PNG-Dateien unter einem Ordner Ihrer Wahl geschrieben, und beim nächsten Öffnen derselben PDF-Quelle liest RenderLoadedPageToBitmapCached sie zurück, statt erneut zu rastern. Die Suchreihenfolge ist Speicher, dann Disk, dann der Renderer
Die Disk-Ebene ist seit v2.416.0 in der API, aber bis v2.770.140 hat sie für einen normalen LoadFromFile- oder LoadFromStream-Aufruf nie tatsächlich eine Seite geliefert. Der Fix zwang eine Frage, die jeder persistente Cache beantworten muss: Woher wissen Sie, dass die Datei, die Sie heute öffnen, das Dokument ist, das Sie gestern gerendert haben, und was passiert mit den gecachten Seiten, wenn nicht? Hier sind die Antworten, auf die sich HotPDF geeinigt hat, samt der Stellen, an denen es bewusst das Cachen verweigert
Wie funktioniert der HotPDF-Disk-Render-Cache?
Der HotPDF-Disk-Render-Cache ist eine zweite Ebene hinter dem Raster-Cache im Arbeitsspeicher, und er partizipiert nur, wenn RenderCacheFolder ein nicht leerer Pfad ist. Ein Aufruf von RenderLoadedPageToBitmapCached(PageIndex, DPI) scannt zuerst die In-Memory-Einträge, verschlüsselt nach Seitenindex, DPI und einer Render-Settings-Variante. Bei einem Fehlschlag fragt er die Disk-Ebene; ein Disk-Treffer dekodiert das PNG, befördert es zurück in den Speicher und liefert eine Kopie in Aufruferbesitz. Nur wenn beide Ebenen fehlgreifen, läuft die Seite durch den Content-Stream-Interpreter aus Eine geladene PDF-Seite in ein TBitmap rendern, und die frische Bitmap wird dann ebenfalls auf Disk geschrieben
Auf der Disk ist das Layout absichtlich langweilig. Jedes Dokument bekommt einen Unterordner, benannt aus einem 16-Hexzeichen-Dokumentschlüssel plus einer 16-Hexzeichen-Render-Variante, jede Seite wird als <page>@<dpi>.png gespeichert, und eine index.txt an der Wurzel hält Dokumente in Most-Recently-Used-Reihenfolge hinter einem Schema-Tag. Ein Schema-Mismatch leert den Ordner bei der ersten Benutzung. Schreibvorgänge gehen zuerst in eine temporäre Datei und werden per atomarem Ersetzen an ihren Platz getauscht, ein Absturz mitten im Schreiben hinterlässt also entweder die alte Seite oder nichts, niemals ein halbes PNG. Ein PNG, das sich nicht dekodieren lässt, wird gelöscht und als Fehlschlag gezählt
Drei Limits begrenzen den Ordner:
RenderCacheMaxDocuments(Default 20) begrenzt die Anzahl der Dokumentunterordner; der am längsten unbenutzte Ordner wird zuerst verdrängtRenderCacheMaxBytes(Default 524288000, also 500 MB) begrenzt die Gesamtgröße aller PNG-Dateien unter der Wurzel- Jeder Dokumentordner hält höchstens 200 Seitenbilder; diese pro-Dokument-Grenze ist von THotPDF fest verdrahtet und keine veröffentlichte Property
RenderCacheCapacity (Default 8) ist ein separater Regler: Er setzt, wie viele gerenderte Seiten die Speicher-Ebene hält, und hat mit dem Disk-Fußabdruck nichts zu tun
uses
SysUtils, Graphics, HPDFDoc;
procedure WarmThumbnails(const FileName: string);
var
Pdf: THotPDF;
Bmp: TBitmap;
I: Integer;
begin
Pdf := THotPDF.Create(nil);
try
// Die Disk-Ebene konfigurieren, bevor das erste gecachte Render läuft:
// Ordner und beide Limits werden gelesen, wenn die Stufe erstmals benutzt wird
Pdf.RenderCacheFolder := IncludeTrailingPathDelimiter(
GetEnvironmentVariable('LOCALAPPDATA')) + 'MyViewer\PageCache';
Pdf.RenderCacheMaxDocuments := 50;
Pdf.RenderCacheMaxBytes := Int64(1024) * 1024 * 1024; // 1 GiB
Pdf.RenderCacheCapacity := 16; // Seiten im Speicher
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
// Die Kopie hier an die Thumbnail-Leiste weitergeben
finally
Bmp.Free; // der gecachte Aufruf liefert stets eine Kopie in Aufruferbesitz
end;
end;
finally
Pdf.Free; // seit v2.770.140 löscht dies die Disk-Einträge nicht mehr
end;
end;
Führen Sie dieselbe Prozedur zweimal aus, rastert der zweite Durchlauf keine Seite mehr, die in den Cache passte. Das Disk-Cache-Objekt wird träge beim ersten gecachten Render erzeugt und lebt, bis die THotPDF-Instanz freigegeben wird, eine Änderung von RenderCacheFolder, RenderCacheMaxDocuments oder RenderCacheMaxBytes danach verschiebt oder vergrößert einen bereits geöffneten Cache also nicht. Seiten, die zu groß für die Aufnahmepolitik des Speichers sind (standardmäßig darf ein einzelner Eintrag 64 MiB an 32-Bit-Pixeln nicht übersteigen), werden ebenfalls nicht persistiert, und die Disk-Ebene wird nur konsultiert, solange RenderFallbackPolicy ihren Default rfpIgnore hält, denn Fallback-Diagnostik wird nicht neben dem PNG gespeichert
Warum hat RenderCacheFolder vor v2.770.140 nie funktioniert?
RenderCacheFolder hatte vor v2.770.140 keinen Effekt, weil die Disk-Ebene Dokumente über einen Hash der Quellbytes verschlüsselte, den gewöhnliche Ladevorgänge nie vorhielten. Der Dokumentschlüssel kam aus einem SHA-256 über eine interne Kopie der rohen PDF-Bytes, aber LoadFromFile und LoadFromStream parsen die Quelle an Ort und Stelle und behalten keine solche Kopie; das Feld wurde nur temporär auf einem Verschlüsselungs-Wiederherstellungspfad gefüllt und direkt danach wieder geleert. Ohne Bytes war der Schlüssel immer leer, und ein leerer Schlüssel bedeutet, dass die Disk-Ebene umgangen wird. Kein Fehler, keine Warnung, nur ein Ordner, der leer blieb
Den Schlüssel nicht leer zu machen, legte einen zweiten Bug frei, der hinter dem ersten versteckt gewesen war. Das alte InvalidateRenderedPageCache löschte den Disk-Ordner des Dokuments, und InvalidateRenderedPageCache läuft am Anfang jedes Ladens, bei jeder Änderung und innerhalb von Free. In dem Moment, in dem der Schlüssel funktioniert hätte, hätte also jede Viewer-Sitzung ihren eigenen Cache beim Verlassen zerstört, und die nächste Sitzung wäre ohnehin kalt gestartet. Schlimmer noch: Der Schlüssel wurde nach einer Änderung aus derselben Quelle neu berechnet, Rendern des editierten Dokuments wären also unter dem Schlüssel der Originaldatei gespeichert und der nächsten Sitzung serviert worden, die das unveränderte PDF öffnete. v2.770.140 fixt Identität und Invalidierung zusammen; nur einen von beiden zu fixen hätte entweder einen toten oder einen lügenden Cache geliefert
Wie HotPDF ein PDF identifiziert, ohne die ganze Datei zu lesen
HotPDF identifiziert ein aus einer lokalen Datei geladenes PDF über einen Fingerabdruck aus seiner Größe, seiner Last-Write-Zeit und seinem ersten und letzten 64 KiB, und eine Stream- oder Random-Access-Quelle über einen SHA-256 ihres gesamten Inhalts. Beide werden einmalig erfasst, wenn ein Laden gelingt, und die ersten 16 Hexzeichen des SHA-256-Digests (64 Bits) werden der Dokumentschlüssel
| Quelle | Identität | Kosten | Erfasst, wenn |
|---|---|---|---|
LoadFromFile | Größe + LastWriteTime + führende und abschließende 64 KiB, gehasht mit SHA-256 | Höchstens 128 KiB gelesen, unabhängig von der Dateigröße | Jeder erfolgreiche Ladevorgang, selbst wenn RenderCacheFolder erst später gesetzt wird |
LoadFromStream | SHA-256 über den ganzen Stream | Ein voller Durchlauf über die Quelle | Nur wenn RenderCacheFolder vor dem Laden gesetzt war |
LoadFromRandomAccessSource | SHA-256 über die ganze Quelle | Ein voller Durchlauf über die Quelle | Nur wenn der Ordner zuerst gesetzt war und die ganze Range verfügbar ist |
Jede Quelle mit einem /Encrypt-Eintrag | Keine | Keine | Nie; die Disk-Ebene wird umgangen |
Der Datei-Fingerabdruck ist ein bewusster Kompromiss. Eine 400-MB-Scanarchiv bei jedem Öffnen vollständig zu hashen kann mehr kosten, als die zwei Seiten zu rendern, die ein Benutzer tatsächlich ansieht. Die Stichprobenregionen sind nicht willkürlich: Der Header sitzt am Anfang der Datei, und Trailer und die letzte Cross-Reference-Sektion sitzen am Ende (ISO 32000-1 §7.5). Ein inkrementelles Update hängt einen neuen Body, eine neue Cross-Reference-Sektion und einen neuen Trailer an (§7.5.6), ändert also Größe und Ende auf einen Schlag. Ein vollständiges Umschreiben durch jedes normale Tool ändert die Last-Write-Zeit. Für Dateien bis 128 KiB decken die zwei Stichproben jedes Byte ab, kleine Dokumente werden also effektiv vollständig gehasht
Das Restrisiko ist eine gleich große, an Ort und Stelle vorgenommene Änderung in der Mitte einer großen Datei, deren Schreiber danach den ursprünglichen Zeitstempel wiederherstellt. Das braucht ein Tool, das absichtlich Änderungszeiten bewahrt, während es Inhalte editiert, was selten, aber nicht unmöglich ist, und in dem Fall serviert der Cache veraltete Seiten. Die Kehrseite ist harmlos: Eine Datei unter Windows zu kopieren bewahrt normalerweise ihre Last-Write-Zeit, eine Kopie eines bereits im Cache liegenden Dokuments trifft also dieselben Einträge, was korrekt ist, weil die Bytes identisch sind
Streams haben überhaupt keine Änderungszeit, die einzige ehrliche Identität ist also der Inhalt. HotPDF zahlt diesen vollen SHA-256-Durchlauf nur, wenn Sie vor dem Laden einen Disk-Cache angefragt haben; jeder andere Aufrufer von LoadFromStream sieht keine Extra-Kosten. Damit ist die Reihenfolge der Property-Zuweisungen tragend:
procedure OpenDownloadedPdf(Pdf: THotPDF; Data: TStream;
const CacheRoot: string);
begin
// Falsche Reihenfolge für Streams: Der Content-Hash wird nur berechnet, wenn
// der Ordner bereits gesetzt ist, dieses Dokument würde die Disk-Ebene also umgehen
// Pdf.LoadFromStream(Data);
// Pdf.RenderCacheFolder := CacheRoot;
Pdf.RenderCacheFolder := CacheRoot; // zuerst setzen
Data.Position := 0;
if Pdf.LoadFromStream(Data) <= 0 then
raise Exception.Create('The stream is not a loadable PDF');
end;
Eine Random-Access-Quelle, die noch lädt (manche Ranges noch nicht verfügbar), bekommt keine Identität statt eines Hashs partiellen Inhalts, und scheitert die Identitätsberechnung aus irgendeinem Grund, gelingt das Laden dennoch; das Dokument rendert einfach ohne die Disk-Ebene
Was macht einen HotPDF-Disk-Cache-Eintrag ungültig?
Ein HotPDF-Disk-Cache-Eintrag wird nie durch Löschen bei einer Änderung ungültig gemacht; stattdessen verwirft das Editieren des geladenen Dokuments die Dokumentidentität, die Disk-Ebene wird also für den Rest dieses Ladens umgangen, und die gespeicherten Seiten bleiben für die unveränderte Quelle gültig. Einträge verlassen die Disk nur über die LRU- und Byte-Limits, ein korruptes PNG oder eine Schema-Änderung
Der Schlüssel beschreibt eine Quelle auf der Disk, nicht den Objektgraphen im Speicher. Sobald Sie eine Seite stempeln oder eine Annotation ändern, passt das Dokument nicht mehr zu dieser Quelle, weder Lesen noch Schreiben unter seinem Schlüssel wäre also korrekt. Seit v2.770.140 löschen sowohl Dokument- als auch Seitenebene-Invalidierung die Identität, statt den Ordner anzufassen, und es gibt eine zweite Absicherung für Änderungen, die InvalidateRenderedPageCache nicht aufgerufen haben: Bevor die Disk-Ebene benutzt wird, prüft THotPDF, ob irgendein geladenes Objekt dirty ist, und behandelt ein dirty Dokument als identitätslos
Render-Einstellungen funktionieren umgekehrt. PageRenderBackend zu wechseln (oder UseNativeGDIRenderBackend aufzurufen) sowie ConfigureRenderICCWorkflow oder ClearRenderICCWorkflow aufzurufen, spült die Speicherseiten, behält aber die Identität, denn das Dokument passt weiterhin zu seiner Quelle. Diese Einstellungen ändern die Pixel, ohne Teil der In-Memory-Variante zu sein, der Disk-Schlüssel faltet also den Backend-Namen, das Black-Point-Compensation-Flag und SHA-256-Digests der ICC-Proof- und Output-Profile ein. Die Variante selbst deckt bereits Color Intent, Output-Dithering, Overprint-Preview, Luminosity-Mask-Modus, Fallback-Politik und die Sichtbarkeit jeder Optional-Content-Gruppe ab, ein Layer-Toggle rendert also in einen anderen Ordner, statt die Default-Ansicht zu überschreiben
Um ein editiertes Dokument zurück auf die Disk-Ebene zu bringen, geben Sie ihm eine neue Quellidentität, indem Sie es speichern und das Ergebnis laden:
procedure CommitEditsAndRekey(Pdf: THotPDF; const EditedFile: string);
begin
// Nach dem Editieren des geladenen Dokuments: die Speicherseiten auffrischen.
// Die Quellidentität ist bereits weg, aus dem Disk-Ordner des Originaldokuments
// wird also nichts gelesen oder hineingeschrieben
Pdf.InvalidateRenderedPageCache;
// Eine gespeicherte Datei hat neue Größe und Last-Write-Zeit, also eine
// neue Identität; Rendern nach diesem Laden werden unter dem neuen Schlüssel gecacht
Pdf.SaveLoadedDocument(EditedFile);
if Pdf.LoadFromFile(EditedFile) <= 0 then
raise Exception.Create('Could not reload the edited document');
end;
Der Ordner des Originaldokuments bleibt in Ruhe und altert über RenderCacheMaxDocuments und RenderCacheMaxBytes hinaus wie jeder andere Eintrag. Öffnet der Benutzer das unbearbeitete Original erneut, sind seine Seiten noch da
Sicherheitsgrenzen: verschlüsselte Quellen und verlinkte Ordner
Der HotPDF-Disk-Render-Cache verweigert zwei Arten von Eingabe mit Absicht: Er schreibt nie Seiten eines verschlüsselten PDF auf die Disk, und er folgt nie einem Dokumentunterordner, der eine Junction oder ein anderer Reparse Point ist. Beide Regeln tauschen Cache-Treffer dagegen ein, keine Daten zu leaken oder die falschen Dateien zu löschen
Verschlüsselte PDFs werden nie auf die Disk gecacht
Eine gerenderte Seite ist entschlüsselter Inhalt. Sie als nacktes PNG in einen Cache-Ordner zu schreiben würde eine lesbare Kopie eines passwortgeschützten Dokuments auf der Disk hinterlassen, außerhalb des Schutzes, den der Autor gewählt hat (ISO 32000-1 §7.6). HotPDF erfasst deshalb für jede Quelle, deren Trailer einen /Encrypt-Eintrag trägt, keine Identität, eingeschlossen Dateien, die mit einem Passwort oder mit leerem Benutzerpasswort geöffnet wurden. Diese Dokumente nutzen weiterhin die Speicher-Ebene, die mit dem Prozess stirbt
Junction-Unterordner werden seit v2.770.173 abgewiesen
Die Cache-Wurzel ist Ihre Wahl, und auf eine Junction zu zeigen ist erlaubt. Die Dokumentunterordner darunter sind eine andere Sache: Der Cache erzeugt, liest, berührt und löscht sie selbstständig, bei der Startwiederherstellung (die übrig gebliebene temporäre Dateien entfernt), beim Lookup (das Zeitstempel aktualisiert), beim Speichern, bei der Invalidierung und bei den drei Verdrängungsgrenzen. Ersetzt jemand mit Schreibzugriff auf die Cache-Wurzel einen Dokumentordner durch eine Junction in ein anderes Verzeichnis, würde jeder dieser Pfade ihm folgen, und die Verdrängung würde Dateien löschen, die der Cache nie besessen hat. Seit v2.770.173 prüft jeder dieser Einstiegspunkte das Reparse-Point-Attribut und überspringt einen verlinkten Dokumentordner: Ein Lookup zählt einen Fehlschlag, ein Speichern zählt einen Schreibfehler, und die Verdrängung lässt ihn in Ruhe
Unicode-Pfade und geteilte Wurzeln
Zwei verwandte Fixes zählen, wenn Sie in Benutzerprofile ausliefern. Vor v2.770.135 war RenderCacheFolder ein AnsiString, ein Ordner außerhalb der System-Codepage (ein chinesischer Benutzername auf einer englischen Windows-Installation zum Beispiel) wurde also verlustbehaftet konvertiert, bevor der Cache ihn sah; die Property ist jetzt ein Unicode-string, und das atomare Ersetzen nutzt die Wide-Windows-API. Seit v2.770.52 teilen sich mehrere THotPDF-Instanzen in einem Prozess, die auf dieselbe Wurzel zeigen (nach Pfadexpansion, case-insensitiv verglichen), einen einzigen referenzgezählten Index und Lock. Vorher überschrieb jede Instanz die index.txt mit ihrer eigenen Kopie und erzwang die Limits gegen ihre Teilansicht, der Ordner konnte also sein Budget mehrfach übersteigen
Dieses Teilen endet an der Prozessgrenze. Zwei getrennte Prozesse auf derselben Wurzel halten weiterhin getrennte In-Memory-Indizes, geben Sie jeder gleichzeitig laufenden Anwendung also ihre eigene Cache-Wurzel. Viewer, die auf Worker-Threads rendern, sind innerhalb eines Prozesses fein: PrefetchLoadedPages und die Queue aus Hintergrund-Rendering mit einer Request-Queue laufen beide durch denselben gecachten Pfad und denselben Lock
Kurzreferenz: RenderCacheFolder-Checkliste
RenderCacheFolder,RenderCacheMaxDocumentsundRenderCacheMaxBytesvor dem ersten Aufruf vonRenderLoadedPageToBitmapCachedsetzen; bei Stream- und Random-Access-Laden den Ordner vor dem Laden setzen- Auf v2.770.140 oder später aktualisieren, wenn Sie sich auf die Disk-Ebene verlassen; frühere Versionen akzeptieren die Property, liefern aber nie eine Seite von der Disk für normale Ladevorgänge
- Rechnen Sie mit keinem Disk-Caching für verschlüsselte PDFs, für nach dem Laden editierte Dokumente oder solange
RenderFallbackPolicynichtrfpIgnoreist - Geben Sie die THotPDF-Instanz normal frei; seit v2.770.140 löscht weder
FreenochInvalidateRenderedPageCacheDisk-Einträge - Das Wechseln von
PageRenderBackendoder des ICC-Workflows hält das Dokument unter einem anderen Schlüssel auf der Disk-Ebene - Eine Cache-Wurzel pro laufender Anwendung benutzen; Instanzen innerhalb eines Prozesses teilen den Index seit v2.770.52
- Die Cache-Wurzel an einem benutzerlokalen Ort halten; Dokumentunterordner, die Junctions sind, werden seit v2.770.173 übersprungen
Ein persistenter Seitencache zahlt sich am meisten in einem Viewer aus, der den ganzen Tag dieselben Dokumente wiederöffnet — genau die Form der Custom-PDF-Viewer-Architektur in Delphi, die anderswo in diesem Blog beschrieben wird. RenderCacheFolder, der In-Memory-Raster-Cache und der Seitenrenderer kommen mit der HotPDF Delphi PDF component für Delphi und C++Builder