用最显而易见的方式去合并或拆分一份两 GB 的 PDF,会同时让你付出两样代价:墙上时钟时间和地址空间。所谓显而易见的方式,就是把每份输入加载进来,做完活,再写出输出。垮掉的正是加载这一步。一个扫描归档从 300 DPI 换到 600 DPI,线性分辨率翻倍,落到磁盘上大约是四倍,于是那个整年都在处理 400 MB 文件的装配作业,一旦某份输入跨过 GB 线就开始颠簸,而它当时往往只是在数页数。任务本身并没有变难。打开、计数、挑区间、拼接,全部内容就这些。只是在这个体量上,整树加载不再是一个理智的默认选项。losLab 面向 Delphi 与 C++Builder 的 PDF 库 PDF Library for Delphi 用它的直接访问层来回应这一点:一族以 DA 为前缀的函数,背后是一个流式读取器,就地遍历交叉引用表,而不是在内存里把整份文档搭起来
整树加载时内存都花到哪儿去了
「正常地」加载一份 PDF,意味着解析 xref、把每一个间接对象解析成内存中的树、解码对象流,再把页面树、字体和注释接成你可以操作的对象。对编辑类工作流来说这是划算的交换。对合并、拆分和检视类工作来说,它大半是浪费。一个三万页的扫描归档可能持有数以百万计的间接对象,而一次拆分作业只需要读其中几百个:所请求区间内的页面节点,加上这些节点所引用的东西
直接访问层把这个模型反了过来。DAOpenFile 和 DAOpenFileReadOnly 只解析文件尾部那几 KB 的 trailer 和 xref,然后返回一个文件句柄。对象则在某次调用需要时才被惰性取回。实际效果是,打开一份数 GB 的文件与打开一份小文件耗时相当,而内存跟随的是你实际触碰的部分,而不是文件所包含的全部
不加载就探查一份巨大的文件
下面这个模式取自库自带的大文件基准测试:以只读方式打开,提问,关闭。全程不存在任何文档树
var
Lib: TPDFlib;
Handle, Pages: Integer;
begin
Lib := TPDFlib.Create;
try
Handle := Lib.DAOpenFileReadOnly('archive-2025.pdf', '');
if Handle = 0 then
raise Exception.Create('Direct access open failed');
Pages := Lib.DAGetPageCount(Handle);
Writeln('pages : ', Pages);
Writeln('title : ', Lib.DAGetInformation(Handle, 'Title'));
Lib.DACloseFile(Handle);
finally
Lib.Free;
end;
end;
只要条件允许,只读模式都值得优先选用:它让接入阶段可以在其他进程占用该文件时照常运行,同时也把意图写进了代码。一个不小心调用了修改类函数的探查阶段会快速失败,而不是把归档弄坏
PageRef 是对象句柄,不是页码
使用 DA API 时最常见的一个错误,是在函数期望 PageRef 的位置传了页码。几乎每一个按页操作的 DA 调用接受的都是指向页面对象的引用句柄而非页码:DAExtractPageText、DARenderPageToFile、DARotatePage 和 DACapturePage 都期望一个 ref。你要通过 DAFindPage 把面向人的页码翻译过来才能拿到它:
PageRef := Lib.DAFindPage(Handle, 250); // 页码 -> 对象句柄
if PageRef <> 0 then
begin
Text := Lib.DAExtractPageText(Handle, PageRef, 0);
Lib.DARenderPageToFile(Handle, PageRef, 5, 150, 'page250.png');
end;
改传裸数字 250 并不会引发错误。它会去寻址恰好位于那个句柄值背后的任意对象,运气好的日子里明面上失败,运气差的日子里则把错误页面的文本提取进一份要交给客户的文档。如果你要在自己的服务代码里包一层 DA,请让这层翻译无从跳过:在边界处接收页码,立刻调用 DAFindPage,内部只传 ref
用具名列表合并上百个文件
只有两个文件时,MergeFiles(First, Second, Output) 就够了。批量装配则通过文件列表扩展得更好:把输入登记到一个列表名下,再一趟把整个列表合并掉
Lib.AddToFileList('Statements', 'jan.pdf');
Lib.AddToFileList('Statements', 'feb.pdf');
Lib.AddToFileList('Statements', 'mar.pdf');
Lib.MergeFileList('Statements', 'q1-statements.pdf');
// 用最省事的方式校验结果:再来一次直接访问
Handle := Lib.DAOpenFileReadOnly('q1-statements.pdf', '');
Writeln('merged pages: ', Lib.DAGetPageCount(Handle));
Lib.DACloseFile(Handle);
合并家族有三个变体,差别不只在速度上。MergeFileListFast 跳过结构树的保留;MergeFileListStrict 强制严格模式;无后缀的版本是取得平衡的默认选项。由此得出的运维规则是:只要有任何一份输入是必须保住无障碍结构的 Tagged PDF,任何为 PDF/UA 而生产的东西都是最明显的例子,就应当选默认变体或 Strict 变体,因为 Fast 会悄无声息地丢掉结构树。对于完全没有标签的普通扫描归档,Fast 是白捡的性能。请按流水线来定,而不是按开发者当天的心情,并把所用变体记进作业日志
不加载就拆分:按区间提取
拆分沿用同一套免加载哲学。ExtractFilePages(InputFileName, Password, OutputFileName, RangeList) 直接把页码区间从文件拉到文件,区间列表可以写成 '1-500'、'501-1000' 或用逗号分隔的选择,而源文件自始至终不会变成文档树。当某份文档已经因为别的原因被加载进来时,ExtractPageRanges 会从当前文档产出一份新的内存文档,而 CopyPageRanges 则按 ID 从另一份已加载文档中把区间取过来。对于把合并打印流按对账单逐份拆开的场景,文件到文件的那种形式,正是让一份 4 GB 输入永远不会膨胀进内存的关键
在几何结构上撒谎的文件
大文件流水线遇到损坏文件的频率,是小文件流水线从未见识过的,原因很简单:这些输入经过了更多系统。有两种失败形态值得显式处理
第一种是偏移的文件头。邮件网关和打印后台有时会在 PDF 前面加上一段字节,于是 %PDF 标记不再位于偏移 0,而文件中每一个 xref 偏移量都错了同样的数值。流式读取器会检测出这一点并把它暴露出来(扁平层是 DAShiftedHeader,TSmartPDFReader 上是 ShiftedHeader),随后在读取时予以补偿。自己手写的偏移算术通常不会这么做,这就是为什么「我们自己生成的每份文件都正常,来自客户 X 的文件就失败」是那个经典症状
第二种是损坏的交叉引用表。DACopyFile(InputFileName, OutputFileName, PageCount) 会把整份文件流式复制成一份新副本并同时重建 xref,页数作为副产品返回。把它作为归一化阶段放在挑剔的下游消费者之前运行,就能把一类时有时无的解析失败转化为一个可预期的修复步骤。而当你自己的编辑需要保存时,DAAppendFile 会以增量更新方式写入,追加一个新修订而不是重写数 GB 内容,从而让保存成本正比于改动量而不是文件体量
交付细节:线性化与合成
还有两项相邻能力让大文件流水线变得完整。当装配好的输出要经 HTTP 提供给浏览器内查看时,LinearizeFile 会为字节区间流式传输重新组织它,使得首页可以在一个 500 MB 包裹的其余部分下载完成之前就显示出来。请把它放在最后一个阶段运行,也就是所有合并之后,因为此后的任何修改都会让文件重新变回非线性。而当包裹需要的是合成而不是简单拼接时,比如在每份对账单背后压一张封面页,或者把两页源内容拼版到一张输出纸上,DACapturePage 会把任意一页变成可复用的模板,再由 DADrawCapturedPage 把它放到目标页的任意矩形区域内,而对那份数 GB 的源文件依旧无需整树加载
上限,以及哪些操作仍是只读的
比直接访问先到头的,是格式本身。DA 层全程使用 Int64 偏移量,因此真正的天花板是可用磁盘,以及经典(非流式)交叉引用表那个十位数的 xref 偏移字段。数 GB 的扫描归档在实践中平淡无奇,而无论文件多大内存都保持有界,因为对象只在某次调用索要时才被读入
有两个问题问得足够频繁,值得直接回答。走默认路径合并会把文档结构一并带过去,因此书签和链接都能存活;用结构树换速度的是 Fast 变体,这也正是要把它留给无标签输入的全部理由。稳妥的习惯是打开合并后的输出,遍历它的大纲,并在交付前抽查几条内部链接。至于编辑:在只读探查和整树加载之间还有一块有用的中间地带。页面级操作可以直接作用于句柄,其中包括 DARotatePage、DAMovePage 和 DAHidePage,还有表单字段的读取,而 DAAppendFile 会把这些编辑作为增量修订持久化。内容级编辑,也就是任何要重写页面内部标记操作符的动作,仍然属于整份文档那一层
相关文章
如果你合并后的输出必须保持无障碍,结构树的背景知识在Tagged PDF 无障碍一文中有讲解,那篇文章会准确说明 Fast 合并变体会丢弃掉什么。要从拆出来的区间中取出内容,参见文本、图像与字体提取指南
完整的直接访问函数清单随库一同发布;版本与试用下载见 PDF Library for Delphi 产品页