技术文章

Delphi 确定性 PDF ID 实现可复现构建

只要调用 SetDeterministicDocumentID(1),losLab PDF Library 就能对相同输入产出逐字节一致的 PDF 输出。默认情况下,trailer 的 /ID 数组是墙钟时间的 MD5 摘要,所以同一个生成器跑两次,至少这几个字节会不一样。确定性模式改为从一个稳定种子推导 /ID,从而恢复可复现构建

这个症状通常先在 CI 里冒出来,等到有人真去查才发现。模板没变,输入记录没变,字体也没变,可生成的 PDF 每次跑流水线的哈希值仍然不一样。构建缓存永远命中不了。内容寻址存储每次夜间构建都会多出一份新 blob。字节级回归 diff 会在没人碰过的文件上亮起来。把 diff 一路追到实际字节,几乎总是文件 trailer 里那几个十六进制数字在作怪

trailer ID 数组是做什么用的

trailer 的 /ID 是文件身份标记,不是内容的校验和。ISO 32000-1 §14.4 把它定义为一个含两个字节串的数组:第一个元素是文档创建时分配的永久标识符,理应在后续每一次编辑中都保持不变;第二个元素是变更标识符,写入器每次修改文件时都会刷新它。两者合在一起,让系统能判断两个文件到底是同一份文档的不同版本,还是两份互不相关的文档。§7.5.5 在实践中让这个条目几近强制,因为只要 trailer 带有 /Encrypt,就必须同时带有 /ID

规范里没有规定具体怎么计算这个值。建议做法是对当前时间、文件路径、文件大小和文档信息字典这类东西求摘要,而墙钟时间正是让结果具备唯一性的那个成分。这恰恰是身份标识所需要的属性,也恰恰是破坏可复现性的那个属性,所以这必须做成一个显式开关,而不是一个悄悄发生的行为变化

为什么同一次构建每次都产出不同的 PDF

因为默认标识符是从生成那一刻推导出来的。历史上 losLab PDF Library 用当前时间戳的 MD5 来构造 /ID 字符串,所以即便文件里其他每个字节都完全一致,前后相隔一秒生成两次的文档也会带有两个不同的永久标识符。下游代价是实实在在的:按哈希给制品建索引的构建系统永远无法复用某个 PDF 生成步骤,做去重的对象存储会为每次构建保留一份副本,而不是每份文档保留一份;审阅二进制 diff 的人在信任 diff 其余部分之前,还得先证明唯一的差异就是这些噪声。确定性 /ID 生成就是为了消除这种噪声,思路上和对象流与交叉引用流笔记里讨论的排版稳定性工作是一脉相承的

切换到可复现标识符

确定性模式是按文档粒度的选择性开启,默认关闭,所以在你主动要求之前,现有输出不会有任何变化。SetDeterministicDocumentID 接受 0 或 1,值被接受时返回 1,超出范围时返回 0;GetDeterministicDocumentID 用来查询当前状态。SetDocumentIDSeed 提供一个显式种子字符串,它的优先级压过其他一切来源,传入空种子则会恢复为推导出的种子。GetDocumentFileID 会在保存之后读回 /ID[0],方便你记录日志或写断言

var
  Lib: TPDFlib;
  FileID: WideString;
begin
  Lib := TPDFlib.Create;
  try
    Lib.SetDeterministicDocumentID(1);
    Lib.SetDocumentIDSeed('invoice-4471-rev3');
    Lib.SetOrigin(1);
    Lib.DrawText(100, 700, 'Invoice 4471');
    Lib.SaveToFile('invoice.pdf');
    FileID := Lib.GetDocumentFileID;   // identical on every run
  finally
    Lib.Free;
  end;
end;

刷新动作发生在保存时,而不是翻转开关的那一刻,所以在文档构建过程的后期才启用确定性模式依然有效。这也意味着改变种子只会在下一次完整保存时体现到文件里:设置种子 A、保存,设置种子 B、保存,这两个文件会带有不同的标识符,而恢复种子 A 就能恢复原来的值。只要文档天然有一个稳定的键——比如发票号、记录修订号或 git 提交标识符——用显式种子就是正确选择,因为它把标识符和无关紧要的元数据解耦开了

不提供种子时,种子从哪里来

如果没有显式种子,losLab PDF Library 会从理论上在相同重生成过程中保持不变的文档状态推导出一个种子:PDF 版本头、页数,以及文档信息字典里的每一个条目。字符串和名称值按原样取用,其他对象类型贡献它们的序列化形式,整个结果再被哈希进 /ID 字符串。这里的重要后果是,CreationDateModDate 本就属于信息字典的一部分,因此按设计也是种子的一部分。只有两次运行真正产出相同的文档元数据时,才会得到相同的标识符

Lib.SetDeterministicDocumentID(1);
// No SetDocumentIDSeed: the seed is derived from document state,
// so the timestamps in the Info dictionary have to be pinned.
Lib.SetInformation(2, 'Quarterly Report');        // Title
Lib.SetInformation(5, 'reporting-service 4.2');   // Creator
Lib.SetInformation(7, 'D:20260101000000Z');       // CreationDate
Lib.SetInformation(8, 'D:20260101000000Z');       // ModDate
Lib.SaveToFile('report.pdf');

用 key 8 固定 ModDate 起到了双重作用,而这正是容易让人踩坑的地方。光有确定性 /ID 并不能让文件逐字节一致,因为除非调用方显式设置过,否则保存路径会用当前时间给 ModDate 打戳。设置 key 8 会把这个值标记为调用方提供,从而抑制这次打戳。如果你想要的不只是可复现的标识符,而是真正可复现的文件,那就把元数据时间戳当作构建输入来对待:从源记录或固定纪元时间推导它们,绝不要用 Now

为什么重写 ID 会破坏加密 PDF

因为在加密文档里,/ID[0] 不只是元数据,它是密钥材料。ISO 32000-1 §7.6.3.3 算法 2 会把文件标识符的第一个元素,连同经过填充的密码、/O 值和权限位一起,输入到标准安全处理器在版本 2 到 4 下的加密密钥计算过程中。推导出的密钥随后生成 /U 校验字符串,读取器打开文件时会检查它;文件密钥的推导和缓存发生在你调用 Encrypt 或加载一份加密文档时,这两个时机都在保存之前。因此,如果在保存时重写标识符,就会产出一个结构上有效、但重新打开时 /U 校验会失败的文件:这不是什么细微损坏,而是一份谁都打不开的文档,包括你自己。这就是为什么确定性刷新被限制在不携带加密状态的文档上,也是为什么加密文档不管确定性模式开没开,都会保留它原来的 /ID,这个设置在那条路径上根本不起作用。相关的版本处理和权限语义在PDF 加密与权限审计的讲解中有覆盖。另外要注意,加密恢复路径只刷新 /ID[1],也就是变更标识符,正是 §14.4 想要的效果

为什么增量保存会保留原始标识符

第二个边界情形是追加模式。增量更新不动文件里更早的任何一个字节,只在后面写入新的一个版本,而 /ID[0] 在整个 §14.4 语境下的永久性,正是让消费方判断新版本和旧版本属于同一份文档的依据。重写它会切断这条联系,与文件里已有的历史版本相矛盾,还会干扰签名语义,因为签名覆盖的是某份文档某个具体版本的一段字节范围。因此 losLab PDF Library 只在完整保存时刷新确定性标识符,追加模式下绝不刷新,这样就保住了PDF 增量更新与追加到流一文里所描述的那条保证

标识符生成收拢到一个单点

losLab PDF Library 现在所有的 /ID 生成都汇聚到一个内部例程 NewFileIDString,正是这一点让确定性开关值得信赖,而不只是打在某一条代码路径上的补丁。空白文档创建、按需惰性创建缺失的 /ID 数组、以及加密指纹恢复路径,全都调用它,所以墙钟时间只有这一个地方可能悄悄漏回来。这也意味着未来的变体——比如基于内容推导的标识符——只需要改一个函数,而不必审计整个序列化器

function BuildQuote(const Seed: WideString): AnsiString;
var
  Lib: TPDFlib;
begin
  Lib := TPDFlib.Create;
  try
    Lib.SetDeterministicDocumentID(1);
    Lib.SetDocumentIDSeed(Seed);
    Lib.SetInformation(7, 'D:20260101000000Z');
    Lib.SetInformation(8, 'D:20260101000000Z');
    Lib.SetOrigin(1);
    Lib.DrawText(100, 700, 'Quote 8812');
    Result := Lib.SaveToString;
  finally
    Lib.Free;
  end;
end;

// Regression guard: two independent builds, one byte sequence.
if BuildQuote('quote-8812') = BuildQuote('quote-8812') then
  WriteLn('reproducible')
else
  WriteLn('nondeterminism leaked into the output');

在依赖任何地方的可复现输出之前,先把这个比较接入你的测试套件,因为一旦某个新功能重新引入了时间戳,它就会立刻大声报错。可复现性是一种会悄无声息衰减的属性,而针对两次内存中保存结果的一个断言,跑在每次构建上几乎不花什么成本

这里展示的确定性标识符 API,随 losLab PDF Library 一起为 Delphi 和 C++Builder 提供,还附带完整的文档信息、加密与增量保存参考