当 ReproducibleOutput 属性为 True 时,HotPDF Delphi Component 会在多次保存之间产出字节一致的 PDF 输出:它把 Info 的 /CreationDate 和 /ModDate 钉到一个固定日期,用带种子的或由内容派生的哈希替换墙上时钟产生的文档标识符,把 AES 加密路径本来要抽的每一个随机字节替换成常量,并对它序列化的每一个字典排序。这个开关是为回归套件和构建产物比较而存在的,不是为生产文档,而划出这条边界的原因正是有意思的部分。推动这个功能的场景是一次 golden file 测试。你渲染一张发票,把 PDF 提交上去,然后断言明天的构建产出同样的字节。它从来不会。文件在每个查看器里都能正常打开,文本一样,页面树一样,而差异照样在四五个地方亮起来。任何试过把 PDF 生成器放进字节级回归测试的人都撞过这堵墙,而修法不是「把时间戳剥掉」,而是精确清点写入器每一处去查了文档本身以外的东西
为什么同一份 PDF 保存两次会不一样?
同一份文档保存两次会不一样,是因为一个 PDF 写入器——HotPDF 也不例外——会去查四种与页面内容毫无关系的信息熵来源:墙上时钟、文档标识符、密码学随机数发生器,以及字典条目在内存里的顺序。每一个单独看都是正当的。ISO 32000-1 正要求它们在。它们只是把文件变成了「什么时候、在哪里写的」的函数,而不是「里面装了什么」的函数
- 时钟。 Info 字典带着
/CreationDate和/ModDate(ISO 32000-1 §14.3.3、Table 317),形式是带时区后缀的D:YYYYMMDDHHmmSS字符串(§7.9.4),而 XMP 包把同一个时刻重复为xmp:CreateDate和xmp:ModifyDate。HotPDF 从FCreationDate给这两处打戳,而构造函数把它初始化成Now,于是两次保存差的正是它们各自被写下的那一秒 - 标识符。 trailer 的
/ID数组(ISO 32000-1 §14.4)装着一个永久标识符和一个修改标识符。HotPDF 的默认配方对第一个元素把文件名与当前时间(精确到毫秒)一起哈希,第二个元素再把这个结果加上GetTickCount哈希一遍。两个标识符,每一轮运行两个全新值 - 随机字节。 标准安全机制依赖标识符,也依赖真正的随机性。对 AES-256,文件加密密钥、验证用与密钥用 salt,以及每一个 CBC 初始化向量都取自系统随机源(ISO 32000-2 §7.6.4.4.7 要求随机 salt)。因为
/U、/UE、/O和/OE都由这些字节算出,一份加密文档即使明文没变也会整体改变。更早的算法把/ID的第一个元素折进密钥(ISO 32000-1 §7.6.3.3、§7.6.3.4),所以光是一个全新的标识符就足以把文件重新加一次密 - 顺序。 一个 PDF 字典是无序映射,而按内存列表遍历的写入器会以插入顺序发出键。任何以不同次序构建资源字典的代码路径,或者一份按不同布局解析出来的已加载文档,产出的都是一个合法但文本上不同的文件
ReproducibleOutput 钉住了什么?
在 BeginDoc 之前或在 SaveLoadedDocument 之前设置 ReproducibleOutput := True,会把四个来源各替换成一个固定值,而且是在本来会去取时钟或随机发生器的同一些代码路径里做的,所以不需要单独一趟清理。注意上面那张清单里缺了什么:内容。对同样的输入,字体、页面流、图像数据和交叉引用表本来就是确定的;噪音完全住在元数据和安全层里,这就是一个有针对性的属性就能把它清掉的原因。这个属性默认是 False,库里没有任何东西会替你打开它
var
Pdf: THotPDF;
begin
Pdf := THotPDF.Create(nil);
try
Pdf.AutoLaunch := False;
Pdf.FileName := 'golden-invoice.pdf';
Pdf.ReproducibleOutput := True; // 在 BeginDoc 之前
Pdf.BeginDoc;
Pdf.CurrentPage.SetFont('Arial', [], 12);
Pdf.CurrentPage.TextOut(40, 40, 0, 'Invoice 2026-0042');
Pdf.EndDoc;
finally
Pdf.Free;
end;
end;
在 BeginDoc 内部,可复现分支赋值 FCreationDate := EncodeDate(2026, 1, 1),并用 MD5CalcString('HotPDF-reproducible-seed') 给文档标识符做种子,而不是用文件名加时钟的摘要。这一次赋值就覆盖了两个 Info 日期和两个 XMP 日期,因为四处都是从同一个字段渲染出来的。当文件最终被写出时,BuildDocumentIdentifiers 向 ComputeCanonicalDocumentIdentifier 要 trailer 标识符:它按规范顺序导出整个对象图,把它找到的任何 D: 日期字符串里的数字清零,好让时间戳不可能通过哈希再漏回来,然后对结果取 MD5。/ID 的两个元素都拿到这个值。当一份已加载文档在从未经过 BeginDoc 的情况下被加密时——也就是对你用 LoadFromFile 打开的文件调用 ActivateProtection 那种情形——用的也是同一个由内容派生的标识符
随机字节是最不显眼的一处替换。AES-256 的密钥例程把它的随机源包在一个本地助手里,这个助手在该开关打开时对 32 字节的文件加密密钥和每个 8 字节 salt 调用 FillChar(P^, Count, $5A),而 AES-128 与 AES-256 的字符串和流加密器从 AESGenerateRandomIV 切到 AESGenerateStaticIV,后者把第 I 个槽的初始化向量填成 14 * (1 + I)。密钥、salt 和向量全部固定之后,/U、/UE、/O、/OE 以及每一条加密流在第二轮运行就会产出完全相同的结果。最后,只要可复现标志被置位,SaveToStream 就会打开 DeterministicDictionaryOrder,序列化器随后按键名的原始字节对每个字典做插入排序,更短的前缀在前,原始索引作为并列时的次序依据。这与诊断写入器用的是同一套顺序,写在手工编辑 PDF 再修复它那篇里;可复现标志只借了这套顺序,没有借那个写入器其余的纯文本布局
为什么固定了日期还是会漏出墙上时钟?
v2.752.2 的修法之所以存在,是因为固定创建日期最初是在构造函数里决定的,而构造函数不可能知道调用方还没设置的属性。正常的调用顺序是先 Create,再 ReproducibleOutput := True,然后 BeginDoc。构造时 FReproducibleOutput 还是 False,所以 FCreationDate 拿到的是 Now 并一直留着。标识符和随机字节被正确钉住了,所以两份文件几乎处处一致,只在正好两个日期字符串和两个 XMP 字段上不一致。把赋值挪进 BeginDoc 的可复现分支、放在带种子的标识符旁边,就把这个决定放到了属性已经取到最终值的地方
漏掉这一点的那个回归测试比修法本身更有价值。两次都在同一个墙上时钟秒内跑的保存会碰巧写下同一个 D: 字符串,于是字节比较对一个在任何更慢的机器上都会失败的 bug 报了通过。修好后的测试在两次保存之间 sleep 1100 ms,好让 PDF 时间戳保证跨过一个秒边界,对明文、AES-128 和 AES-256 输出各跑一遍,两个加密变体用真实密码,并用 CompareMem 比较两个缓冲区,失败时报告第一个不同的偏移量,让差异指向某个具体对象而不是整份文件。字节比较只能证明确定性、别的什么都证明不了,所以请另外保留一条断言:用用户密码重新加载加密输出并读一个页数;一个把文件同时变得稳定又不可读的改动,绝不能靠一个绿灯差异蒙混过去
function SaveOnce(const Target: string): TBytes;
var
Pdf: THotPDF;
Stream: TFileStream;
begin
Pdf := THotPDF.Create(nil);
try
Pdf.AutoLaunch := False;
Pdf.FileName := Target;
Pdf.ReproducibleOutput := True;
Pdf.OwnerPassword := 'owner';
Pdf.UserPassword := 'user';
Pdf.CryptKeyLength := aes256;
Pdf.ActivateProtection := True;
Pdf.BeginDoc;
Pdf.CurrentPage.SetFont('Arial', [], 12);
Pdf.CurrentPage.TextOut(40, 40, 0, 'reproducible save');
Pdf.EndDoc;
finally
Pdf.Free;
end;
Stream := TFileStream.Create(Target, fmOpenRead or fmShareDenyWrite);
try
SetLength(Result, Stream.Size);
if Stream.Size > 0 then
Stream.ReadBuffer(Result[0], Stream.Size);
finally
Stream.Free;
end;
end;
// 在测试主体里
A := SaveOnce(PathA);
TThread.Sleep(1100); // 逼出一个不同的 PDF 时间戳秒
B := SaveOnce(PathB);
Assert.AreEqual<Integer>(Length(A), Length(B));
Assert.IsTrue(CompareMem(@A[0], @B[0], Length(A)),
'two saves under ReproducibleOutput must be byte-identical');
可复现的加密 PDF 还安全吗?
不安全。一份在 ReproducibleOutput 下加密的文档在任何有意义的意义上都不受保护,任何要离开测试目录的东西都必须把这个标志关掉。AES-256 的文件加密密钥是三十二个 $5A 字节,salt 是八个 $5A 字节,初始化向量遵循一个公开的算术模式。密码仍然守着 /UE 和 /OE 这两个包装,但被包起来的密钥是常量,所以任何知道这个常量的人根本不需要密码就能解开每一条内容流。salt 固定还去掉了 ISO 32000-2 §7.6.4.4.7 所依赖的逐文档唯一性,那条唯一性用来保证相同密码在不同文件中不会产出相同的 /U 字符串。想了解随机源完好时那些加密属性承诺了什么,请读AES-256 设置那篇;在可复现标志下,那些承诺是被暂停的
标识符这一处的取舍更微妙。ISO 32000-1 §14.4 的本意是让 /ID 的第二个元素在每次修改时都变,好让工具能把一个更新过的文件与它的祖先区分开,而一次可复现保存往两个槽里写的都是同一个值。因为这个值是规范对象图的哈希,两份内容不同的文档仍然会拿到不同的标识符,这比一个常量要好。但 BeginDoc 用来派生密钥的那个种子在每台机器上的每一份文档里都是同一个字符串,而一个以 /ID 为键来区分文件的读取方——比如注释缓存或表单数据附属文件——会把每一份恰好哈希相同的可复现文件混为一谈
这个标志不覆盖什么?
ReproducibleOutput 去掉的是写入器自己引入的熵;它去不掉从环境进入的熵,也去不掉它不控制的代码路径带来的熵,而其中三种很容易踩到
- 时区后缀。
_DateTimeToPdfDate会附上本地 UTC 偏移,所以一台构建机上的D:20260101000000+08'00'和另一台上的D:20260101000000-05'00'对同一个固定日期就是不同的字节。可复现性在同一台机器的多次运行之间成立,或者在共享同一时区的机器之间成立;如果你的 golden file 要跨机器流动,请把构建机的时区钉住 - 增量更新。
SaveIncrementalUpdate从目标路径、GetTickCount和当前时间算它的修改标识符,没有可复现分支,因为一个增量段按定义就是一次新的修改。请比较完整重写,不要比较追加的增量 - 直通捷径。
SaveLoadedDocument通常会把一份未修改、未加密的源文件逐字节拷过去,而不是重新序列化。可复现标志会关掉那条捷径并强制一次完整重写,好让顺序规则和标识符规则生效,这意味着对已加载文件做可复现保存比默认慢,而且它绝不会是输入的副本。请把它与上一次可复现保存比较,永远不要与原始文件比较
同一个版本还留下一条关于「通过的检查能证明什么、不能证明什么」的教训。一个 PDF/X-6 测试夹具调用 CharProcs.DeleteValue('A'),释放了一个被直接持有的字形流,然后又把这个指针插回去,另外还把同一个直接的 ExtGState 对象同时交给一个资源字典和一个 pattern。一致性校验器在那个 use-after-free 和双重所有权上时通时不通,因为它读到的是被释放的内存碰巧装着的内容。当一个结构检查闪烁不定时,先去看测试输入的归属关系,再去看校验器。可复现输出让这套纪律更便宜:一旦两次保存字节一致,闪烁的唯一剩余来源就是对象图本身,而从 catalog 往下做的结构性 diff会把它找出来
本文描述的 ReproducibleOutput、DeterministicDictionaryOrder 和加密属性,都随标准版 HotPDF Delphi Component 一起发布,面向 Delphi 和 C++Builder,而同一个标志也驱动着库自己的回归语料,所以你在测试套件里得到的行为,就是组件被测时的行为