给一个 1.4 GB 的扫描档案统计页数,本来应该是件很便宜的事。但如果你对它调用 LoadFromFile,事情立刻就不便宜了:HotPDF 会解析交叉引用数据,为文档里几十万个间接对象逐个建立内存对象,而一个 32 位工作进程很可能在解析到一半时就撞上 2 GB 地址空间上限。可你真正想做的事,其实只是统计页数,根本不需要那整棵对象树,只需要页面树就够了。任务实际需求和完整加载所交付内容之间的这道落差,就是 Direct File API 存在的全部理由
Direct File API 让 Delphi 和 C++Builder 可以在文件层面访问 PDF,包括页数统计、整文件复制、解密、增量追加等,而且每一步都只从磁盘读取真正需要的数据,而不是把整份文档模型先在 RAM 里重建出来。真正的技巧在于,把每项任务匹配到能回答它的最轻量层级。匹配对了,服务面对任意输入大小都能维持平坦内存;匹配错了,第一个超大文件就会把 worker 直接拖垮
完整加载会让你付出什么代价
LoadFromFile 不是坏人。它消耗的内存是有价值的:一旦整棵对象树进入内存,你就能随机访问每一页和每一个对象,这正是 InsertPagesFromDocument、MovePage 以及通过 SaveLoadedDocument 重新序列化所必需的能力。真正要重组文档时没有捷径,你必须先把整份文档握在手里,才能重新排列它
麻烦出在输入大小不受你控制的时候。客户上传、扫描件输出以及十年前留下的历史档案,都不会理会你的测试样本是怎么假设的。如果你对所有输入一律完整加载,那么你的内存上限就会由任何人提交过的最大单个文件决定。解析时间会随对象数量上升,而常驻内存则会在对象结构和解码后流都算进去之后,稳定在文件大小的数倍,因此磁盘上一 GB 的文件,往往意味着内存里要常驻几 GB
把程序改成 64 位,只是抬高了地址空间上限,并没有让账单消失。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,就是那种平时不炸、等到某天客户扔来一个坏文件才会突然暴露的问题。第二,每一次成功打开都要在 finally 里配对 DACloseFile;句柄泄漏不会像崩溃那样立刻被发现,它会让服务慢慢腐烂,而这更糟。第三,必须弄清密码参数到底意味着什么。DAOpenFileReadOnly 虽然接受密码,但面对加密输入时,它会悄悄退回完整解析路径来读取页数,于是平坦内存的保证就消失了。先用 DecryptFile 把受保护文件转成明文,再让后续管线继续,整条路径才会继续保持便宜
同一个探针还可以充当分流闸门。现实里的文件经常被贴错标签、上传到一半就中断,甚至根本是别的格式改了个扩展名。用 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 加密文章里介绍的完整加载再重存解密流程的大文件版本。EncryptFile 则沿相反方向工作,在文件级复制过程中施加密码保护,并使用与内存路径相同的密钥类型和权限参数
追加更改,而不重写
第三个层级是增量更新,也就是 ISO 32000-1 §7.5.6 定义的 incremental update。原始字节会原封不动留在磁盘上,任何新增或修改的对象都会追加到它们后面,再写出一段新的交叉引用区,把自己链回原始内容。对一个 900 MB 的档案来说,如果只需要追加一页,那么写入成本就是这一页带来的增量,而不是整份文件重写一遍
// 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 必须指向原始文件,因为追加写出的交叉引用数据要回链到原文件中的字节偏移。第二,这个模型天生就是 append-only:每做一次增量保存,文件都会继续变大,永远不会缩小。如果一份文档每天都盖一次章,它就会无限膨胀,直到你周期性地重新完整加载并通过 SaveLoadedDocument 重写一次,把它压缩回当前状态。也正因为这种 append-only 特性,增量更新才成为修改已数字签名文档时唯一安全的办法,这个约束在数字签名和 PAdES 文章里有更完整的讨论,底层交叉引用机制则在对象流与增量更新文章里单独展开
增量保存还有一个很容易在评审中漏掉的陷阱。原始字节会继续留在文件里,对任何愿意检查的人来说都是可见的。一个看似“替换”页面的增量更新,并不会把旧页面删掉,它只是让当前修订版覆盖旧内容,而旧修订仍然完整可恢复。因此,增量更新绝不是移除敏感内容的正确工具。如果你真的要让历史彻底不可见,就必须走完整重序列化,也就是先 LoadFromFile,再 SaveLoadedDocument,只把当前状态重新写出,把埋在后面的旧修订统统留在过去
将处理层级与操作相匹配
选择逻辑短到可以直接记在脑子里,但更值得把它编码成流水线入口处的显式路由决策,而不是让每个任务自己临场发挥。你要做什么操作,就决定你该走哪个层级:
- 若是数页数、作端相验察、或是将之标划归其类等等那就是开了手把握件那路就是了:走
DAOpenFileReadOnly,DAGetPageCount, 以及那DACloseFile便行 - 遇着整身子完全个把文件干干迁移那大搬动或者将其开封启秘还有大布法锁等等的这派做法那就是不越该档界之分界守文件层次:上阵这
DACopyFile,DecryptFile或上阵那EncryptFile这等就行 - 遇要是重新改页子排组或者是将别大作拼组其内之此派行为那就是该唤之全部那整件搬之底招:走
LoadFromFile、随后才用那InsertPagesFromDocument也或是这用MovePage;最终那步去给SaveLoadedDocument就算成 - 给一已是这高大体身或者是上封着有大名落章封着大号大档上去补添了零敲碎的一小股差别更动的去召
BeginIncrementalUpdate后便加保存这般行止这就成
在混合型流水线里,最好在完整加载路径前面再加一道文件大小阈值。超过几百 MB 的输入,一律优先走 Direct File 层级;只有真正需要重组结构的任务,才送到 64 位 worker 上,用真实的内存预算执行完整加载。这道阈值会把一次 OOM 崩溃变成一个你看得见、也能继续调节的路由决策
无论哪一层接手任务,都应当先把结果写到临时文件名下,等验证通过后再原子替换到最终名称。一个写到一半的文件如果已经占用了最终文件名,对下游阶段来说看起来和好文件没有任何区别,而 Direct File 调用会让验证这一步变得非常便宜:确认输出是否合法,只需要再做一次句柄探测
Direct File API 是HotPDF Component面向 Delphi 和 C++Builder 提供的一部分,产品页也链接了完整函数参考,包括本文展示的增量更新调用