Перейти к содержимому
getnextpdf.com

Одни и те же байты каждый раз: воспроизводимые PDF

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

Соберите PDF из одних и тех же входных данных дважды — и вы ожидали бы один и тот же файл. Большинство библиотек PDF не могут этого обещать — пересоберите и сравните, и байты дрейфуют. NextPDF может зафиксировать те две вещи, что движутся, так что одни и те же входные данные производят одни и те же байты, каждый раз.

Побайтово идентичный вывод — не метрика тщеславия. Это фундамент под тремя вещами, которые команды действительно хотят.

Первая — кэширование. Если сборка — чистая функция своих входных данных, хеш её вывода — это ключ кэша. Те же входные данные, тот же хеш, пропустить работу и отдать сохранённый файл. Когда байты блуждают, блуждает хеш, и кэш никогда не попадает.

Вторая — доказуемость отсутствия подмены. Конвейер, который может заново сгенерировать тот же файл, что он отправил, может доказать, позже, что заархивированный документ не был изменён: пересобрать его, хешировать оба, сравнить. Если хоть один байт отличается из-за встроенных часов, доказательства нет, и вы возвращаетесь к «поверьте мне».

Третья — достойный доверия CI. Эталонный (golden-file) тест записывает известно-хороший вывод и проваливается, когда изменение его меняет. Этот сигнал осмыслен, только если неизменённый движок воспроизводит неизменённый файл. Если каждый прогон отличается по временной метке, эталонный файл — это шум, и команда научается игнорировать красную сборку — самая дорогая привычка в тестировании.

В детерминированном профиле NextPDF два управляемых движком поля, которые иначе дрейфовали бы между идентичными сборками, — это даты и /ID. Это предполагает, что остальная часть конвейера уже стабильна — те же входные данные и сериализация, которая не варьируется сама по себе (подробнее об этом ниже):

  • Встроенные даты. Словарь document information несёт CreationDate и ModDate (Spec: 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 — это объект конфигурации, а не тестовый трюк. Движок предоставляет объект-значение DeterministicSettings в пространстве имён NextPDF\Core. Он final readonly, неизменяемый, и он фиксирует ровно два производных от часов и случайности источника дрейфа, названных выше: даты и /ID. Фиксация их убирает два самых распространённых источника дрейфа, но сама по себе не гарантирует побайтово идентичный вывод. Прочее поведение сериализации движка — порядок объектов, субсеттинг шрифтов и настройки сжатия — тоже должно быть детерминированным, чтобы вывод воспроизводился, и NextPDF держит это стабильным по своей конструкции.

Его конструктор принимает два аргумента:

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

$timestamp — это единственный фиксированный момент, записываемый в каждое поле даты — CreationDate, ModDate и их зеркало в 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.
Воспроизводимая сборка: идентичные входные данные плюс зафиксированная временная метка и зафиксированное зерно /ID производят одни и те же байты, что подтверждает шаг «пересобрать и сравнить».

Небольшая, полная форма. Настройки конструируются один раз и переиспользуются, так что два прогона одной программы выдают один и тот же файл.

<?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

Полная поддержка. DeterministicSettings поставляется в открытом ядре: зафиксируйте временную метку и зерно /ID — и то же содержимое пересобирается в те же байты — без гейта по изданию.

ProNot in this edition
EnterpriseNot in this edition
  • Эталонное (golden-file) тестирование — техника CI, которая зависит от побайтово идентичного вывода, и почему детерминированный движок — её предпосылка.
  • Инкрементные обновления — как PDF растёт за счёт добавления, где массив /ID снова важен для связи файла с его более ранними версиями.
  • Метаданные и пакет XMP — где живут встроенные даты и как пакет XMP зеркалит словарь document information.
  • Анатомия файла PDF — трейлер, таблица перекрёстных ссылок и где в структуре файла находится массив /ID.
  • Побайтово идентичные — два файла, которые совпадают точно, байт в байт. Сильнейшая форма «того же самого» и та, что хеш или сравнение могут проверить.
  • /ID (идентификатор файла) — массив из двух строк байтов, который идентифицирует PDF и его версии (ISO 32000-2 §14.4), хранимый в словаре трейлера (§7.5.5). Обычно выводится из часов плюс случайные байты, поэтому он меняется при каждой незафиксированной сборке.
  • Словарь document information — структура, которая несёт CreationDate и ModDate (ISO 32000-2 §14.3.3). Один из двух источников недетерминированности, которые детерминированная сборка должна зафиксировать.
  • Эталонный файл — записанный известно-хороший вывод, с которым сравнивает тест; осмыслен, только когда неизменённый движок воспроизводит неизменённый файл.
  • Воспроизводимая сборка — сборка, чей вывод — детерминированная функция её входных данных, так что пересборка из тех же входных данных даёт те же байты. Термин происходит из проекта Reproducible Builds для скомпилированного ПО.