这个作业并不特别:一个夜间 Delphi 服务接收扫描的抵押贷款归档,统计页数,并把每个文件路由到正确的处理队列。它安静运行了几个月,直到一个 1.4 GB 的归档到达。LoadFromFile 会解析交叉引用数据,并为文件中数十万个间接对象逐一物化对象;在 32 位服务中,这棵树在解析过程中把工作集推过了 2 GB 地址空间上限。修复方式不是换更大的服务器。这个作业只问一个问题:有多少页?回答它根本不需要加载整个文档。
HotPDF 的 Direct File API 正是为这类工作存在的:从 Delphi 和 C++Builder 进行文件级 PDF 操作,只从磁盘读取所需信息,而不是物化完整文档模型。知道某个操作属于 API 的哪一层,是服务内存使用保持平坦和第一次遇到超大输入就崩溃之间的差别。
完整加载能带来什么,又要付出什么
用 LoadFromFile 加载文档会获得对所有内容的随机访问:任何页面、任何对象,都可用于重构、内容编辑或通过 SaveLoadedDocument 重新序列化。这种能力是页面操作的正确工具,InsertPagesFromDocument 和 MovePage 都需要这棵树。成本与文档规模成正比,而不是与操作成正比:解析时间随对象数增长;一旦考虑对象结构和解码后的流,常驻内存会达到文件大小的数倍。
当输入大小没有上限时,不匹配就会出现。客户上传、扫描仪输出和十年前的归档不会尊重测试语料的假设。每个输入都完整加载的管线,其内存需求由未来任何人可能提交的最大文件决定;把允许文件句柄读取的问题交给句柄层处理的管线,其内存需求大致恒定。对长期运行的服务而言,这个区别比原始速度更重要。
迁移到 64 位可以提高上限,但不会改变经济账:一个解析 GB 级文件的 worker,仍会花数秒 CPU 和文件数倍内存去回答文件结构本可直接回答的问题。并发会放大问题,四个同时进行的大文件加载会争抢同一内存预算,因此吞吐量会在队列最繁忙时崩塌。
基于句柄的检查
只读层把文件作为句柄打开,回答结构性问题,然后关闭它,不创建对象树,不渲染页面,也没有随文件大小成比例增长的内存。
var
Pdf: THotPDF;
Handle, PageCount: Integer;
begin
Pdf := THotPDF.Create(nil);
try
Handle := Pdf.DAOpenFileReadOnly('archive-2026-06.pdf', '');
if Handle > 0 then
try
PageCount := Pdf.DAGetPageCount(Handle);
RouteByPageCount('archive-2026-06.pdf', PageCount);
finally
Pdf.DACloseFile(Handle);
end;
finally
Pdf.Free;
end;
end;
三条规则能让这一层保持可靠。检查句柄:非正返回值表示打开失败,对无效句柄调用 DAGetPageCount 只会在客户发来的畸形文件上暴露。每次成功打开都要配对 DACloseFile,并放在 finally 块中,因为泄漏句柄的服务会缓慢劣化,而不是明显失败。还要知道密码参数的成本:DAOpenFileReadOnly 接收密码,但对加密输入,它会在内部退回到完整解析来回答页数问题;平坦内存属性只适用于未加密文件,因此受保护输入应先走 DecryptFile。
句柄层也可以作为廉价 triage gate。客户发来的文件可能标签错误、上传截断,或者从其他格式改名而来;在入口处用 DAOpenFileReadOnly 探测,可以在毫秒级拒绝这些文件,并把清晰错误绑定到正确文件上,而不是让它们在队列 worker 深处失败,耗掉一个下午做诊断。
整文件操作:复制、解密、加密
第二层会转换完整文件,但不暴露其内部结构,是接收管线的主力。
// Structural copy: validate-and-move without parsing the object tree
Status := Pdf.DACopyFile('incoming\statement.pdf', 'verified\statement.pdf');
LogDirectFileStatus('copy', Status);
// Decrypt while copying: the Direct File route into protected inputs
Status := Pdf.DecryptFile('incoming\protected.pdf',
'verified\plain.pdf', 'batch-password');
LogDirectFileStatus('decrypt-copy', Status);
// Encrypt while copying: protect an output without a full load
Status := Pdf.EncryptFile('verified\statement.pdf',
'outbound\statement.pdf', 'owner-secret', '', aes256, [prPrint]);
LogDirectFileStatus('encrypt-copy', Status);
每个调用都有不同角色。DACopyFile 是从隔离目录到受管存储的已验证复制,它会边复制边打开并索引 PDF 结构,因此截断输入或非 PDF 输入会在这里失败,而不是三个阶段之后。DecryptFile 会产生解密副本,在输入允许时走直接 AES-256 重写路径,避免构建对象树;它是AES-256 加密文章中 load-and-resave 解密流程的大文件对应方案。EncryptFile 则是镜像操作,会在文件级复制过程中应用密码保护,使用与内存路径相同的 key-type 和权限参数。
追加变更,而不是重写
ISO 32000-1 §7.5.6 定义的 incremental update 是第三层:原始字节在磁盘上保持不变,修改或新增对象追加在其后,并带有回链到原始文件的交叉引用区段。对一个只需要新增一页的 900 MB 归档来说,写入成本是 delta,而不是整个文件。
// Append an audit page to a large archive without rewriting it
Pdf.BeginIncrementalUpdate('archive-2026-06.pdf');
Pdf.AddPage;
Pdf.CurrentPage.SetFont('Arial', [], 10);
Pdf.CurrentPage.TextOut(50, 760, 0, 'Processed by intake service 2026-06-11');
Pdf.SaveIncrementalUpdate('archive-2026-06-stamped.pdf'); // original bytes + delta
这里的纪律是:BeginIncrementalUpdate 必须指向原始文件,因为追加的交叉引用数据会回链到其中的偏移。并且该模型按设计就是只追加,每次 incremental save 都会让文件变大,永远不会变小。因此,如果一个文档每晚都被盖章,它会无上限增长,直到周期性重新序列化,也就是加载文档并通过 SaveLoadedDocument 写回,来压缩它。只追加属性也使 incremental update 成为修改数字签名文档的唯一安全方式,这个约束在数字签名和 PAdES 文章中分析;底层交叉引用机制见对象流和 incremental updates 文章。
只追加保存还有一个审阅时容易漏掉的属性:原始字节仍然留在文件里,任何查看的人都能读取。一次“替换”页面的 incremental update 并不会擦除旧页面,只是在当前 revision 中覆盖它,而上一 revision 仍可恢复。绝不要用 incremental updates 删除敏感内容;正确方式是完整重新序列化,即 LoadFromFile 后接 SaveLoadedDocument,只保留收件人应看到的当前状态。
让操作匹配正确层级
选择逻辑可以压缩成四行,值得在管线顶部编码成显式路由决策,而不是让每个作业各自选择路径:
- 统计、检查、分类 — 打开句柄:
DAOpenFileReadOnly、DAGetPageCount、DACloseFile。 - 移动、解密或加密整文件 — 文件级调用:
DACopyFile、DecryptFile、EncryptFile。 - 重构页面或合并文档 — 完整加载:
LoadFromFile,然后InsertPagesFromDocument或MovePage,再SaveLoadedDocument。 - 给巨大文件或已签名文件增加一个小 delta — 使用
BeginIncrementalUpdate并保存。
混合管线应在完整加载路径之前设置大小阈值:把几百 MB 以上的内容路由到 Direct File 层,并把真正需要重构的作业排入有内存预算的 64 位 worker。这个阈值把 out-of-memory 崩溃变成了显式、可观察的路由决策。
无论哪个层级处理作业,都应先把输出写到临时名称,并在结果验证通过后才重命名到最终位置。对下一阶段管线而言,一个以最终名称存在的半写入文件和一个好文件无法区分。Direct File 调用让这种验证很便宜,因为确认输出本身就是一次单行句柄探测。
FAQ:Delphi 服务中的大型 PDF
如何在不加载整个文件的情况下获取 PDF 页数?
使用 DAOpenFileReadOnly 加 DAGetPageCount,如上面的检查示例所示;无论文件大小如何,内存使用都保持平坦。
为什么我的 PDF 每次保存后都会变大?
Incremental updates 按设计追加内容,绝不会移除任何东西。当累计 revision 不再需要时,定期用完整 load-and-resave 压缩,也就是 LoadFromFile 后接 SaveLoadedDocument。
Direct File API 能打开加密 PDF 吗?
它接受密码,但加密输入会在内部路由到完整解析,从而失去平坦内存优势。对受保护输入,使用带密码的 DecryptFile 生成普通副本,之后管线的其他部分就可以按文件级方式处理。
Direct File API 随面向 Delphi 和 C++Builder 的 HotPDF Component 提供;产品页链接完整函数参考,包括本文展示的 incremental-update 调用。