The PDFium Component 把可复现 PDF 输出做成一次调用:TPdf.SaveAsReproducible 重写文档,让同一内容保存两次得到逐字节相同的文件。它从文档信息字典移除 /CreationDate 和 /ModDate,不写 trailer /ID,保持对象顺序稳定,并在一份 TPdfReproducibleReport 里报告改了什么。加密文件被拒收,签名文件需要明确同意
这种需求通常出现在构建管线里。有人把生成的 PDF 提交进仓库或 golden-file 测试套件,每次运行都产出 diff,尽管任何页面上的内容什么都没变。或者归档按 SHA-256 去重,却悄悄存下五十份同一张发票,因为每次保存都往文件里盖了个新时间。往文件里嵌墙钟的 PDF 写入器,对桌面应用无妨,对任何把字节当身份的东西都是敌意行为
为什么同一份 PDF 存两次字节不一样
未改动的文档两次保存不同,原因恰好只有两类数据:文档信息字典里的时间戳和 trailer 里的文件标识符。细心的写入器输出的其它一切——对象编号、对象顺序、交叉引用布局——都可以做成确定性的,The PDFium Component 的全量重写路径已经做到了:对象按对象号升序写出,xref 表遵循同一顺序
时间戳是显而易见的元凶。ISO 32000-1 §14.3.3 在 /Info 字典里定义 /CreationDate 和 /ModDate,而 PDFium 原生保存每次序列化文档都写一个崭新的 /ModDate。文件标识符才是微妙的那一个。ISO 32000-1 §14.4 把 /ID 描述成双元素字节串数组,第一元素标识原始文件,第二元素随文件修订而变。写入器通常靠对文件某方面做哈希来导出这些字符串,如果那“某方面”包含带时间戳的字节,标识符就继承了非确定性
在 Delphi 中使用 TPdf.SaveAsReproducible
TPdf.SaveAsReproducible 有两个重载,一个写 TStream,一个写文件名,都返回 Boolean,带 out Report: TPdfReproducibleReport 和默认为 False 的可选 AllowSignedDocument 参数。返回值与 Report.Succeeded 一致;为 False 时,Report.ErrorMessage 说明原因。文件重载写到目标旁边的临时文件,只在保存成功后替换目标,失败的一趟绝不会在你上一次构建产物的位置留下半份 PDF
var
Pdf: TPdf;
Report: TPdfReproducibleReport;
begin
Pdf:= TPdf.Create(nil);
try
Pdf.FileName:= 'invoice-2026-10.pdf';
Pdf.Active:= True;
if Pdf.SaveAsReproducible('build\invoice-2026-10.pdf', Report) then
Writeln(Format('%d -> %d bytes, %d objects kept, %d pruned, ' +
'%d timestamps stripped', [Report.SourceByteCount,
Report.OutputByteCount, Report.KeptObjectCount,
Report.PrunedObjectCount, Report.StrippedTimestampCount]))
else
Writeln('Reproducible save failed: ', Report.ErrorMessage);
finally
Pdf.Free;
end;
end;
报告字段值得在 CI 里记录。文档的 /Info 带两个日期时,StrippedTimestampCount 正常为 2;连 /Info 字典都没有的文档则为 0。PrunedObjectCount 统计原生保存里存在、但从 catalog 或 /Info 字典不可达的对象;可复现重写会把它们丢掉,所以输出预期与普通保存一样大或更小
为什么光去掉时间戳还不够
第一版实现移除了两个时间戳,产出的仍是两份不同的文件。页面相同,/Info 字典相同,trailer 却在 /ID 的第二元素上不同。根因是顺序:重写先向 PDFium 要一份原生保存,再后处理那些字节,而 trailer 构建器是拿原生输出做哈希算出 /ID 的。原生输出里仍带着崭新的 /ModDate,于是哈希、进而标识符每次运行都变——尽管时间戳本身从未到达最终文件
这是可复现构建通用法则的好例子:如果先有任何东西从非确定性值派生而来,光移除它并不够。修法是干脆不携带 /ID。TPdf.SaveAsReproducible 写出的 trailer 只含 /Size、/Root,以及源文件有时的 /Info。trailer 里不再剩下任何时间相关的东西,同一文档连续两次保存逐字节相等——行为验证正是这么做的
function SavesAreIdentical(Pdf: TPdf): Boolean;
var
A, B: TMemoryStream;
RA, RB: TPdfReproducibleReport;
begin
A:= TMemoryStream.Create;
B:= TMemoryStream.Create;
try
Result:= Pdf.SaveAsReproducible(A, RA) and
Pdf.SaveAsReproducible(B, RB) and
(A.Size = B.Size) and CompareMem(A.Memory, B.Memory, A.Size);
finally
B.Free;
A.Free;
end;
end;
删除字典键而不损坏字符串
时间戳靠对 /Info 对象主体做感知 token 的扫描移除,不是文本搜索。对 /ModDate 做朴素的字节替换,会误伤恰好含有这段文本的 /Subject 或 /Keywords 字符串;而从字面字符串里切掉字节,会悄悄损坏元数据而不是响亮地失败。The PDFium Component 先在主体扫描副本里把字面字符串和注释中和掉,然后只接受真实名称 token 开始处的匹配,用 PDF 对象语法找到该键值的终点,跳过整个键值对
实际结果是 /Info 里其余每个条目——标题、作者、生成器、自定义键——原样幸存。只有定义为日期的两个键被移除,而且只从 trailer 的 /Info 条目实际指向的那个字典里移除
对加密与签名的 PDF,省略 /ID 代价是什么
对普通 PDF 1.x 输出,省略 /ID 是安全的,但它排除了加密,也改变签名的命运。ISO 32000-1 里,trailer 的 /ID 是可选的,除非存在 /Encrypt 条目,因为标准安全处理器会把第一个标识符字符串掺进加密密钥。没有 /ID 的文件不可能是正确加密的文件,所以 TPdf.SaveAsReproducible 不去尝试:trailer 带 /Encrypt 时,它返回 False,Report.ErrorMessage 说明文档已加密。管线允许就先解密,否则把加密交付物排除在字节一致性检查之外。目标是 PDF 2.0 的话,注意 ISO 32000-2 收紧了规则、要求每个文件都有 /ID,所以可复现输出与 PDF 1.x 目标最合拍;Delphi 中的精确 PDF 版本符合性一文讲组件如何处理版本目标
签名文档是另一类问题。TPdf.SaveAsReproducible 是全量非增量重写,字节偏移会移动,既有签名的 /ByteRange 不再覆盖被签名的字节。因此对签名文件做可复现输出,是刻意用既有签名的有效性换稳定字节的决定,这也是该方法在 SignatureCount 大于零且你没传 AllowSignedDocument = True 时抛 EPdfError 的原因
if Pdf.SignatureCount > 0 then
// 显式选择加入:重写会移动字节偏移,
// 既有签名此后理应预期验签失败
Ok:= Pdf.SaveAsReproducible('normalized.pdf', Report, True)
else
Ok:= Pdf.SaveAsReproducible('normalized.pdf', Report);
如果签名的原件就是事实记录,请原样保留它,可复现输出只用于派生副本,比如你在 CI 里 diff 的规范化版本。想往受保护文件里追加而不扰动既有内容,见为什么加密的 PDF 无法在 Delphi 中被悄悄修补
依赖字节一致性之前要知道的边界
字节一致性成立于同一文档由同一组件构建保存的情形,且只覆盖该方法触碰的东西。TPdf.SaveAsReproducible 只剥两个 /Info 日期;自带 xmp:CreateDate 或 xmp:ModifyDate 的 XMP 元数据流按文档已有的字节原样携带。这在多次保存之间是稳定的,但意味着 XMP 与 /Info 日期不再描述同一件事——对要求两者一致的 PDF/A 配置这很重要;PDF/A Info 与 XMP 元数据等价性一文解释这些规则。不同的 PDFium 二进制或组件版本序列化对象的方式也可能不同,任何跨机器比较哈希的管线里请把两者都钉死
在这些边界之内,该方法给出 CI diff、golden-file 测试和内容寻址归档需要的东西:相同输入,相同字节。TPdf.SaveAsReproducible 和 TPdfReproducibleReport 随 PDFium Component for Delphi and C++Builder 提供,与其余保存、优化和符合性 API 同在
FAQ
TPdf.SaveAsReproducible 会改变页面内容吗
不会。它从 /Info 字典移除 /CreationDate 和 /ModDate 键,省略 trailer /ID,并丢弃从 catalog 或 /Info 不可达的对象;页面内容原样写出
能从加密 PDF 产出可复现输出吗
不能。加密依赖可复现输出省略的 trailer /ID,所以对加密源该方法返回 False 并附解释性的 ErrorMessage
为什么签名 PDF 需要 AllowSignedDocument
可复现保存是移动字节偏移的全量重写,既有签名不再匹配其签名字节范围。传 AllowSignedDocument = True 即确认你接受这个取舍