Artykuł techniczny

HotPDF RenderCacheFolder: cache stron PDF na dysku w Delphi

HotPDF RenderCacheFolder zamienia pamięciowy cache wyrenderowanych stron komponentu HotPDF Delphi w trwały cache stron na dysku: wyrenderowane strony są zapisywane jako pliki PNG w folderze, który wybierzesz, a przy następnym otwarciu tego samego źródła PDF RenderLoadedPageToBitmapCached czyta je z powrotem zamiast rasteryzować od nowa. Kolejność szukania to pamięć, potem dysk, potem renderer

Warstwa dyskowa siedzi w API od v2.416.0, ale do v2.770.140 nigdy faktycznie nie wydała strony dla zwykłego wywołania LoadFromFile albo LoadFromStream. Poprawka wymusiła pytanie, na które musi odpowiedzieć każdy trwały cache: skąd wiesz, że plik otwarty dzisiaj to dokument, który renderowałeś wczoraj, i co się dzieje z cache'owanymi stronami, gdy nie jest? Poniżej odpowiedzi, na które osiadł HotPDF, łącznie z miejscami, w których cache'owania odmawia celowo

Jak działa dyskowy cache renderowania w HotPDF?

Dyskowy cache renderowania HotPDF to druga warstwa za pamięciowym cache rastrowym i bierze udział tylko wtedy, gdy RenderCacheFolder jest niepustą ścieżką. Wywołanie RenderLoadedPageToBitmapCached(PageIndex, DPI) najpierw skanuje wpisy w pamięci, kluczowane indeksem strony, DPI i wariantem ustawień renderowania. Przy chybieniu pyta warstwę dyskową; trafienie na dysku dekoduje PNG, promuje go z powrotem do pamięci i zwraca kopię należącą do wołającego. Dopiero gdy obie warstwy chybią, strona przechodzi przez interpreter strumienia treści opisany w renderowaniu wczytanej strony PDF do TBitmap, a świeża bitmapa jest potem zapisywana też na dysk

Diagram HotPDF wyszukiwania w cache renderowania dla RenderLoadedPageToBitmapCached: najpierw sprawdzana jest warstwa w pamięci kluczowana stroną, DPI i wariantem renderowania, potem dyskowa warstwa RenderCacheFolder z plikami PNG i atomową podmianą, na końcu interpreter strumienia treści, a każde trafienie zwraca kopię należącą do wołającego
HotPDF zagląda najpierw do pamięci, potem na dysk i dopiero wtedy rasteryzuje; trafienie na dysku wraca do pamięci, a każda ścieżka wręcza ci kopię, która jest twoja i którą musisz zwolnić

Na dysku układ jest celowo nudny. Każdy dokument dostaje podfolder nazwany 16-znakowym kluczem dokumentu w hex plus 16-znakowym wariantem renderowania w hex, każda strona jest trzymana jako <page>@<dpi>.png, a index.txt w korzeniu trzyma dokumenty w kolejności od ostatnio używanych za znacznikiem schematu. Niedopasowanie schematu czyści folder przy pierwszym użyciu. Zapisy lecą najpierw do pliku tymczasowego i są podmieniane na miejsce atomową zamianą, więc awaria w trakcie zapisu zostawia starą stronę albo nic — nigdy pół PNG. PNG, którego nie da się zdekodować, jest kasowany i liczony jako chybienie

Folder ograniczają trzy limity:

  • RenderCacheMaxDocuments (domyślnie 20) ogranicza liczbę podfolderów dokumentów; najdawniej używany folder jest wysiedlany pierwszy
  • RenderCacheMaxBytes (domyślnie 524288000, czyli 500 MB) ogranicza łączny rozmiar wszystkich plików PNG pod korzeniem
  • Każdy folder dokumentu trzyma co najwyżej 200 obrazów stron; ten limit per dokument jest zaszyty w THotPDF i nie jest opublikowaną właściwością

RenderCacheCapacity (domyślnie 8) to osobne pokrętło: ustawia, ile wyrenderowanych stron trzyma warstwa w pamięci, i nie ma nic wspólnego z zajętością dysku

uses
  SysUtils, Graphics, HPDFDoc;

procedure WarmThumbnails(const FileName: string);
var
  Pdf: THotPDF;
  Bmp: TBitmap;
  I: Integer;
begin
  Pdf := THotPDF.Create(nil);
  try
    // Skonfiguruj warstwę dyskową przed pierwszym renderem z cache:
    // folder i oba limity są czytane przy pierwszym użyciu warstwy
    Pdf.RenderCacheFolder := IncludeTrailingPathDelimiter(
      GetEnvironmentVariable('LOCALAPPDATA')) + 'MyViewer\PageCache';
    Pdf.RenderCacheMaxDocuments := 50;
    Pdf.RenderCacheMaxBytes := Int64(1024) * 1024 * 1024; // 1 GiB
    Pdf.RenderCacheCapacity := 16;                        // strony w pamięci

    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
          // Przekaż kopię do paska miniatur tutaj
        finally
          Bmp.Free; // wywołanie z cache zawsze zwraca kopię należącą do wołającego
        end;
      end;
  finally
    Pdf.Free; // od v2.770.140 to już nie kasuje wpisów dyskowych
  end;
end;

Uruchom tę samą procedurę dwa razy, a drugi przebieg nie rasteryzuje już żadnej strony, która zmieściła się w cache. Obiekt dyskowego cache powstaje leniwie przy pierwszym renderze z cache i żyje do zwolnienia instancji THotPDF, więc zmiana RenderCacheFolder, RenderCacheMaxDocuments albo RenderCacheMaxBytes po tym momencie nie przenosi ani nie przemieszcza już otwartego cache. Stron zbyt dużych dla polityki przyjęć do pamięci (domyślnie pojedynczy wpis nie może przekroczyć 64 MiB pikseli 32-bitowych) nie utrwala się również, a warstwa dyskowa jest odpytywana tylko dopóki RenderFallbackPolicy trzyma domyślne rfpIgnore, bo diagnostyka fallbacku nie jest przechowywana obok PNG

Dlaczego RenderCacheFolder nigdy nie działał przed v2.770.140?

RenderCacheFolder nie dawało efektu przed v2.770.140, bo warstwa dyskowa kluczowała dokumenty hashem bajtów źródła, którego zwykłe wczytania nigdy nie trzymały. Klucz dokumentu pochodził z SHA-256 nad wewnętrzną kopią surowych bajtów PDF, ale LoadFromFile i LoadFromStream parsują źródło w miejscu i takiej kopii nie zatrzymują; pole było wypełniane tylko czasowo na ścieżce odzyskiwania szyfrowanych plików i zaraz potem czyszczone. Bez bajtów klucz był zawsze pusty, a pusty klucz znaczy, że warstwa dyskowa jest omijana. Żadnego błędu, żadnego ostrzeżenia — po prostu folder, który zostawał pusty

Niepusty klucz odsłonił drugiego błęda, który do tej pory krył się za pierwszym. Stary InvalidateRenderedPageCache kasował dyskowy folder dokumentu, a InvalidateRenderedPageCache wykonuje się na początku każdego wczytania, przy każdej edycji i wewnątrz Free. W chwili, gdy klucz by zadziałał, każda sesja przeglądarki niszczyłaby własny cache przy wyjściu, a następna sesja i tak startowałaby na zimno. Gorzej: klucz był przeliczany z tego samego źródła po edycji, więc rendery edytowanego dokumentu lądowałyby pod kluczem oryginalnego pliku i byłyby serwowane następnej sesji, która otworzyła niezmodyfikowany PDF. v2.770.140 naprawia tożsamość i unieważnianie razem; naprawa tylko jednej z tych rzeczy wydałaby martwy cache albo kłamiący

Jak HotPDF identyfikuje PDF bez czytania całego pliku

HotPDF identyfikuje PDF wczytany z pliku lokalnego odciskiem palca złożonym z rozmiaru, czasu ostatniego zapisu oraz pierwszych i ostatnich 64 KiB, a źródło strumieniowe albo o dostępie losowym — SHA-256 całej treści. Oba są przechwytywane raz, gdy wczytanie się uda, a pierwsze 16 znaków hex skrótu SHA-256 (64 bity) staje się kluczem dokumentu

ŹródłoTożsamośćKosztKiedy przechwycona
LoadFromFileRozmiar + LastWriteTime + pierwsze i ostatnie 64 KiB, hashowane SHA-256Odczyt co najwyżej 128 KiB, niezależny od rozmiaru plikuKażde udane wczytanie, nawet gdy RenderCacheFolder ustawi się później
LoadFromStreamSHA-256 całego strumieniaJeden pełny przebieg po źródleTylko gdy RenderCacheFolder był ustawiony przed wczytaniem
LoadFromRandomAccessSourceSHA-256 całego źródłaJeden pełny przebieg po źródleTylko gdy folder był ustawiony najpierw i cały zakres jest dostępny
Każde źródło z wpisem /EncryptBrakBrakNigdy; warstwa dyskowa jest omijana
Mapa tożsamości źródeł dla dyskowego cache renderowania w HotPDF: LoadFromFile hashuje rozmiar, LastWriteTime i pierwsze oraz ostatnie 64 KiB, LoadFromStream i LoadFromRandomAccessSource hashują całą treść tylko, gdy RenderCacheFolder był ustawiony najpierw, a każdy trailer z /Encrypt nie przechwytuje żadnej tożsamości
pliki dostają odcisk palca ze swoich końców, bo tam mieszkają nagłówek, xref i trailer, strumienie płacą za pełny hash tylko, gdy najpierw poprosisz o cache, a dokumenty zaszyfrowane nigdy nie lądują na dysku

Odcisk palca pliku to świadomy kompromis. Hashowanie 400-megabajtowego zeskanowanego archiwum w całości przy każdym otwarciu może kosztować więcej niż wyrenderowanie dwóch stron, na które użytkownik faktycznie zagląda. Próbkowane regiony nie są przypadkowe: nagłówek siedzi na początku pliku, a trailer i ostatnia sekcja krzyżowych referencji na końcu (ISO 32000-1 §7.5). Aktualizacja przyrostowa dokleja nowe ciało, sekcję krzyżowych referencji i trailer (§7.5.6), więc zmienia od razu rozmiar i ogon. Pełny zapis dowolnym normalnym narzędziem zmienia czas ostatniego zapisu. Dla plików do 128 KiB obie próbki pokrywają każdy bajt, więc małe dokumenty są faktycznie hashowane w całości

Ryzyko resztkowe to zmiana o tym samym rozmiarze, w miejscu, w środku dużego pliku, po której zapisujący przywraca oryginalny znacznik czasu. Wymaga to narzędzia, które celowo zachowuje czasy modyfikacji przy edycji treści — rzadkość, ale nie niemożliwość, a w takim wypadku cache serwuje stare strony. Druga strona medalu jest łagodna: skopiowanie pliku na Windows normalnie zachowuje czas ostatniego zapisu, więc kopia dokumentu już obecnego w cache trafia w te same wpisy, co jest poprawne, bo bajty są identyczne

Strumienie nie mają żadnego czasu modyfikacji, więc jedyną uczciwą tożsamością jest treść. HotPDF płaci za ten pełny przebieg SHA-256 tylko wtedy, gdy poprosiłeś o dyskowy cache przed wczytaniem; każdy inny wołający LoadFromStream nie widzi żadnego dodatkowego kosztu. To czyni kolejność przypisań właściwości nośną:

procedure OpenDownloadedPdf(Pdf: THotPDF; Data: TStream;
  const CacheRoot: string);
begin
  // Zła kolejność dla strumieni: hash treści jest liczony tylko, gdy
  // folder jest już ustawiony, więc ten dokument ominąłby warstwę dyskową
  //   Pdf.LoadFromStream(Data);
  //   Pdf.RenderCacheFolder := CacheRoot;

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

Źródło o dostępie losowym, które wciąż się pobiera (część zakresów jeszcze niedostępna), nie dostaje tożsamości zamiast hasha treści częściowej, a jeśli policzenie tożsamości zawiedzie z jakiegokolwiek powodu, wczytanie i tak się udaje; dokument po prostu renderuje się bez warstwy dyskowej

Co unieważnia wpis dyskowego cache w HotPDF?

Wpis dyskowego cache HotPDF nie jest nigdy unieważniany przez skasowanie go przy edycji; zamiast tego edycja wczytanego dokumentu zrzuca tożsamość dokumentu, więc warstwa dyskowa jest omijana do końca tego wczytania, a przechowane strony pozostają ważne dla niezmodyfikowanego źródła. Wpisy schodzą z dysku tylko przez limity LRU i bajtów, uszkodzony PNG albo zmianę schematu

Klucz opisuje źródło na dysku, nie graf obiektów w pamięci. Skoro oznaczysz stronę pieczątką albo zmienisz adnotację, dokument przestaje pasować do tego źródła, więc ani czytanie, ani pisanie pod jego kluczem nie byłyby poprawne. Od v2.770.140 unieważnienie i na poziomie dokumentu, i na poziomie strony czyści tożsamość zamiast ruszać folder, a jest i druga straż dla edycji, które nie wołały InvalidateRenderedPageCache: przed użyciem warstwy dyskowej THotPDF sprawdza, czy jakiś wczytany obiekt jest brudny, i traktuje brudny dokument jako pozbawiony tożsamości

Ustawienia renderowania działają w drugą stronę. Przełączenie PageRenderBackend (albo wołanie UseNativeGDIRenderBackend) oraz wołanie ConfigureRenderICCWorkflow albo ClearRenderICCWorkflow wypłukuje strony w pamięci, ale trzyma tożsamość, bo dokument wciąż pasuje do swojego źródła. Te ustawienia zmieniają piksele, nie będąc częścią wariantu w pamięci, więc klucz dyskowy ma wplecioną nazwę backendu, flagę kompensacji czerni i skróty SHA-256 profili ICC proof oraz wyjściowych. Sam wariant pokrywa już intencję kolorów, dithering wyjścia, podgląd overprint, tryb maski luminancji, politykę fallbacku i widoczność każdej grupy opcjonalnej treści, więc przełączenie warstwy renderuje do innego folderu zamiast nadpisywać widok domyślny

Semantyka unieważniania dla dyskowego cache RenderCacheFolder w HotPDF: edycja wczytanego dokumentu albo jakiegokolwiek brudnego obiektu zrzuca tożsamość źródła, więc warstwa jest omijana, zmiana backendu renderowania albo workflow ICC trzyma tożsamość pod nowym kluczem wariantu, a zapis plus ponowne wczytanie daje dokumentowi nowy klucz
edycja nigdy nie kasuje przechowanego folderu, zmiana ustawień renderuje pod innym kluczem, a świeżą tożsamość edytowany dokument zdobywa dopiero zapisem i ponownym wczytaniem

Żeby wrócić z edytowanym dokumentem na warstwę dyskową, daj mu nową tożsamość źródła, zapisując go i wczytując wynik:

procedure CommitEditsAndRekey(Pdf: THotPDF; const EditedFile: string);
begin
  // Po edycji wczytanego dokumentu: odśwież strony w pamięci.
  // Tożsamość źródła już nie istnieje, więc nic nie jest czytane ani
  // zapisywane w dyskowym folderze oryginalnego dokumentu
  Pdf.InvalidateRenderedPageCache;

  // Zapisany plik ma nowy rozmiar i czas ostatniego zapisu, czyli nową
  // tożsamość; rendery po tym wczytaniu jadą do cache pod nowym kluczem
  Pdf.SaveLoadedDocument(EditedFile);
  if Pdf.LoadFromFile(EditedFile) <= 0 then
    raise Exception.Create('Could not reload the edited document');
end;

Folder oryginalnego dokumentu zostaje w spokoju i starzeje się przez RenderCacheMaxDocuments i RenderCacheMaxBytes jak każdy inny wpis. Gdy użytkownik otworzy ponownie nieedytowany oryginał, jego strony wciąż tam są

Granice bezpieczeństwa: źródła zaszyfrowane i foldery dowiązane

Dyskowy cache renderowania HotPDF odmawia dwóch rodzajów wejścia celowo: nigdy nie zapisuje stron zaszyfrowanego PDF na dysk i nigdy nie podąża za podfolderem dokumentu, który jest junctionem albo innym punktem reparse. Obie reguły wymieniają trafienia w cache za to, że dane nie wyciekną i nie zostaną skasowane złe pliki

Zaszyfrowane PDF-y nigdy nie lądują w cache na dysku

Wyrenderowana strona to odszyfrowana treść. Zapisanie jej jako gołego PNG do folderu cache zostawiłoby na dysku czytelną kopię dokumentu chronionego hasłem, poza ochroną, którą wybrał autor (ISO 32000-1 §7.6). HotPDF nie przechwytuje więc tożsamości dla żadnego źródła, którego trailer niesie wpis /Encrypt, łącznie z plikami otwartymi z hasłem albo z pustym hasłem użytkownika. Te dokumenty wciąż używają warstwy w pamięci, która umiera razem z procesem

Podfoldery-junctione są odrzucane od v2.770.173

Korzeń cache wybierasz sam i wskazanie na niego junctiona jest dozwolone. Podfoldery dokumentów pod nim to inna sprawa: cache tworzy je, czyta, dotyka i kasuje na własną rękę, przy odzyskiwaniu na starcie (które usuwa zostałe pliki tymczasowe), przy wyszukiwaniu (które aktualizuje znaczniki czasu), przy zapisie, unieważnianiu i trzech limitach wysiedlania. Gdyby ktoś z prawem zapisu do korzenia cache podmienił folder dokumentu na junctiona do innego katalogu, każda z tych ścieżek poszłaby za nim, a wysiedlanie kasowałoby pliki gdzieś, gdzie cache nigdy nic nie posiadał. Od v2.770.173 każdy z tych punktów wejścia sprawdza atrybut punktu reparse i pomija dowiązany folder dokumentu: wyszukiwanie liczy chybienie, zapis liczy porażkę zapisu, a wysiedlanie zostawia go w spokoju

Ścieżki Unicode i wspólne korzenie

Dwie powiązane poprawki mają znaczenie, gdy wdrażasz do profili użytkowników. Przed v2.770.135 RenderCacheFolder był AnsiString, więc folder poza systemową stroną kodową (chińska nazwa użytkownika na anglojęzycznym Windows, na przykład) był traconie konwertowany, zanim cache go zobaczył; właściwość jest teraz Unicode'owym string, a atomowa podmiana używa szerokiego API Windows. Od v2.770.52 kilka instancji THotPDF w jednym procesie wskazujących na ten sam korzeń (po rozwinięciu ścieżki, porównywanej bez rozróżniania wielkości liter) dzieli jeden indeks i jedną blokadę z licznikiem referencji. Wcześniej każda instancja nadpisywała index.txt swoją kopią i egzekwowała limity względem swojego częściowego widoku, więc folder mógł urosnąć kilka razy ponad budżet

To współdzielenie kończy się na granicy procesu. Dwa osobne procesy na tym samym korzeniu wciąż trzymają osobne indeksy w pamięci, więc daj każdej równolegle działającej aplikacji własny korzeń cache. Przeglądarki renderujące na wątkach roboczych są w porządku w obrębie jednego procesu: PrefetchLoadedPages i kolejka opisana w renderowaniu w tle z kolejką żądań przechodzą oba przez tę samą ścieżkę cache i tę samą blokadę

Ściąga: lista kontrolna RenderCacheFolder

  • Ustaw RenderCacheFolder, RenderCacheMaxDocuments i RenderCacheMaxBytes przed pierwszym wywołaniem RenderLoadedPageToBitmapCached; przy wczytaniach strumieniowych i o dostępie losowym ustaw folder przed wczytaniem
  • Zaktualizuj do v2.770.140 lub nowszego, jeśli polegasz na warstwie dyskowej; wcześniejsze wersje przyjmują właściwość, ale nigdy nie wydają strony z dysku przy zwykłych wczytaniach
  • Nie oczekuj cache'owania na dysku dla zaszyfrowanych PDF-ów, dla dokumentów edytowanych po wczytaniu ani gdy RenderFallbackPolicy nie jest rfpIgnore
  • Zwalniaj instancję THotPDF normalnie; od v2.770.140 ani Free, ani InvalidateRenderedPageCache nie kasują wpisów dyskowych
  • Zmiana PageRenderBackend albo workflow ICC trzyma dokument na warstwie dyskowej pod innym kluczem
  • Używaj jednego korzenia cache na działającą aplikację; instancje wewnątrz jednego procesu dzielą indeks od v2.770.52
  • Trzymaj korzeń cache w lokalizacji per użytkownik; podfoldery dokumentów będące junctionami są pomijane od v2.770.173

Trwały cache stron opłaca się najbardziej w przeglądarce, która przez cały dzień otwiera te same dokumenty, a dokładnie taki jest architektura własnej przeglądarki PDF w Delphi opisana gdzie indziej na tym blogu. RenderCacheFolder, pamięciowy cache rastrowy i renderer stron jadą w pakiecie z komponentem HotPDF Delphi PDF dla Delphi i C++Buildera