技术文章

Delphi 中的 PDF 增量更新:AppendToStream 指南

PDF 增量更新允许 Delphi 应用程序仅通过追加更改的对象来修改文档,从而使原始的每个字节保持原封不动。losLab PDF Library 通过 AppendToStream 实现了这一点,它仅写入 ISO 32000-1 §7.5.6 定义的增量部分,因此对 2 GB 文件进行单个书签编辑只需花费几千字节的输出,而不需要进行完整重写。相同的机制也是已签名文档可以更新而不使签名失效的原因

这解决的痛点是非常具体的。完整保存会重写整个文件:重新序列化每个对象,重新计算每个交叉引用偏移量,且输出与输入之间不存在字节级的关联。对于 40 KB 的发票,这没问题。但是对于一个 2 GB 且您仅修改了文档标题中拼写错误的扫描归档文件,重写两吉字节以更改二十字节是荒谬的 — 如果该文件带有数字签名,那么重写就会彻底破坏它

为什么保存 PDF 会破坏其数字签名?

PDF 数字签名并不签名文档的逻辑内容,它签名的是物理文件的字节范围。签名字典中的 /ByteRange 条目准确记录了加密摘要所覆盖的文件跨度。任何重新序列化这些字节的保存操作 — 甚至是生成语义完全相同文档的操作 — 都会改变摘要,每个验证器都会报告签名已损坏。这是有意设计的:签名证明的是签名者看到的字节,而不是某种抽象的文档模型

增量更新是 PDF 规范提供的逃生舱口。由于增量保存将新数据追加在原始 %%EOF 之后,且绝不触及已签名的字节范围,因此现有签名会继续针对其覆盖的字节进行验证。验证器随后将追加的更改单独分类 — 第二个签名、表单填充、批注 — 并决定它们是否是允许的修改。每一个多签名工作流都依赖于此:每个签名者都在上一个签名的基础上添加一个增量部分。如果您正在构建签名管道,配套文章 Delphi 中的 PAdES 签名和验证详细介绍了签名字节范围和增量部分是如何相互作用的

增量更新在 ISO 32000-1 §7.5.6 下是如何工作的

ISO 32000-1 §7.5.6 通过三条规则定义了该模型。首先,原始文件内容保持完全完整 — 一个字节也不移动。其次,被修改和新创建的对象被追加在最后一个 %%EOF 之后,每个对象都具有与以前相同的对象编号(更改后的对象只是获得了一个掩盖旧定义的新定义)。第三,追加了新的交叉引用部分和文件尾(trailer);文件尾的 /Prev 条目指向前一个交叉引用部分的字节偏移量,形成一个链,读者可以从最新到最旧遍历该链,以解析每个对象的最新定义

从这种结构中衍生出两个有用的属性。更新成本与更改的内容成比例,而不是文档大小 — 追加的成本是修改对象的大小加上微小的 xref/trailer 开销。并且文件成为了它自己的版本历史:每个先前的修订仍然物理存在,因此审计员可以在任何更早的 %%EOF 处截断文件,并精确地恢复当时存在的文档。对于必须证明文档在每次修改前的样子的合规工作流来说,这种内置的审计追踪通常是选择增量保存的决定性论据

使用 AppendToStream 写入增量更新

losLab PDF Library 通过 AppendToStream(AppendMode: Integer; OutStream: TStream): Integer 公开增量输出,成功时返回 1,失败时返回 0。AppendMode 参数选择落在目标流中的内容。模式 0 写入完整文件:原始源字节首先被复制到流中,然后追加增量部分。模式 1 仅写入增量部分本身 — 增量(delta) — 并完全跳过源字节。模式 2 首先写入通过 SetAppendInputFromString 注册的调用者提供的前缀,然后在上面追加更新部分

var
  Doc: TPDFlib;
  Delta: TMemoryStream;
begin
  Doc := TPDFlib.Create;
  try
    if Doc.LoadFromFile('contract.pdf', '') <= 0 then
      Exit;

    // 小幅编辑:这类更改不应
    // 触发对整个文件的重写
    Doc.SetInformation(3, 'Amended 2026-07-04');  // key 3 = /Subject

    Delta := TMemoryStream.Create;
    try
      // AppendMode = 1: 仅写入增量部分。
      // 原始字节 + 增量 = 一个完整、有效的 PDF。
      if Doc.AppendToStream(1, Delta) = 1 then
        Delta.SaveToFile('contract.delta.bin');
    finally
      Delta.Free;
    end;
  finally
    Doc.Free;
  end;
end;

对于系统设计而言,模式 1 是最有趣的。由于增量是自包含的,您可以独立于原始文件对其进行交付:将修订版本作为独立的 blob 存储在对象存储中,仅将增量复制到远程站点,或者通过将基本文件与其增量链连接起来重建任何修订版本。重建规则是纯粹的字节连接 — 先是原始文件,然后按顺序是每个增量 — 因为这正是 §7.5.6 对增量更新文件规定的布局

库如何在不复制原始文件的情况下计算 xref 偏移量?

增量部分内部的交叉引用条目必须包含绝对字节偏移量 — 即从完整文件开头测量的位置,而不是从增量开头测量的位置。这给模式 1 带来了一个难题:写入器绝不输出原始字节,然而它记录的每个偏移量都必须假装它们在那里。losLab PDF Library 通过一个内部流适配器 TPDFAppendSectionStream 解决了这个问题,该适配器为序列化器呈现一个虚拟坐标空间。该适配器以原始文件的字节长度作为其基准偏移量创建,将其位置和大小报告为该基准加上目前已追加的任何内容,并仅将新写入的字节转发到调用者的目标流中

其结果是,模式 1 绝不会生成源文档的副本 — 无论是在磁盘上还是在内存中。向幼稚的实现(将完整文件写入临时缓冲区,然后切掉尾部)会带有整个原始 PDF 的临时副本,对于吉字节级别的输入,这正是增量更新旨在避免的成本。这种偏移量虚拟化技术与该库其他地方使用的字节引用转移非常相似;关于 使用字节引用转移快速合并 PDF 的文章展示了将相同想法应用于合并文档,而 通过直接文件访问进行大型 PDF 合并与拆分指南则介绍了不适合放入内存的文件的相关 I/O 架构

使用 SaveToStream 流式传输完整保存

增量输出只是流式传输故事的一半,另一半是完整保存时发生的情况。losLab PDF Library 中的 SaveToStream 直接针对目标流驱动文档序列化器,而不是先将整个文档渲染成中间 AnsiString 然后在一次调用中写出该缓冲区。老旧的方法也行得通,但这意味着每次完整保存都会在内存中临时占用输出的第二个完整副本 — 这在 10 MB 时是无害的,但在 500 MB 时是痛苦的,而在 32 位进程上处理数吉字节输出时则是无法逾越的障碍。直接序列化使内存峰值追踪文档的对象结构,而不是其序列化后的长度

var
  Doc: TPDFlib;
  Output: TFileStream;
begin
  Doc := TPDFlib.Create;
  try
    if Doc.LoadFromFile('archive.pdf', '') <= 0 then
      Exit;

    // ... 证明需要完整重写的编辑 ...

    Output := TFileStream.Create('archive-rewritten.pdf', fmCreate);
    try
      if Doc.SaveToStream(Output) = 0 then
        Writeln('Save failed, error ', Doc.LastErrorCode);
    finally
      Output.Free;
    end;
  finally
    Doc.Free;
  end;
end;

共享模式的教训:当 AppendToFile 返回 0 时

这一领域的一个退化现象值得重提,因为该失败模式具有普适性。AppendToFile(FileName) 将增量更新直接追加到磁盘上现有的 PDF 文件中 — 这是就地审计追踪工作流的自然调用:加载文件、进行更改、追加到同一路径。在 v3.71.2 中,该确切序列开始返回 0。根本原因在加载器,而不在写入器:为了支持大型文档的按需读取,LoadFromFile 在文档对象的生命周期内保持源文件句柄打开,而该句柄是以 fmShareDenyWrite 打开的。当 AppendToFile 随后尝试重新打开同一个文件进行写入时,加载器自身的共享模式拒绝了它,API 在写入一个字节之前就失败了

修复方法将加载器的共享模式放宽为 fmShareDenyNone,这之所以安全,恰恰是因为增量追加的特性:它严格在文件末尾之后添加字节,绝不重写读取器长期保留的句柄所服务的文件区域。对于任何封装该库 — 或构建类似流式加载器 — 的人的普遍教训是,惰性的、持有句柄的读取器与同文件写入器之间存在冲突,您在打开时选择的共享模式是一个 API 契约,不是实现细节。如果 AppendToFile 在您的代码中返回了 0,请先检查您的进程中是否有其他事物仍以限制性共享模式持有目标文件

真实的成本:何时增量更新是错误的工具

增量更新牺牲了文件大小以换取写入效率,而这种交易并不总是划算的。每次修订都会追加其更改的对象,而取代的定义仍保留在文件中,因此被编辑数百次的文档会累积死对象和一条每个读者都必须遍历的漫长 /Prev chain 链。更糟糕的是,“被删除的”内容并没有消失:在第五版中删除的文本物理上仍然存在于第四版的字节中,任何截断文件的人都可以恢复它。因此,脱敏、清理或任何删除敏感内容的操作都需要完全重写 — 对脱敏内容进行增量保存不过是增加步骤的数据泄露罢了

当目标是压缩(挤出累积的增量和未使用的对象)、更改整个文档范围的属性(如加密 — 重新加密会触及每个字符串和流,因此关于该更改没有任何“增量”可言 —)或者生成一个编辑历史不随文件同行的干净交付成果时,完整保存也是正确的选择。一个合理的规则是:在文档处于活跃状态且在不断修改时(尤其是带有签名时),使用 AppendToStreamAppendToFile;在生命周期边界(当文档离开您的系统或必须扁平化其历史记录时),使用完整的 SaveToStream 重写

增量更新、虚拟偏移量 delta 输出和直接流式序列化都是适用于 Delphi、C# 和 VB.NET 的标准 losLab PDF Library 的一部分;产品页面列出了完整的保存和追加 API 界面,以及上面讨论的签名和大型文件功能