技术文章

Delphi 中的 HotPDF 页面提取性能

从一个 40 页的 PDF 中复制 3 页需要两分钟,这已经不是性能调优的问题了,而是一个使用了错误 API 路径的信号。当我第一次在一个 HotPDF Component 的页面复制示例上看到这个耗时时,我的直觉是先看文档结构,然后再看代码。事实证明这个顺序很重要。

究竟是什么变慢了

有问题的 PDF 是一个 40 页的参考文档,它具有非平凡的页面树:包含多个中间的 /Pages 节点,而不是单个扁平数组。原始的示例代码调用 LoadFromFile,然后使用 BeginDoc 构建一个新文档,在选定的页码上进行循环,并在每次迭代中再次从磁盘加载源文档以提取页面。这相当于将完整的解析成本乘以你想要的页面数。一个 12 MB 的文件为了提取 3 页竟然读取了 6 次磁盘,因为没人注意到该文件是否需要在整个迭代过程中保持打开状态。

第二个促成因素在代码中是不可见的:HotPDF 的 LoadFromFile 在加载时会解析整个交叉引用表并解压缩每个对象流。对于你要修改的文档来说,这是正确的行为,但如果你只想要页数和页面的子集,这做的工作就太多了。对于结构的只读访问,DAOpenFileReadOnly 避免了反序列化完整的对象树,这对于包含大量图像资源的压缩文件来说很重要。

这些都不是库的 bug。这两种情况都是调用者选择了专为某项工作设计的 API,却将其用于另一项完全不同的工作。

使用 InsertPagesFromDocument 进行页面提取

将页面范围从一个 HotPDF 文档复制到另一个文档的正确路径是 InsertPagesFromDocument,在源文档的 LoadFromFile 之后调用它。你加载源文件一次,加载或创建目标文件一次,移动页面,然后保存。在所有页面插入过程中,源始终保存在内存中:

procedure ExtractPages(const SourceFile, DestFile: string;
  const PageRange: string);
var
  Source, Dest: THotPDF;
begin
  Source := THotPDF.Create(nil);
  Dest   := THotPDF.Create(nil);
  try
    // Load source once: full parse happens here and only here
    Source.LoadFromFile(SourceFile);

    // Build a minimal destination document
    Dest.FileName := DestFile;
    Dest.BeginDoc;

    // Copy the requested range; '1-3' inserts pages 1 through 3
    // starting at position 1 in the destination
    Dest.InsertPagesFromDocument(Source, PageRange, 1);

    Dest.EndDoc;
  finally
    Source.Free;
    Dest.Free;
  end;
end;

PageRange 参数接受与命令行示例相同的格式:逗号分隔的页码或范围列表,例如 '1-3''1,5,7-9'。页面从 1 开始编号。InsertPagesFromDocument 复制内容流、资源字典和页面几何信息,而不触及元数据、书签或嵌入的文件附件,除非它们被所复制的页面引用。对于从 40 页文档中提取 3 页的操作而言,这是一个很小的工作集。

以前运行需要两分钟的同一个 12 MB 文件,使用这种模式的时间不到 1.5 秒。大部分时间都花在那一次 LoadFromFile 调用上。一旦对象表在第一次被解析,文档结构就变得无关紧要了。

当 LoadFromFile 开销过大时:直接文件 API

如果你只需要计算页数、检查文档信息,或者在不接触其内容的情况下复制文件,直接文件 API (Direct File API) 完全避免了完整解析。DAOpenFileReadOnly 映射交叉引用表而不解压缩对象流,因此计算页数的时间复杂度是 O(交叉引用表大小) 而不是 O(文件大小):

procedure InspectPDF(const FileName: string);
var
  Pdf: THotPDF;
  Handle, PageCount: Integer;
begin
  Pdf := THotPDF.Create(nil);
  try
    Handle := Pdf.DAOpenFileReadOnly(FileName, '');
    if Handle <= 0 then
      Exit;
    try
      PageCount := Pdf.DAGetPageCount(Handle);
      Writeln('Pages: ', PageCount);

      // DACopyFile is a byte-preserving copy, no re-serialization
      Pdf.DACopyFile(FileName, 'archive-copy.pdf');
    finally
      Pdf.DACloseFile(Handle);
    end;
  finally
    Pdf.Free;
  end;
end;

需要注意的是:DAOpenFileReadOnly 接受一个密码参数,但对于加密输入,它会回退到完整解析,因为解密需要对象树来解析加密字典。如果你的源文件是加密的,请先使用 DecryptFile 对其进行解密以获取未加密的副本,然后再使用直接文件 API 打开该副本。文件级 DecryptFile 函数对标准加密采用了直接的 AES-256 重写路径,并且对于大文件,它比 LoadFromFile 后跟 SaveLoadedDocument 更快,因为它不会构建完整的内存对象模型。

大批量处理期间的内存

在循环中处理几十个文件的批处理作业有一种看起来正确但会累积内存的模式:在循环内部创建 THotPDF,调用 LoadFromFile,执行工作,然后调用 Free。这在结构上是没问题的。问题出在当内部工作分配了临时对象、捕获了异常,并使这些临时对象在错误路径上存活时。Delphi 的内存管理器不进行内存整理,因此,在整个批处理运行过程中,错误路径上的一百个泄漏就可以将内存推高到足以减慢所有其他分配的速度。

修复方法并不稀奇。每个 THotPDF 以及参与 PDF 工作的每个中间 TStreamTBitmap 都应放在 try/finally 块中,并且 Free 必须是最后一条语句。在 try 之前将局部指针设置为 nil,以便当初始化中途失败时,finally 分支能够安全地使用 if Assigned(x) then x.Free。这是标准的 Delphi 所有权原则,并且它完全足以解决这类问题。

在批处理上下文中还需要检查一件事:AddImage 会将图像注册到一个在 THotPDF 实例的生命周期内持久存在的内部列表中。如果你通过重复调用 LoadFromFile 在多个文档中重用单个实例,则早期文档中的图像注册会留在该列表中。要么为每个文档创建一个新实例,要么在处理文档之间调用清理图像列表的路径。

在做出任何更改之前进行测量

在使用这些模式之前,请先进行测量。System.Diagnostics 提供的 Delphi 的 TStopwatch 包装了 QueryPerformanceCounter,它的精度足以对文件 I/O 进行挂钟性能分析。只需单独包装 LoadFromFile 并查看它占用了多长时间。如果它占总时间的 90%,修复方法就是使用直接文件 API,或是减少你解析同一个文件的次数。如果它低于 20%,瓶颈就在其他地方,你就找错方向了。

引发这篇文章的两分钟提取操作最终被证明完全是由于重复加载模式造成的。文档结构并没有产生影响;即使是扁平的页面树也会以同样的方式运行。切换为单次 LoadFromFile 后跟一次 InsertPagesFromDocument 调用,在没有任何其他改动的情况下,使同一硬件上的时间缩短到了 1.3 秒。

此处展示的页面操作 API 是适用于 Delphi 和 C++Builder 的 HotPDF Component 的一部分。