Technical Article

在 Delphi 中使用 PDFium 按需流式传输巨型 PDF

单个 PDF 格式的扫描存档可以达到几个千兆字节(GB)。打开此类文件的查看器通常只想显示一页,可能是目录,或者是用户从书签跳转到的页面。将整个文件读取到内存中以渲染两页在每个轴向上都是浪费的:它消耗地址空间,让用户在漫长的初始读取后面等待,并且在 32 位 Delphi 进程上,它可能在显示单页之前直接失败。PDFium 的构建就考虑到了这一点。它可以通过一个回调来加载文档,该回调在需要时请求它所需的特定字节范围,并且从不需要一次性加载整个文件。

该组件通过流适配器公开了该路径。你只需向其传递任何 TStream,PDFium 就会按需从该流中提取块。文件可以放在磁盘上、数据库 blob 字段中或任何其他 TStream 子类的背后,并且所有这些内容都不会预先复制到内存中。

PDFium 是如何请求字节的

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

这就是缓冲加载与流式加载的区别。缓冲加载在 PDFium 看到字节 0 之前端到端地读取文件。流式加载倒置了这种关系:PDFium 驱动读取,而从未被触及的字节就永远不会被读取。对于一次只查看一页的数 GB 大小的文件来说,这是不可用的加载与即时加载之间的本质区别。

流适配器

将 Delphi TStream 桥接到 FPDF_FILEACCESS 的适配器是 TPdfStreamAdapter。它的构造函数接受流和所有权标志,捕获一次流长度,填充 FPDF_FILEACCESS 记录,并连接读取回调。当 PDFium 稍后通过偏移量和大小进行回调时,适配器会将流寻道到该偏移量,并精确地将该范围复制到 PDFium 提供的缓冲区中。

// Verbatim from the component: the stream-to-FPDF_FILEACCESS bridge
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 is a 32-bit unsigned long. Refuse a stream
  // that would silently truncate past 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 边界传递对象上下文的标准跳板(trampoline)。

// Verbatim from the component: the cdecl trampoline back to the instance
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);   // recover the instance from 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;  // report failure by return value, never by raising
  end;
end;

4 GiB 上限以及为什么它需要防护

FPDF_FILEACCESS 中的长度字段 m_FileLen 是一个 32 位无符号值。其最大可表示长度比 4 GiB 少一个字节。TStream 将其大小报告为 Int64,因此流可以描述比该字段所能容纳的字节多得多的字节。一旦流的大小超过该上限,就没有真实的方法来告诉 PDFium 文件有多长了。

错误的反应是分配大小并让其回绕。将 5 GiB 的长度截断为 32 位字段会产生一个小的、看起来合理的值,然后 PDFium 会解析该文件,认为它在大约 1 GB 处结束。而 trailer 和交叉引用表位于文件的真实末尾(远超出截断后的长度),因此解析会以一种与实际原因毫无关系的方式失败。你将会针对一个本是有效的文件调试交叉引用错误,而没有任何提示指出两层之上的一个整数发生了回绕。

相反,适配器拒绝输入。构造函数将流大小与 High(FPDF_DWORD) 进行比较,并在流过大而无法描述的瞬间引发 EPdfError。在构造点显式、立竿见影的报错指出了真正的问题。而静默截断会将其隐藏在令人误解的症状背后,这会让你在很久之后才去排查。4 GiB 限制是该加载路径的真实约束,真实的做法是将其大声地表面化,而不是用碰巧可以编译的算术运算来掩盖它。

失败绝不能跨越边界

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

这就是为什么跳板(trampoline)将寻道和读取封装在吞掉异常并返回零的 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
    // Hand stream ownership to Pdf: it frees FileStream when the document closes.
    Pdf.LoadCustomDocument(FileStream, True);
    // PDFium has read only the trailer and catalog so far.
    // Rendering a page pulls just that page's bytes through the callback.
    // ... render or inspect pages here ...
  finally
    Pdf.Free;  // closes the document, which frees the adapter and the stream
  end;
end;

相同的调用适用于 TMemoryStream、来自数据库数据集的 blob 流,或自定义的 TStream 子类。按需加载就体现了价值,当文件很大且只有一部分会被读取时:存档查看器、对几页进行采样的缩略图生成器,或者一次只拉取一页的搜索索引。当文件很小或者你无论如何都要读取全部内容时,缓冲加载更简单,且流式传输机制不会给你带来任何好处。决定因素是你实际触及的字节与文件包含的字节的比例。

一旦页面按需流式传入,下一个关注点就是当用户缩放和滚动时保持渲染页面的响应速度,这在我们关于渲染缓存和缩放性能的笔记中进行了介绍。当流式文档是查看器应显示但不允许用户导出或更改的文档时,安全 PDF 预览指南中的技术可以自然地与此加载路径配合使用。两者都基于此处描述的流式加载构建,该加载作为 Delphi 和 C++Builder 的 PDFium Component 的一部分提供,同时还包括本博客其他地方介绍的渲染、文本提取和批注 API。