技术文章

用 Delphi 与 PDFium 按需流式加载巨型 PDF

一个扫描存档可以在单个 PDF 中达到数吉字节。一个打开这样一份文件的查看器通常只想显示一页,也许是目录,也许是用户从书签跳到的一页。把整个文件读进内存来渲染两页在每个维度上都是浪费:它烧掉地址空间、在一次漫长的初始读取背后拖住用户,而在一个 32 位 Delphi 进程上它可以在一页出现之前就直接失败。PDFium 就是带着这一点在脑海中构建的。它可以通过一个回调加载一份文档,该回调在它需要时请求它所需的特定字节范围,而且它从不一次要求整个文件。有一个边界该预先声明:这条流式通道用一个 32 位长度描述文件,所以它服务单个文件最多到 4 GiB,这在实践中覆盖了几乎每个扫描存档。越过那条线的文件不是本文的领地;它要在扫描时被拆分成卷,或改为通过一种直接访问策略打开,而如实强制那条上限的守卫在下面有自己的一节

该组件通过一个流适配器暴露那条路径。你交给它任何 TStream,而 PDFium 按需从那条流中拉取块。文件可以坐在磁盘上、一个数据库 blob 字段里,或任何其他 TStream 后代背后,而它没有一个在预先被复制进内存

PDFium 如何请求字节

PDFium 的 C API 从一个由调用者提供的、由 FPDF_FILEACCESS 结构描述的对象加载文档。该结构有三部分在此要紧:一个长度字段、一个读取回调,以及一个不透明的用户参数。消费它的入口点是 FPDF_LoadCustomDocument。一旦 PDFium 持有该结构,它解析 trailer、定位交叉引用表,并从那时起只读取给定操作所需的东西。打开文档触碰的是文件尾部和少数几个目录对象。渲染第 400 页读取的是那一页的内容流和资源,别无其他

这就是缓冲加载与流式加载之间的差别。一次缓冲加载在 PDFium 看到第零个字节之前把文件从头读到尾。一次流式加载反转了那种关系:PDFium 驱动读取,而从未被触碰的字节从未被读取。对于一个逐页查看的多吉字节文件,那就是一次不可用的加载与一次瞬时加载之间的差距

架构图:对比缓冲式加载(解析前把数 GB 的 PDF 复制进内存)与流式(PDFium 经 FPDF_FILEACCESS 向 Delphi TStream 请求字节区间)
打开只花 trailer 和 Catalog 的成本;渲染第 400 页时经回调只拉取第 400 页的字节,别无其他

流适配器

把一个 Delphi TStream 桥接到 FPDF_FILEACCESS 的适配器是 TPdfStreamAdapter。它的构造器接收流和一个所有权标志,一次性捕获流长度,填好 FPDF_FILEACCESS 记录,并接线读取回调。当 PDFium 稍后带一个偏移和大小回调时,适配器把流寻址到那个偏移,并把恰好那个范围复制进 PDFium 提供的缓冲区

// 直接取自组件:stream 到 FPDF_FILEACCESS 的桥接
constructor TPdfStreamAdapter.Create(AStream: TStream; AOwnsStream: Boolean);
begin
  inherited Create;
  if AStream = nil then
    raise EPdfError.Create('TPdfStreamAdapter: AStream is nil');
  FStream := AStream;
  FOwnsStream := AOwnsStream;

  // FPDF_FILEACCESS.m_FileLen 是一个 32 位无符号长整型。拒绝一个会
  // 在超过 4 GiB 处被静默截断的流。
  if AStream.Size > High(FPDF_DWORD) then
    raise EPdfError.Create('TPdfStreamAdapter: stream exceeds the 4 GiB limit');

  FillChar(FFileAccess, SizeOf(FFileAccess), 0);
  FFileAccess.m_FileLen  := FPDF_DWORD(AStream.Size);
  FFileAccess.m_GetBlock := GetBlockCallback;
  FFileAccess.m_Param    := Self;
end;

所有权标志决定谁释放流。传 False 而调用者保留流,并且必须为文档的整个生命保持它存活。传 True 而适配器接管,在文档关闭时释放流。无论哪种,流都必须比 PDFium 将执行的每次读取活得更久,因为 PDFium 持有 FPDF_FILEACCESS 指针,并会在文档打开期间的任何时刻回调,而不仅仅是在初始加载期间

为何回调是一个静态函数

PDFium 存进 m_GetBlock 的读取回调是一个带 cdecl 调用约定的普通 C 函数指针。一个 Delphi 方法不能直接使用,因为一个方法携带一个隐藏的 Self 参数,而一个 C 调用者对此一无所知也永远不会提供。因此适配器把回调声明为一个标了 cdecl; staticclass function,它编译成一个带 PDFium 期望的 C 帧布局、且没有隐式 Self 的独立函数

那解决了调用约定却引出了第二个问题:没有 Self,回调如何到达它本该读取的那个特定流?答案是不透明的用户参数。当适配器构建记录时,它把自己的实例指针存进 m_Param。PDFium 把同一个指针作为每次回调的第一个参数交还。静态函数把它转回一个 TPdfStreamAdapter,并针对那个实例的流派发读取。这是把对象上下文交给一个没有对象概念的 C 边界的标准蹦床

示意图:cdecl 蹦床——把 PDFium 块请求从 C 边界带进 Delphi 的 TPdfStreamAdapter 实例,并把异常坍缩为零返回值
静态 cdecl 回调不藏隐式 Self;m_Param 把适配器实例交还给每次调用,任何 Pascal 异常都折叠为零返回值
// 直接取自组件:回到该实例的 cdecl 蹦床
class function TPdfStreamAdapter.GetBlockCallback(
  param   : Pointer;
  position: FPDF_DWORD;
  pBuf    : PByte;
  size    : FPDF_DWORD): Integer; cdecl;
var
  Adapter: TPdfStreamAdapter;
begin
  Result := 0;
  if (param = nil) or (pBuf = nil) or (size = 0) then
    Exit;
  Adapter := TPdfStreamAdapter(param);   // 从 m_Param 恢复该实例
  if Adapter.FStream = nil then
    Exit;
  try
    Adapter.FStream.Position := Int64(position);
    Adapter.FStream.ReadBuffer(pBuf^, Int64(size));
    Result := 1;
  except
    Result := 0;  // 通过返回值报告失败,绝不抛出异常
  end;
end;

4 GiB 上限以及它为何需要一道守卫

这里就是开篇陈述的那条边界的来源。FPDF_FILEACCESS 中的长度字段 m_FileLen 是一个 32 位无符号值。它可表示的最大长度是 4 GiB 少一个字节。一个 TStream 把它的大小报告为一个 Int64,所以一个流可以描述比该字段能容纳的多得多的字节。在一个流的大小越过那条上限的那一刻,没有诚实的方式告诉 PDFium 文件有多长

错误的响应是赋上大小并让它回绕。把一个 5 GiB 长度截断到一个 32 位字段产生一个小的、看起来合理的数字,而 PDFium 会随之在相信文件大约在一吉字节处结束的情况下解析它。Trailer 和交叉引用表住在文件真正的末尾,远过被截断的长度,所以解析以一种与实际原因毫无关系的方式失败。你会在一个完全有效的文件上调试一个交叉引用错误,而没有任何暗示两个层之上有个整数回绕了

适配器转而拒绝该输入。构造器把流大小与 High(FPDF_DWORD) 相比较,并在流大到无法描述的那一刻抛出 EPdfError。一个显式的、即时的错误在构造点说出真实问题。一次静默截断把它藏在一个你会在很久之后才追查的误导性症状背后。4 GiB 上限是这条加载路径的一项真实约束,而诚实的做法是把它大声暴露出来,而不是用恰好能编译的算术把纸糊过去。当一个存档真正越过那条线时,顶部承诺的补救措施住在这条 API 之外:把扫描拆分成每个都在上限之下的分卷文件,或把文档留在磁盘上并通过一种建在 64 位偏移之上的直接访问设计来服务它,而非通过 FPDF_FILEACCESS

决策图:守护 FPDF_FILEACCESS 的 4 GiB 限制——超大的 Delphi TStream 立即抛出 EPdfError,而非悄然回绕声明的长度字段
立即抛出 EPdfError 胜过仅仅能编译的算术:回绕的 m_FileLen 会把调试引向一条虚构的交叉引用踪迹

失败不得越过边界

一次读取可能失败。流可能是一个超时的网络后台对象、一个在你下面被关闭的 blob 句柄,或一个在文档打开之后被截断的文件。PDFium 对读取回调的契约是一个返回值:非零为成功,零为失败。它是一个 C 帧,而它没有捕获或传播一个 Pascal 异常的机制

这就是为何蹦床把寻址和读取包在一个吞掉异常并返回零的 try/except 中的原因。如果一个 Delphi 异常被允许从回调中传播出去,它会展开穿过 PDFium 的 cdecl 栈帧,而那些帧从不是为被 Pascal 异常机制展开而构建的。结果在最好情况下是未定义行为,在最坏情况下是一次硬崩溃,深陷在 PDF 解析器内部,没有可用的栈。返回零把失败保持在契约内部。PDFium 看到一次失败的块读取,干净地中止操作,而 FPDF_LoadCustomDocument 报告文档无法加载,组件在 Pascal 侧把它作为 EPdfError 浮现在它该在的地方

以这种方式打开一份文档

驱动流式路径的组件方法是 LoadCustomDocument,声明为一个独立方法而非又一个 LoadDocument 重载,这样传一个 TMemoryStream 就永远不会意外落到缓冲路径上。它构建适配器,调用 FPDF_LoadCustomDocument,并为已加载文档的生命保持适配器存活

var
  Pdf: TPdf;
  FileStream: TFileStream;
begin
  Pdf := TPdf.Create(nil);
  FileStream := TFileStream.Create('Archive_4GB.pdf', fmOpenRead or fmShareDenyWrite);
  try
    // 把流的所有权交给 Pdf:文档关闭时它会释放 FileStream。
    Pdf.LoadCustomDocument(FileStream, True);
    // PDFium 目前只读取了 trailer 和 catalog。
    // 渲染一页只会通过回调拉取该页的字节。
    // ... 在此渲染或检查各页 ...
  finally
    Pdf.Free;  // 关闭文档,从而释放适配器和流
  end;
end;

同一个调用对一个 TMemoryStream、来自一个数据库数据集的 blob 流,或一个自定义 TStream 后代都适用。按需加载在文件很大而且只有它的一部分会被读取时挣回它的价值:一个存档查看器、一个采样几页的缩略图生成器、一个一次拉一页的搜索索引。当文件很小或你反正会全部读取时,一次缓冲加载更简单,而流式机制买不到任何东西。决定性因素是你将实际触碰的字节与文件所含字节的比率

一旦页面按需流进来,下一个关切就是在用户缩放和滚动时保持已渲染页面的响应,这在我们关于渲染缓存与缩放性能的笔记中涵盖。当流式文档是一个查看器应显示却不让用户导出或更改的时候,安全 PDF 预览演练中的技术与这条加载路径天然搭配。两者都建立在本文描述的流式加载之上,后者作为面向 Delphi 和 C++Builder 的 PDFium Component的一部分发布,旁边还有本博客其他地方涵盖的渲染、文本提取和注解 API