技术文章

在 Delphi 中按需流式加载超大 PDF 文件

一个扫描件存档可能会有数十 GB 大小,打开这种 PDF 时,阅读器通常只需要显示一页,例如目录页或书签跳转后的目标页。将整个文件一次性读入内存再渲染两页会浪费很多资源,增加启动等待,并且在 32 位 Delphi 进程里可能在首屏渲染前直接失败。PDFium 之所以设计了按需加载,就是为了避免这些问题:它不会一次读取整份文件,而是按需读取每次需要的字节范围

组件通过流适配器暴露这条路径。你只要传入任意 TStream,PDFium 就会按需从流中拉取数据块。文件可以来自磁盘、数据库 blob 字段或任何 TStream 子类,PDFium 不会预先拷贝全部内容到内存

PDFium 如何请求字节

PDFium 的 C API 接收调用方提供的 FPDF_FILEACCESS 结构后加载文档,它包含长度、读取回调和不透明用户参数三部分。入口函数是 FPDF_LoadCustomDocument。PDFium 在拿到该结构后先解析 trailer 并定位交叉引用表,然后只读取当前操作必须的内容。打开文档时通常会先触碰文件尾部和少量目录对象,渲染第 400 页时只读取该页的内容流与资源,不会触发全量读取

这就是缓冲加载和流式加载的区别,缓冲加载会在看到 byte 0 之前把文件全读一遍,流式加载则由 PDFium 按需驱动读取,未命中的字节根本不会被访问。对数十 GB 文件,这决定了可用加载与不可用加载的分界

流适配器

把 Delphi TStream 映射到 FPDF_FILEACCESS 的适配器是 TPdfStreamAdapter。它的构造函数接收流和所有权标志,保存流长度,填充 FPDF_FILEACCESS,并绑定读取回调。PDFium 回调时会传入偏移与长度,适配器据此 seek 到偏移位置并把对应范围精确拷贝进缓冲区

// 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 保持 FPDF_FILEACCESS 指针并在任意时刻回调,不只在初始加载时

为什么回调必须是静态函数

m_GetBlock 中的读取回调采用 C 的 cdecl 调用约定。Delphi 方法带有隐式 Self,并不能直接作为 C 回调。组件将回调声明为带 cdecl; staticclass function,因此它在二进制层面是无隐式 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,能表示远大于该限制的大小。若不校验,超大流会被截断后伪装成看起来正常的值,后续解析很容易走向错误判断

构造函数会比较 SizeHigh(FPDF_DWORD),超过限制即抛出 EPdfError,并在真实输入点停止。这个失败点直观且容易定位;如果让截断静默发生,异常则会在后续解析阶段很晚才暴露,而且表现为交叉引用失败等与业务无关的问题

4 GiB 限制是这条加载路径的真实边界条件,超过后要么拆分文件、要么保留 64 位索引路径。推荐把它作为明确错误而不是靠算术绕过处理

失败不能越界

读取也可能失败,可能来自超时网络对象、已被关闭的句柄,或后续被截断的文件。PDFium 回调约定依赖返回码:非零成功、零失败。回调失败不能抛出 Pascal 异常,因为这是 C 框架,它不能处理这类异常传播

因此回调封装了 try/except 并返回 0 而不是抛出异常;否则异常会穿透 cdecl 调用栈导致未定义行为,严重时触发崩溃并且丢失堆栈。返回 0 后 PDFium 能将失败控制在协议内,并通过 FPDF_LoadCustomDocument 报告加载失败,由组件侧转为 Pascal 的 EPdfError

按这种方式打开文档

LoadCustomDocument 是此流式路径的驱动入口,它是独立方法,不会与 LoadDocument 混淆。该方法先创建适配器再调用 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 预览指南 使用;两者都建立在本篇说明的流式加载之上,并依赖 PDFium Component 在 Delphi 和 C++Builder 下的渲染、文本提取与批注 API

`r`n`r`n