跳转到内容
getnextpdf.com

每次都是相同的字节:可复现的 PDF

Spec: ISO 32000-2, §14.4Spec: ISO 32000-2, §14.3.3

用相同的输入构建一份 PDF 两次,你会期望得到相同的文件。多数 PDF 库无法承诺那一点——重建并 diff,那些字节就漂移了。NextPDF 能够钉住那两样会移动的东西,因此相同的输入会产生相同的字节,每一次都是。

字节相同的输出并非一个虚荣指标。它是团队真正想要的三样东西底下的基础。

第一样是缓存。如果一次构建是其输入的一个纯函数,那么它的输出哈希就是一个缓存键。相同的输入、相同的哈希,跳过工作并提供已存储的文件。当字节四处游走时,哈希也四处游走,而缓存便永远不命中。

第二样是篡改可证性。一条能重新生成它所发出之确切文件的流水线,可以稍后证明一份已封存的文档未曾被更改:重建它、对两者哈希、比较。如果因为一个内嵌的时钟而哪怕只有一个字节不同,那个证明就没了,而你又退回到了“相信我”。

第三样是值得信赖的 CI。一个 golden-file 测试记录一份已知良好的输出,并在一处变更改变了它时失败。那个信号唯有在一个未变更的引擎能复现一份未变更的文件时才有意义。如果每一次运行都在一个时间戳上不同,那么 golden 文件就是噪声,而团队便学会无视一次红色的构建——测试中代价最高的习惯。

在 NextPDF 的确定性设定档中,那两个本会在相同构建之间漂移的、由引擎掌控的字段是日期与 /ID。这假定流水线的其余部分已经稳定——相同的输入,以及一份不会自行变化的序列化(下文详述):

  • 内嵌的日期。 document information 字典携带 CreationDateModDateSpec: ISO 32000-2, §14.3.3),而 XMP 元数据镜像它们。在构建时捕捉“现在”,于是每一次重建都不同。
  • 文件标识符。 /ID 数组是一对标识文件的字节字符串(Spec: ISO 32000-2, §14.4),储存在尾段字典中(Spec: ISO 32000-2, §7.5.5)。库通常从当前时间加上随机字节来推导它,因此它在设计上每一次运行都不同。

把两者都钉住——一个固定的时间戳与一个固定的 /ID 种子——那么输出就成为其内容的一个确定性函数。别动内容,那么文件便逐字节地相同。这与可复现构建(Reproducible Builds)项目为编译软件所确立的,是同一套纪律,应用到文档层。

NextPDF 中的确定性是一个配置对象,而非一个测试上的取巧。引擎在 NextPDF\Core 命名空间中暴露一个 DeterministicSettings 值对象。它是 final readonly、不可变的,且它恰好钉住上面所命名的那两个由时钟与随机所派生的漂移来源:日期与 /ID。钉住它们移除了那两个最常见的漂移来源,但它单凭其本身并不保证字节相同的输出。引擎的其他序列化行为——对象排序、字体子集化,以及压缩设置——也必须是确定性的,输出才能复现,而 NextPDF 在设计上让那些保持稳定。

它的构造函数接收两个参数:

public function __construct(
public DateTimeImmutable $timestamp,
public string $fileIdSeed,
) {
// ...
}

$timestamp 是写入每一个日期字段的那个单一固定时刻——CreationDateModDate,以及它们的 XMP 镜像。传入一个 DateTimeImmutable,文档便不再去问挂钟现在几点了。$fileIdSeed 是钉住尾段 /ID 的输入:一个 32 个字符的十六进制字符串。给同一个种子,引擎便派生同一个文件标识符,而非采样时钟与一个随机源。

该对象会验证它自己的输入。种子必须恰好是 32 个十六进制字符;其他任何东西都在构造时以一个 InvalidConfigException 被拒绝,而非悄悄地产生一个看起来不同的 /ID。这与这个引擎其余部分所采取的,是同一套拒绝猜测的立场——一个模棱两可的输入会响亮地失败,而非安静地改变那些字节。

把两者都钉住,那个配方就是可复现构建项目让人熟悉的那一个:重建它、diff 它,而那个 diff 是空的。

  1. Fix the inputsThe same content, fonts, and settings that produced the original document.
  2. Pin the timestampOne DateTimeImmutable feeds CreationDate, ModDate, and the XMP dates — no wall clock.
  3. Pin the /ID seedA 32-character hex seed derives the trailer /ID instead of a clock-plus-random value.
  4. BuildThe output is now a pure function of content; the two moving parts are held still.
  5. Rebuild and diffRegenerate from the same inputs and compare bytes — an empty diff is the proof.
Reproducible build: identical inputs plus a pinned timestamp and a pinned /ID seed produce the same bytes, which a rebuild-and-diff step confirms.

一份小而完整的写法。设置被构造一次并复用,因此同一个程序的两次运行发出同一份文件。

<?php
declare(strict_types=1);
use NextPDF\Core\DeterministicSettings;
use NextPDF\Exception\InvalidConfigException;
// One fixed instant for every date field — never the wall clock.
$timestamp = new DateTimeImmutable('2026-01-01T00:00:00+00:00');
// A 32-character hex seed pins the trailer /ID. Same seed, same /ID.
$fileIdSeed = '0123456789abcdef0123456789abcdef';
try {
$deterministic = new DeterministicSettings(
timestamp: $timestamp,
fileIdSeed: $fileIdSeed,
);
} catch (InvalidConfigException $e) {
// A malformed seed (not exactly 32 hex chars) is refused here,
// before any document is built — not silently coerced.
error_log($e->getMessage());
throw $e;
}
// Hand $deterministic to the document configuration. With both moving
// parts pinned, building the same content twice yields identical bytes:
//
// sha256(build_one) === sha256(build_two)

种子是一个你掌控的构建输入,而非一个秘密。把它存在你其余构建配置的旁边。要点在于它是固定的,因此它所产生的文件标识符也是固定的。

第一个陷阱是“我移除了时间戳,所以我的构建现在可复现了”。它通常不可复现,因为 /ID 数组是那两个来源中较安静的一个。日期在一个元数据面板里可见、且易于记住;尾段 /ID 对多数读取器是不可见的,且在每一次运行都从时钟与一个随机源重新生成。一次只钉住日期的构建,仍然每一次都产生一份不同的文件。你必须把两者都按住不动。

第二个陷阱是把确定性单凭其本身当作一项安全功能。一个被钉住的 /ID 让一份文件可复现;它并不让它被签名,且它本身并不证明两次构建相符。一次逐字节的比较或一个哈希才证明两次构建相符;钉住 /ID 只是移除一个虚假差异的来源。而那两者中,没有一个证明一个第三方为该文件作担保。可复现性与签名是互补的层,而非彼此的替代品。

确定性钉住引擎自己那些会移动的部件。它并不钉住你的输入。如果你的内容内嵌了一个活的时间戳、拉取了一个在磁盘上变过的字体,或渲染了一个取决于当前日期的值,那么输出之所以改变是因为输入改变了——而那是正确的。DeterministicSettings 移除引擎的非确定性,而非你的。一次可复现的构建仍然需要可复现的输入。

Deterministic byte-identical output — edition availability
EditionAvailability
Core

Full support. DeterministicSettings ships in the open-source core: pin the timestamp and the /ID seed and the same content rebuilds to the same bytes — no edition gate.

ProNot in this edition
EnterpriseNot in this edition
  • Golden-file 测试 — 那种依赖于字节相同输出的 CI 技术,以及为何一个确定性引擎是它的前提条件。
  • 增量更新 — 一份 PDF 如何藉由追加而增长,以及 /ID 数组在何处又一次对把一份文件关联到它的较早版本而言要紧。
  • 元数据与 XMP 封包 — 那些内嵌的日期住在何处,以及 XMP 封包如何镜像 document information 字典。
  • PDF 文件剖析 — 尾段、交叉引用表,以及 /ID 数组在文件结构中所处的位置。
  • 字节相同 — 两份逐字节、精确相符的文件。“相同”最强的形式,也是一个哈希或一次 diff 能验证的那一种。
  • /ID(文件标识符) — 那个标识一份 PDF 及其各版本的、由两个字节字符串构成的数组(ISO 32000-2 §14.4),储存在尾段字典中(§7.5.5)。通常从时钟加上随机字节派生,这正是为何它在每一次未钉住的构建上都改变。
  • document information 字典 — 那个携带 CreationDateModDate 的结构(ISO 32000-2 §14.3.3)。一次确定性构建必须钉住的两个非确定性来源之一。
  • golden 文件 — 一份被记录下来、供测试据以比较的已知良好输出;唯有当一个未变更的引擎复现一份未变更的文件时才有意义。
  • 可复现构建 — 一次其输出是其输入之确定性函数的构建,因此从相同的输入重建会产出相同的字节。这个术语来自编译软件的可复现构建(Reproducible Builds)项目。