跳到內容
getnextpdf.com

每次都是相同的位元組:可重現的 PDF

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

用相同的輸入建構一份 PDF 兩次,你會預期得到相同的檔案。多數 PDF 函式庫無法承諾這一點——重建並差異比對,位元組便會漂移。NextPDF 能把那兩樣會移動的東西固定下來,讓相同的輸入每一次都產生相同的位元組。

位元組相同的輸出不是一個用來炫耀的指標。它是團隊真正想要的三件事所立足的根基。

第一是快取。如果一次建構是其輸入的純函式,那麼它的輸出雜湊值就是一個快取鍵。相同的輸入、相同的雜湊值,便能略過工作、直接送上所儲存的檔案。當位元組四處遊蕩時,雜湊值也跟著遊蕩,於是快取永遠命中不了。

第二是防竄改性。一條能重新產生它所交付確切檔案的管線,便能在日後證明一份已封存的文件未曾被改動:重建它、把兩者雜湊、加以比較。如果哪怕只有一個位元組因為一個內嵌的時鐘而不同,那項證明便消失了,而你又退回到「相信我就對了」。

第三是值得信賴的 CI。一個 golden-file 測試會記下一個已知良好的輸出,並在某項變更改動它時失敗。那個訊號唯有在一個未變更的引擎能重現一個未變更的檔案時才有意義。如果每次執行都因為一個時間戳記而不同,那麼 golden 檔案就只是雜訊,而團隊便學會無視一個紅色的建構——那是測試中代價最高昂的習慣。

在 NextPDF 的確定性設定檔中,那兩個若不加處理便會在相同建構之間漂移、且由引擎掌控的欄位,就是日期與 /ID。這假設管線的其餘部分已然穩定——相同的輸入,以及一套不會自行變化的序列化(下文詳述):

  • 內嵌日期。 文件資訊字典攜帶 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。這正是引擎其餘部分所採取的同一套「拒絕猜測」立場——一個有歧義的輸入會大聲失敗,而非悄悄改變位元組。

兩者都固定後,做法就是 Reproducible Builds 專案讓人耳熟能詳的那一套:重建它、差異比對它,而那份差異是空的。

  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 封包如何映照文件資訊字典。
  • PDF 檔案剖析 — 尾段、交叉參照表,以及 /ID 陣列在檔案結構中坐落於何處。
  • 位元組相同 — 兩個逐位元組精確相符的檔案。「相同」最強的一種形式,也是雜湊或差異比對所能驗證的那一種。
  • /ID(檔案識別碼) — 識別一份 PDF 及其各版本的那對兩個位元組字串所構成的陣列(ISO 32000-2 §14.4),儲存在尾段字典中(§7.5.5)。通常從時鐘加上隨機位元組推導,這正是它在每次未固定的建構中都改變的原因。
  • 文件資訊字典 — 攜帶 CreationDateModDate 的那個結構(ISO 32000-2 §14.3.3)。一次確定性建構必須固定的兩個非確定性來源之一。
  • Golden 檔案 — 一個被記下、供測試比對的已知良好輸出;唯有當一個未變更的引擎重現一個未變更的檔案時才有意義。
  • 可重現的建構 — 一次其輸出是其輸入之確定性函式的建構,因此從相同輸入重建會產出相同位元組。這個術語來自針對編譯軟體的 Reproducible Builds 專案。