技术文章

HotPDF RenderCacheFolder:Delphi 磁盘页面缓存

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 所述的内容流解释器,新位图随后也写进磁盘

HotPDF 图示 RenderLoadedPageToBitmapCached 的渲染缓存查找:先查按页面、DPI 与渲染变体为键的内存层,再查带原子替换的 RenderCacheFolder PNG 磁盘层,再走内容流解释器,每次命中都返回调用者所有的副本
HotPDF 先查内存、再查磁盘,最后才栅格化;磁盘命中会提升回内存,每条路径交给你的都是你拥有且必须释放的副本

磁盘上的布局刻意朴素。每份文档一个子文件夹,名字由 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 表项的任何源无无从不;磁盘层被绕过
HotPDF 磁盘渲染缓存的源同一性地图:LoadFromFile 哈希大小、LastWriteTime 与头尾各 64 KiB,LoadFromStream 与 LoadFromRandomAccessSource 仅在先设置了 RenderCacheFolder 时才哈希全部内容,任何带 /Encrypt 的 trailer 完全不捕获同一性
文件从两端取指纹,因为文件头、xref 和 trailer 都在那里;流只有在你先要了缓存时才为完整哈希买单;加密文档永远不写盘

文件指纹是刻意的取舍。每次打开都对一份 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 检查是否有加载对象是脏的,脏文档视作没有同一性

渲染设置朝反方向走。切换 PageRenderBackend(或调 UseNativeGDIRenderBackend),以及调用 ConfigureRenderICCWorkflow 或 ClearRenderICCWorkflow,会清空内存页面但保留同一性,因为文档仍匹配它的源。这些设置改变像素却不属于内存变体,所以磁盘键把后端名称、黑点补偿标志以及 ICC 打样与输出 profile 的 SHA-256 摘要折进键里。变体本身已覆盖色彩意图、输出抖动、叠印预览、亮度蒙版模式、回退策略和每个可选内容组的可见性,所以切换图层会渲染进不同的文件夹,而不是覆写默认视图

HotPDF 的 RenderCacheFolder 磁盘缓存失效语义:编辑加载中的文档或任何脏对象会丢弃源同一性从而绕过该层,更改渲染后端或 ICC 工作流在新变体键下保留同一性,保存再重新加载则给文档换新键
编辑从不删除已存储的文件夹,设置变更换个键渲染,只有保存再重新加载才能让编辑后的文档赢得新的同一性

想让编辑后的文档回到磁盘层,把它保存并加载结果,给它一份新的源同一性:

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 检查清单

  • 在第一次调用 RenderLoadedPageToBitmapCached 之前设置 RenderCacheFolder、RenderCacheMaxDocuments 和 RenderCacheMaxBytes;流与随机访问加载要在加载前设置文件夹
  • 依赖磁盘层就升级到 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 组件发布,支持 Delphi 与 C++Builder