HotPDF 的 RenderCacheFolder 把 HotPDF Delphi 元件的記憶體內渲染頁快取,變成常駐的磁碟頁面快取:渲染好的頁面寫成 PNG 檔,放在您指定的資料夾底下,下次開啟同一個 PDF 來源時,RenderLoadedPageToBitmapCached 直接讀回來,不再點陣化一次。查找順序是記憶體、磁碟、渲染器
磁碟層從 v2.416.0 就在 API 裡,但直到 v2.770.140,一般的 LoadFromFile 或 LoadFromStream 呼叫從來沒真正從它手上拿到過一頁。這個修正逼出一個每種常駐快取都躲不掉的問題:您怎麼知道今天開的檔案,就是昨天渲染過的那份文件?如果不是,快取的頁面怎麼辦?下面是 HotPDF 給出的答案,包括它刻意拒絕快取的地方
HotPDF 的磁碟渲染快取怎麼運作?
HotPDF 磁碟渲染快取是記憶體點陣快取後面的第二層,只有 RenderCacheFolder 是非空路徑時才會參與。呼叫 RenderLoadedPageToBitmapCached(PageIndex, DPI) 時,先掃記憶體條目,鍵是頁索引、DPI 與渲染設定變體。撲空就問磁碟層;磁碟命中則解碼 PNG、升回記憶體,回傳一份歸呼叫方所有的複本。兩層都撲空,頁面才走 把已載入 PDF 頁面渲染成 TBitmap 一文描述的內容串流直譯器,新的點陣圖隨後也寫進磁碟
磁碟上的版面刻意平淡無奇。每份文件一個子資料夾,名字由 16 個十六進位字元的文件鍵加 16 個十六進位字元的渲染變體組成;每頁存成 <page>@<dpi>.png;根目錄下一個 index.txt 在 schema 標籤之後按最近使用順序記錄文件。schema 對不上,第一次使用就清空資料夾。寫入先落暫存檔,再以原子替換換到位,寫到一半崩潰,結果是舊頁或什麼都沒有,絕不會是半張 PNG。解碼失敗的 PNG 會被刪除、計為撲空
三個上限約束著資料夾:
RenderCacheMaxDocuments(預設 20)限制文件子資料夾數量;最近最少使用的資料夾先被逐出RenderCacheMaxBytes(預設 524288000,即 500 MB)限制根目錄下所有 PNG 檔的總大小- 每個文件資料夾最多留 200 張頁面影像;這個單文件上限由 THotPDF 寫死,不是公開屬性
RenderCacheCapacity(預設 8)是另一顆旋鈕:它設定記憶體層保留多少張渲染頁,跟磁碟佔用毫無關係
uses
SysUtils, Graphics, HPDFDoc;
procedure WarmThumbnails(const FileName: string);
var
Pdf: THotPDF;
Bmp: TBitmap;
I: Integer;
begin
Pdf := THotPDF.Create(nil);
try
// 在第一次快取渲染之前設定磁碟層:
// 資料夾與兩個上限都在該層第一次使用時讀取
Pdf.RenderCacheFolder := IncludeTrailingPathDelimiter(
GetEnvironmentVariable('LOCALAPPDATA')) + 'MyViewer\PageCache';
Pdf.RenderCacheMaxDocuments := 50;
Pdf.RenderCacheMaxBytes := Int64(1024) * 1024 * 1024; // 1 GiB
Pdf.RenderCacheCapacity := 16; // 記憶體中的頁數
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
// 在這裡把複本交給縮圖列
finally
Bmp.Free; // 快取呼叫永遠回傳歸呼叫方所有的複本
end;
end;
finally
Pdf.Free; // 從 v2.770.140 起,這裡不再刪除磁碟條目
end;
end;
同一個程序跑兩遍,第二遍不會再點陣化任何裝得進快取的頁面。磁碟快取物件在第一次快取渲染時才惰性建立、活到 THotPDF 實例釋放為止,所以在那之後改 RenderCacheFolder、RenderCacheMaxDocuments 或 RenderCacheMaxBytes,不會搬動或縮放一個已經開著的快取。對記憶體准入政策來說太大的頁面(預設單條目不得超過 64 MiB 的 32 位元像素)同樣不會持久化,而且只有 RenderFallbackPolicy 維持預設 rfpIgnore 時才會詢問磁碟層,因為後備診斷資訊不跟 PNG 存在一起
為什麼 v2.770.140 之前 RenderCacheFolder 一直沒用?
v2.770.140 之前 RenderCacheFolder 沒有效果,是因為磁碟層以來源位元組的雜湊為文件鍵,而普通載入根本不留這種位元組。文件鍵來自對原始 PDF 位元組內部副本的 SHA-256,但 LoadFromFile 與 LoadFromStream 是原地剖析來源、不保留這種副本;那個欄位只在加密復原路徑上暫時填過,隨後立刻清掉。沒有位元組,鍵就永遠是空的,空鍵代表繞過磁碟層。沒有錯誤、沒有警告,就是一個一直空著的資料夾
把鍵變非空,暴露出藏在第一個 bug 背後的第二個。舊的 InvalidateRenderedPageCache 會刪掉文件的磁碟資料夾,而 InvalidateRenderedPageCache 在每次載入開頭、每次編輯與 Free 內部都會跑。鍵一生效,每個檢視器工作階段就會在退出時毀掉自己的快取,下一個工作階段反正也是冷啟動。更糟的是,鍵會在編輯之後從同一個來源重新計算,被編輯過的文件其渲染就會存到原始檔的鍵底下,端給下一個開啟未修改 PDF 的工作階段。v2.770.140 把身分與失效一起修;只修其一,出貨的 要嘛是個死快取,要嘛是個說謊的快取
HotPDF 怎麼不讀整個檔案就辨識一份 PDF
對從本機檔案載入的 PDF,HotPDF 以大小、最後寫入時間與頭尾各 64 KiB 組成的指紋辨識;對串流或隨機存取來源,以整個內容的 SHA-256 辨識。兩者都在載入成功的那一刻擷取一次,SHA-256 摘要的前 16 個十六進位字元(64 位元)成為文件鍵
| 來源 | 身分 | 成本 | 擷取時機 |
|---|---|---|---|
LoadFromFile | 大小 + LastWriteTime + 頭尾各 64 KiB,以 SHA-256 雜湊 | 最多讀 128 KiB,與檔案大小無關 | 每次成功載入,就算 RenderCacheFolder 之後才設也一樣 |
LoadFromStream | 整個串流的 SHA-256 | 來源完整走一遍 | 只有 RenderCacheFolder 在載入前已設定時 |
LoadFromRandomAccessSource | 整個來源的 SHA-256 | 來源完整走一遍 | 只有先設了資料夾、且整個範圍可用時 |
帶 /Encrypt 條目的任何來源 | 無 | 無 | 絕不;磁碟層被繞過 |
檔案指紋是刻意的取捨。每次開啟都對 400 MB 的掃描檔完整雜湊,成本可能超過渲染使用者真正要看的那兩頁。取樣區域不是隨便挑的:檔頭在檔案開頭,trailer 與最後一個交叉參照節在結尾(ISO 32000-1 §7.5)。增量更新會附加新的本體、交叉參照節與 trailer(§7.5.6),大小與尾端一起變。任何正常工具的完整重寫都會動到最後寫入時間。128 KiB 以內的檔案,兩段取樣涵蓋每個位元組,小文件等於被完整雜湊
殘餘風險是:有人對大檔案中段做同尺寸的原地修改,然後把時間戳復原。這需要一個刻意在編輯內容時保留修改時間的工具,罕見但不是不可能,那種情況下快取會端出過期頁面。反面是良性的:在 Windows 上複製檔案通常保留最後寫入時間,所以已在快取裡的文件的副本會命中同一批條目——這是對的,因為位元組一模一樣
串流根本沒有修改時間,唯一誠實的身分就是內容。HotPDF 只有在您載入前就要求了磁碟快取時,才付那趟完整 SHA-256 的成本;LoadFromStream 的其他呼叫者感受不到額外開銷。屬性賦值順序因此變得舉足輕重:
procedure OpenDownloadedPdf(Pdf: THotPDF; Data: TStream;
const CacheRoot: string);
begin
// 對串流而言的錯誤順序:內容雜湊只在資料夾
// 已經設定時才計算,這份文件會因此繞過磁碟層
// Pdf.LoadFromStream(Data);
// Pdf.RenderCacheFolder := CacheRoot;
Pdf.RenderCacheFolder := CacheRoot; // 先設定
Data.Position := 0;
if Pdf.LoadFromStream(Data) <= 0 then
raise Exception.Create('The stream is not a loadable PDF');
end;
還在下載中的隨機存取來源(部分範圍尚不可用)拿不到身分,而不是拿到部分內容的雜湊;計算身分因任何原因失敗時,載入照樣成功,文件就只是沒有磁碟層地渲染
什麼會讓 HotPDF 磁碟快取條目失效?
HotPDF 磁碟快取條目絕不會因編輯而被刪除失效;相反,編輯已載入的文件會丟掉文件身分,那趟載入的剩餘時間裡磁碟層被繞過,而存下的頁面對未修改的來源依然有效。條目離開磁碟只有三條路:LRU 與位元組上限、損壞的 PNG,或 schema 變更
鍵描述的是磁碟上的來源,不是記憶體裡的物件圖。一旦您蓋了章、改了註解,文件就不再匹配那個來源,用它的鍵去讀去寫都不對。從 v2.770.140 起,文件層與頁層的失效都改為清除身分、不碰資料夾;對沒呼叫 InvalidateRenderedPageCache 的編輯還有第二道防線:使用磁碟層之前,THotPDF 會檢查有沒有任何已載入物件是 dirty 的,dirty 的文件視同沒有身分
渲染設定則反著走。切換 PageRenderBackend(或呼叫 UseNativeGDIRenderBackend)、呼叫 ConfigureRenderICCWorkflow 或 ClearRenderICCWorkflow,會沖掉記憶體頁面但保留身分,因為文件仍匹配它的來源。那些設定改變像素、卻不是記憶體變體的一部分,所以磁碟鍵把後端名稱、黑點補償旗標、ICC 打樣與輸出設定檔的 SHA-256 摘要都摺進去。變體本身已涵蓋色彩意圖、輸出抖動、疊印預覽、明度遮罩模式、後備政策與每個 optional content 群組的可見性,所以切換一個圖層,會渲染進不同的資料夾,而不是蓋掉預設視圖
想讓編輯過的文件重回磁碟層,存檔再載入結果,給它新的來源身分:
procedure CommitEditsAndRekey(Pdf: THotPDF; const EditedFile: string);
begin
// 編輯已載入文件之後:重新整理記憶體頁面。
// 來源身分已經不在,所以對原文件的磁碟資料夾
// 既不讀也不寫
Pdf.InvalidateRenderedPageCache;
// 存出來的檔案有新的大小與最後寫入時間,因此有新
// 身分;這次載入之後的渲染會存進新鍵底下
Pdf.SaveLoadedDocument(EditedFile);
if Pdf.LoadFromFile(EditedFile) <= 0 then
raise Exception.Create('Could not reload the edited document');
end;
原文件的資料夾原封不動,跟其他條目一樣透過 RenderCacheMaxDocuments 與 RenderCacheMaxBytes 自然老化。使用者重新開啟未編輯的原稿,頁面還在那裡
安全邊界:加密來源與連結資料夾
HotPDF 磁碟渲染快取刻意拒收兩種輸入:絕不把加密 PDF 的頁面寫上磁碟,絕不跟進屬於 junction 或其他 reparse point 的文件子資料夾。兩條規則都是拿快取命中,換不洩漏資料、不刪錯檔案
加密 PDF 絕不寫進磁碟快取
渲染出來的頁面就是解密後的內容。把它以普通 PNG 寫進快取資料夾,等於在磁碟上留下受密碼保護文件的一份可讀副本,脫離了作者選擇的保護(ISO 32000-1 §7.6)。所以 HotPDF 對 trailer 帶 /Encrypt 條目的任何來源都不擷取身分,包括用密碼開啟、或使用者密碼為空的檔案。這些文件照樣用記憶體層,而記憶體層隨行程一起消失
v2.770.173 起拒絕 junction 子資料夾
快取根目錄由您決定,指向 junction 是允許的。它底下的文件子資料夾是另一回事:快取會自作主張地建立、讀取、觸碰與刪除它們——啟動復原(清除殘留暫存檔)時、查找(更新時間戳)時、儲存時、失效處理時,還有三個逐出上限執行時。要是有寫入權限的人把某個文件資料夾換成指向別處的 junction,上述每一條路徑都會跟著走進去,逐出就會刪到快取從未擁有過的地方。從 v2.770.173 起,每個這些進入點都檢查 reparse-point 屬性、跳過連結的文件資料夾:查找計為撲空,儲存計為寫入失敗,逐出則放它一馬
Unicode 路徑與共用根目錄
要部署到使用者設定檔的話,有兩個相關修正要緊。v2.770.135 之前,RenderCacheFolder 是 AnsiString,系統字碼頁之外的資料夾(例如英文版 Windows 上的中文使用者名稱)在快取看到之前就被失真轉換;屬性現在是 Unicode string,原子替換走寬字元版 Windows API。從 v2.770.52 起,同一行程式裡指向同一個根目錄(路徑展開後、不分大小寫比較)的多個 THotPDF 實例,共用一份帶參照計數的索引與鎖。先前每個實例用自己的副本覆寫 index.txt、對著自己那點殘缺視野執行上限,資料夾因此可能超出預算好幾倍
這種共用止步於行程邊界。同一根目錄上的兩個獨立行程仍然各持記憶體索引,所以並行運行的每個應用程式請各給一個快取根。在工作執行緒上渲染的檢視器在單一行程內沒問題:PrefetchLoadedPages 與 以請求佇列做背景渲染一文涵蓋的佇列,走的都是同一條快取路徑、同一把鎖
速查:RenderCacheFolder 檢查清單
RenderCacheFolder、RenderCacheMaxDocuments與RenderCacheMaxBytes要在第一次呼叫RenderLoadedPageToBitmapCached之前設好;串流與隨機存取載入,資料夾要在載入前設- 依賴磁碟層就升級到 v2.770.140 以上;更早的版本收下屬性,普通載入卻從來不會從磁碟端出一頁
- 加密 PDF、載入後編輯過的文件,或
RenderFallbackPolicy不是rfpIgnore時,都不會有磁碟快取 - 正常釋放 THotPDF 實例即可;從 v2.770.140 起,
Free與InvalidateRenderedPageCache都不刪磁碟條目 - 換
PageRenderBackend或 ICC 工作流,文件仍在磁碟層上、換個鍵 - 每個運行中的應用程式一個快取根;同一行程內的實例自 v2.770.52 起共用索引
- 快取根放在每位使用者自己的位置;屬於 junction 的文件子資料夾自 v2.770.173 起被跳過
常駐頁面快取在整天反覆開同一批文件的檢視器裡最划算,這正是本站另一篇文章 Delphi 的自訂 PDF 檢視器架構描繪的形狀。RenderCacheFolder、記憶體點陣快取與頁面渲染器,都隨 HotPDF Delphi PDF component 出貨,支援 Delphi 與 C++Builder