用同一份模板和数据把同一张发票生成两遍,然后 diff 两个 PDF。有两处不同:trailer /ID 数组里的十六进制串,和文档信息字典里的 ModDate 字符串——两者都取自时钟。在 losLab PDF Library 里,SetDeterministicDocumentID(1) 加固定的 ModDate 能让文件逐字节一致,前提是文档没有逐次运行的 XMP UUID 或附件
直觉的第一步是钉住日期再跑一遍。CreationDate 和 ModDate 现在一致了,哈希仍然不同——如今只差文件末尾附近 /ID [<…> <…>] 里的两个 32 位十六进制串。这个标识符是与元数据分开计算的,只要它还依赖保存时刻,以文件哈希为键的构建缓存就永远不命中,内容寻址存储也会为每次运行留一个 blob 而不是每份文档一个
trailer ID 数组是干什么的
trailer /ID 标识一个文件;它不是内容的校验和。ISO 32000-1 §14.4 把它定义为两个字节数组组成的数组。第一个是永久标识符,文档创建时分配,意在穿越之后每一次编辑。第二个是变更标识符,写入器每次修改文件都刷新它。文档管理系统比较这一对,判断两个文件是同一文档的不同修订还是两份无关文档。§7.5.5 的 Table 15 规定 trailer 带 /Encrypt 时该条目必填,否则可选;§14.4 还提到省略它会妨碍依赖文件身份的工作流
规范从没说这个值怎么算。它建议对当前时间、文件路径、文件大小、信息字典里的值之类做 MD5 摘要,时钟正是让结果唯一的那种配料。唯一性恰恰是你对身份标记的期望,也恰恰是毁掉可复现性的东西。默认情况下,losLab PDF Library 以最简单的形式遵循这个建议——对当前时间戳做 MD5——所以相隔一秒创建的两份文档,即便其余每个字节都一致,永久标识符也不同
打开可复现标识符
确定性模式是逐文档的开关,默认关闭,所以在你开口之前既有输出不会变。SetDeterministicDocumentID 接收 0 或 1,接受时返回 1,其他一律返回 0;GetDeterministicDocumentID 报告当前设置。SetDocumentIDSeed 提供显式种子字符串,种子就位后标识符就是该字符串的 MD5,写进数组的两个元素。保存之后 GetDocumentFileID 可以读回 /ID[0],记日志或写测试断言都很顺手
var
Lib: TPDFlib;
FileID: WideString;
begin
Lib := TPDFlib.Create;
try
Lib.SetDeterministicDocumentID(1); // 紧跟 Create 之后
Lib.SetDocumentIDSeed('invoice-4471-rev3');
Lib.SetInformation(7, 'D:20260101000000Z'); // CreationDate
Lib.SetInformation(8, 'D:20260101000000Z'); // ModDate
Lib.SetOrigin(1);
Lib.DrawText(100, 700, 'Invoice 4471');
Lib.SaveToFile('invoice.pdf');
FileID := Lib.GetDocumentFileID; // 每次运行都是同一个值
finally
Lib.Free;
end;
end;
标识符在保存时计算。文档构建晚期才打开开关也有效,改过的种子在下一次完整保存时进入文件:用种子 A 保存、再用种子 B 保存,两份输出携带不同标识符;换回种子 A,原值又回来了
不过,开关要在创建对象之后立刻打开。字体、图像、图形状态和其他页面资源会在页面的资源字典里获得名字,确定性模式开着时这些名字来自一个计数器。开关之前加进去的东西保留随机生成的名字——标识符依然稳定,但每次运行内容流里总有几个字节不同
没设种子时标识符为什么还会变?
因为信息字典是哈希的一部分。种子留空,或给 SetDocumentIDSeed 传空字符串清掉种子,losLab PDF Library 就从文件本身推导标识符:写出序列化体的同时对其跑 SHA-256,取摘要的前 16 字节填进 /ID 的两个元素。页面内容一变标识符就变,这正是要点。麻烦在于 CreationDate 和 ModDate 也住在那个体里,而调用者没设时保存例程会给 ModDate 盖当前时间
Lib.SetDeterministicDocumentID(1);
Lib.SetDocumentIDSeed(''); // 从保存的字节推导 /ID
// 体哈希覆盖 Info 字典,所以日期必须是构建输入。
// 键 8 还能阻止保存时盖 ModDate 时间戳。
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');
键 8 在这里身兼两职,而这正是人们最常漏掉的一行。经 SetInformation 设置 ModDate 会把日期标记为调用方提供,从此 SaveToFile 和 SaveToString 都不再碰它。缺了这一行,每次保存都写新时间戳,被哈希的体随之改变,内容推导的标识符跟着变。日期从源记录或固定纪元取。Now 在可复现构建里没有立足之地
有几样东西刻意留在哈希之外。随机生成的资源名和 XMP xmpMM:InstanceID 条目里的 UUID 在哈希前被归一化,所以只有这些令牌不同的两次运行得到同一个 /ID。文件字节在那些位置仍然不同,所以每次运行都带新实例 UUID 的 PDF/A 风格文档给你可复现的标识符,但不是逐字节一致的文件。内嵌附件是另一个漂移源:AddAssociatedFileFromString 会给附件自己的 /ModDate 盖当前时间,这个日期像任何其他体字节一样被哈希
显式种子还是内容哈希
对构建产物,我们建议种子留空。内容推导的标识符恰在字节变化时变化,以它为键的缓存绝不会端出过期文件,你也不用再维护一把与数据同步的钥匙。显式种子适合另一类工作:下游归档把 /ID[0] 当文档身份,期望同一张发票每次重新生成时标识符不变——哪怕模板改了。这更贴合 §14.4 的本意,代价是两份肉眼不同的文件可以共享一个标识符
内容路径有一个后果值得专门拿主意。加载既有 PDF 并在确定性模式下完整保存时,进来的 /ID 被丢弃,新的只取决于写出的内容。只在 trailer 标识符上有差别的两份源文件,保存出来带着同一个 /ID。这正是归一化构建步骤想要的。如果你在编辑客户的文档、而它的身份必须存活,这份作业请关掉确定性模式,或者做增量保存
为什么重写 ID 会弄坏加密 PDF?
因为在加密文档里 /ID[0] 是密钥材料。ISO 32000-1 §7.6.3.3 算法 2 在修订 2 到 4 的标准安全处理器中,把文件标识符的第一个元素连同填充后的密码、/O 值和权限位一起喂进加密密钥计算。这把密钥随后生成阅读器打开时核对的 /U 字符串。密钥在你调 Encrypt 或加载加密文件时、任何保存之前就已推导,所以保存期间换掉标识符会产出一个结构完好、但谁都对不上密码的文件——包括你自己
因此 losLab PDF Library 只在未加密的完整保存上刷新确定性标识符。加密文档保留它已有的 /ID,开关在那条路径上没有效果。用用户密码重新打开这类文件照常工作。加密输出也别放进字节比较:§7.6.2 下的 AES 加密给每条加密字符串和流都以随机 16 字节初始化向量开头,同一文档加密两次永远对不上字节。需要回归检查就先把两者解密再比较。修订处理和权限位在 PDF 加密与权限审计 的走查里有更深入的展开
增量保存保留原标识符
增量更新让文件此前的每个字节原地不动,在其后追加新修订。/ID[0] 的恒定性正是消费者判断"追加的修订属于同一份文档"的依据。重写它就与文件里已有的修订自相矛盾,也会动摇签名工作流,因为签名覆盖的是某一具体修订的字节范围。确定性模式下追加模式从不碰标识符,PDF 增量更新与追加到流 一文描述的行为因此原样保持
让输出长期保持可复现
之后对生成器的每一次改动都是把时钟值塞回文件的机会:打印生成时间的水印、挂附件的代码路径、换一个保存选项。保存选项与内容同样要紧:把对象打包进压缩对象流,产出的文件布局与经典交叉引用表完全不同,见 对象流与交叉引用流 的说明,所以每条管线把这些设置钉死。最便宜的防线是一个在内存里构建同一文档两遍、逐字节比较的测试
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;
// 两次独立构建必须产出同一个字节序列
if BuildQuote('quote-8812') = BuildQuote('quote-8812') then
WriteLn('reproducible')
else
WriteLn('nondeterminism leaked into the output');
同一套装置还能比较同一来源的串行与并行 RecompressStreams 运行:把源加载进两个 TPDFlib 实例,两边都开确定性 ID、钉住 ModDate,一边设 SetOptimizationWorkers(1)、另一边设更高的 worker 数,然后 diff 两个 SaveToString 结果。时钟从两个文件里都拿掉之后,剩下的差异只能来自重压缩本身
可复现的 trailer 标识符、元数据控制和增量保存都是 losLab PDF Library for Delphi and C++Builder 的一部分,参考文档也列出了这里提到的加密选项