同一份发票代码间隔一秒写出的两份 PDF 几乎从来不是同一个文件。二进制比较首先在 Info 字典里绊倒——/CreationDate 挪了一秒——然后是 XMP 包,然后是 trailer 的 /ID。打开 AES-256,比较几乎在每条流上崩掉,因为文件加密密钥本身就是新抽的。比较前把日期掩掉,能修掉前两处差异,其余一处也修不了。只有当写入器从文档之外取的每一个值都在被读取的位置被钉死时,golden file 测试才值得信
四种从不出自文档本身的值
HotPDF Delphi Component 和任何守规范的 PDF 写入器一样,从页面内容之外填四类值:时钟、文档标识符、密码学随机源和字典条目在内存里的顺序。ISO 32000-1 给每一个都安排了理由
- 时钟。 Info 字典带着
/CreationDate和/ModDate(ISO 32000-1 §14.3.3、Table 317),形式是带时区后缀的D:YYYYMMDDHHmmSS字符串(§7.9.4),XMP 包把同一个时刻重复为xmp:CreateDate和xmp:ModifyDate。它们全部由组件构造时从Now取来的那个创建时间戳渲染而来 - 标识符。 trailer 的
/ID数组(§14.4)装着一个永久标识符和一个修改标识符。默认情况下,第一个是文件名加当前时间(精确到毫秒)的 MD5,第二个再把那个值连同系统 tick 计数哈希一遍 - 随机字节。 AES-256 文档需要 32 字节的文件加密密钥、
/U用的 8 字节验证 salt 和 8 字节密钥 salt、/O的另一对,/Perms里的四个填充字节,以及每条加密字符串和流前面的 16 字节初始化向量。ISO 32000-2 §7.6.4.4.7 要求这些 salt 是随机的。RC4 和 AES-128 从密码和/ID第一个元素派生密钥(ISO 32000-1 §7.6.3.3),所以光是一个新标识符就足以给整个文件重新配钥 - 顺序。 PDF 字典是无序映射(§7.3.7)。按内存列表遍历的写入器按键被代码加进来的先后发出,所以以不同次序构建同一份资源字典的两条代码路径,产出的是合法但文本不同的文件
其余一切都已由输入决定:同样的绘图调用,内容流、压缩图像数据和交叉引用偏移都会重复。连那个看着随机的字段也不随机:嵌入字体子集的六字母前缀(ISO 32000-1 §9.6.4,ABCDEF+Arial 里 ABCDEF+ 那截)由子集保留的字形集合算出,所以同样的文本每轮运行给出同样的前缀。噪音住在元数据和安全层里,这正是单个属性就能清掉它的原因
ReproducibleOutput 钉住了什么
在 BeginDoc 之前设置 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 把创建时间戳设为 2026 年 1 月 1 日 00:00 本地时间;由于 Info 日期和 XMP 日期都从这一个值渲染,一次赋值就全覆盖了。同一个分支改用常量字符串给内部文档标识符做种子,不再用文件名加时钟。写文件时,trailer /ID 的计算方式是对整个对象图的一次规范导出取 MD5,导出前先把每个 D: 日期字符串里的数字清零,所以时间戳没法从哈希里溜回来。/ID 的两个元素都拿到这个值。加载的文档不经 BeginDoc 就加密的(在 LoadFromFile 之后设 ActivateProtection),拿到同样的由内容派生的标识符
随机字节换成常量。AES-256 文件密钥、全部四个 salt 和 /Perms 填充字节都用 $5A 填满,而每一条 CBC 初始化向量——AES-128 和 AES-256 的字符串与流都算——在字节 I 处变成模式 14 * (1 + I)。这个模式故意非零。全零 IV 是 CBC 教科书级的弱选择,回归套件犯不着去把它合法化。密钥、salt 和向量都固定之后,第二轮运行里 /U、/UE、/O、/OE、/Perms 和每条加密流都原样复现
最后,只要可复现标志置位,SaveToStream 就打开 DeterministicDictionaryOrder,而且是在它挑选线性化、对象流还是经典布局之前就打开,所以 LinearizeOutput 或 UseObjectStreams 的输出也照排序。按键的原始字节排序,一个是另一个前缀的键排在前,并列时按原始位置。这正是诊断写入器用的那套顺序,见手工编辑 PDF 再修复它那篇;可复现标志只借这套顺序,不借那个写入器的纯文本布局。单开 DeterministicDictionaryOrder 是半个办法:键是对齐了,Info 日期和两个 trailer /ID 字符串还带着时钟,两次保存照样在同样的四处不同
一个靠运气过不了的测试
日期在 BeginDoc 里固定,因为构造函数跑在调用方还没机会设属性之前。正常顺序是 Create,然后 ReproducibleOutput := True,然后 BeginDoc,三者中只有最后一个看到的标志才是最终值。这个顺序对测试设计有个直接推论:落在同一个墙上时钟秒内的两次保存会碰巧写下同一个 D: 字符串,于是跑得快的测试不管日期钉没钉都会通过,几个月后在更慢的构建机上再失败
修法很便宜:两次保存之间等 1100 ms,保证时间戳跨过一个秒边界;明文、AES-128、AES-256 各跑一遍,加密变体用真实密码;报告第一个不同的字节偏移,而不是一句干巴巴的真或假。偏移指向一个对象;布尔值指向整份文件
uses
System.SysUtils, System.IOUtils, HPDFDoc;
function SaveOnce(const Target: string; Encrypt: Boolean;
KeyType: THPDFKeyType): TBytes;
var
Pdf: THotPDF;
begin
Pdf := THotPDF.Create(nil);
try
Pdf.AutoLaunch := False;
Pdf.FileName := Target;
Pdf.ReproducibleOutput := True;
Pdf.VerifySavedOutput := True; // EndDoc 之后立刻严格重载
if Encrypt then
begin
Pdf.OwnerPassword := 'owner';
Pdf.UserPassword := 'user';
Pdf.CryptKeyLength := KeyType; // aes128 或 aes256
Pdf.ActivateProtection := True;
end;
Pdf.BeginDoc;
Pdf.CurrentPage.SetFont('Arial', [], 12);
Pdf.CurrentPage.TextOut(40, 40, 0, 'reproducible save');
Pdf.EndDoc;
finally
Pdf.Free;
end;
Result := TFile.ReadAllBytes(Target);
end;
// 你自己的帮手:相等时为 -1,否则为第一个不同的偏移
function FirstDifference(const A, B: TBytes): Integer;
var
I, N: Integer;
begin
N := Length(A);
if Length(B) < N then
N := Length(B);
for I := 0 to N - 1 do
if A[I] <> B[I] then
Exit(I);
if Length(A) <> Length(B) then
Result := N
else
Result := -1;
end;
procedure CheckReproducible(Encrypt: Boolean; KeyType: THPDFKeyType);
var
A, B: TBytes;
Offset: Integer;
begin
A := SaveOnce('run-a.pdf', Encrypt, KeyType);
Sleep(1100); // 逼出一个不同的时间戳秒
B := SaveOnce('run-b.pdf', Encrypt, KeyType);
Offset := FirstDifference(A, B);
if Offset >= 0 then
raise Exception.CreateFmt('Saves differ at byte offset %d', [Offset]);
end;
明文跑 CheckReproducible(False, aes128),加密跑 CheckReproducible(True, aes128) 和 CheckReproducible(True, aes256);明文那趟密钥类型被忽略。VerifySavedOutput 在这个测试里物有所值:打开之后,EndDoc 会用一个新开的 THotPDF 实例重载刚写出的文件,关掉交叉引用恢复,文档受保护时用用户密码,加载失败就抛异常。一个让输出既稳定又不可读的改动,会顶着一张绿灯差异蒙混过关;重载拦下它
可复现的加密 PDF 还安全吗?
不安全。在 ReproducibleOutput 下加密的文档没有任何真实防护,凡是要离开测试目录的东西都该把这个标志关掉。AES-256 文件密钥是三十二个 $5A 字节。密码仍然把守 /UE 和 /OE 包装,但里面包着的是公开常量,任何知道它的人不需要密码就能解开每条内容流。固定 salt 还去掉了 ISO 32000-2 §7.6.4.4.7 依赖的逐文件唯一性——它保证相同密码不会在两个文件里产出相同的 /U 字符串。AES-256 设置那篇讲的是随机源完好时加密属性交付的东西;可复现标志下这些一概不适用。把这个属性藏在仅测试的配置值后面,别让生产代码路径够得着
AES-GCM 的处理不同。设 CryptKeyLength := aesgcm(仅 PDF 2.0)时,无论 ReproducibleOutput 说什么,组件都从系统随机源取 8 字节 nonce 前缀,然后为每条加密字符串或流向上计数。这是对的行为。同一把钥下 nonce 重复时 GCM 输得很惨:两份密文泄漏各自明文的 XOR,攻击者能恢复出足以伪造认证标签的信息。钉死的钥加钉死的 nonce,套件产出的每个文件都必然重复。所以加密 golden file 用 aes128 或 aes256——它们跑 CBC 模式
标识符是较温和的取舍。ISO 32000-1 §14.4 的本意是第二个 /ID 元素每次修改都变,而可复现保存往两个槽里写同一个值。因为这个值哈希的是对象图,不同内容仍拿到不同标识符。以 /ID 为键的读取方——注释存储或表单数据附属文件之类——仍会把同一内容的两次可复现保存当成一个文件。在测试套件里,这正是你想要的
留在写入器之外的输入
ReproducibleOutput 覆盖的是写入器自己引入的熵。还有几个输入来自构建机器,或来自按定义就要记录新时刻的操作
- 时区后缀。 每个 PDF 日期字符串结尾是本地 UTC 偏移,所以同一个固定日期在这台构建机上读作
D:20260101000000+08'00',在另一台上读作D:20260101000000-05'00'。golden file 在一台机器的多次运行之间成立,或在共享时区的机器之间成立。文件要跨办公室流动的话,把构建机的时区钉住 - 增量更新。
SaveIncrementalUpdate保留源的永久标识符,并从上一个标识符、目标路径、tick 计数和当前时间构造新的修改标识符。追加段按定义就是一次新修改。比较完整重写,不要比较追加的增量 - 直通捷径。
SaveLoadedDocument通常把未修改、未加密的源逐字节拷过去,而不是重新序列化。标志之下文件总是被重写,顺序和标识符规则因此生效,保存因此更慢,且绝不是输入的副本。跟上一次可复现保存比较,永远不要跟原始文件比 - 你自己加的时间戳。
AddDocumentAttachment用调用时刻给嵌入文件的/Params/CreationDate和/ModDate(ISO 32000-1 §7.11.4)打戳,签名在/M里记录签署时间。这些日期描述的是调用方要求的事件,所以标志不去碰它们。字节级用例里别放附件和签名,或者在比较时掩掉那些字符串 - 已安装的字体。 TrueType 子集是从跑测试那台机器上装的字体文件里切出来的。两台装着不同版本 Arial 的机器,同一段文本嵌入的字形程序不同,子集前缀也可能跟着变。把字体文件随构建镜像一起发
已加载文档走同样的规则。加载之前设好属性,做完编辑,经 SaveLoadedDocument 保存;输出是一份规范化重写,可以直接提交为下一轮的参照
var
Pdf: THotPDF;
begin
Pdf := THotPDF.Create(nil);
try
Pdf.AutoLaunch := False;
Pdf.ReproducibleOutput := True; // 加载之前设置
if Pdf.LoadFromFile('incoming.pdf') <= 0 then
raise Exception.Create('incoming.pdf did not load');
// ……被测的编辑……
Pdf.SaveLoadedDocument('normalized.pdf'); // 完整重写,键已排序
finally
Pdf.Free;
end;
end;
字节一致的保存还顺手逼出一个闪烁的一致性检查。写入器稳定之后,一次跑过、下一次跑挂的检查,多半是对测试自己构建的对象图起反应——比如同一个直接字典交给了两个父节点——所以先查测试输入,再查校验器。从 catalog 往下的结构性 diff 能指出两次运行之间哪个对象动了
上文用到的 ReproducibleOutput、DeterministicDictionaryOrder、VerifySavedOutput 和加密属性都属于 HotPDF Delphi Component,Delphi 和 C++Builder 双双可用