Техническая статья

Воспроизводимый PDF в HotPDF: побайтово одинаковый вывод

HotPDF Delphi Component даёт побайтово одинаковый вывод PDF при повторных сохранениях, когда свойство ReproducibleOutput равно True: оно прибивает гвоздями /CreationDate и /ModDate в Info к фиксированной дате, заменяет идентификатор документа, взятый с системных часов, на хеш от сида или от содержимого, подставляет константы вместо каждого случайного байта, который иначе вытянули бы пути шифрования AES, и сортирует каждый сериализуемый словарь. Флаг существует для регрессионных наборов и сравнения артефактов сборки, а не для продакшен-документов, и причины этой границы — самое интересное. Сценарий, который и породил эту функцию, — тест на эталонном файле. Вы рендерите счёт-фактуру, коммитите PDF и утверждаете, что сборка завтра даст те же байты. Она не даёт никогда. Файл прекрасно открывается в любом просмотрщике, текст идентичен, дерево страниц идентично, а сравнение всё равно подсвечивает четыре или пять мест. Каждый, кто пытался поставить генератор PDF под побайтовый регрессионный тест, упирался в эту стену, и исправление здесь — не «вырезать метки времени», а точный учёт каждого места, где писатель обращается к чему-то помимо самого документа

Почему два сохранения одного и того же PDF различаются?

Два сохранения одного документа различаются потому, что писатель PDF, включая HotPDF, обращается к четырём источникам энтропии, не имеющим отношения к содержимому страниц: к системным часам, идентификатору документа, криптографическому генератору случайных чисел и порядку записей словаря в памяти. Каждый из них сам по себе законен. ISO 32000-1 хочет, чтобы они там были. Просто они превращают файл в функцию от того, когда и где он записан, а не от того, что в нём лежит

  • Часы. Словарь Info несёт /CreationDate и /ModDate (ISO 32000-1 §14.3.3, Table 317) строками D:YYYYMMDDHHmmSS с суффиксом часового пояса (§7.9.4), а пакет XMP повторяет тот же момент как xmp:CreateDate и xmp:ModifyDate. HotPDF ставит обе метки из FCreationDate, которую конструктор инициализирует значением Now, так что два сохранения различаются секундой, в которую их записали
  • Идентификатор. Массив /ID в трейлере (ISO 32000-1 §14.4) держит постоянный идентификатор и идентификатор изменения. Рецепт HotPDF по умолчанию хеширует имя файла вместе с текущим временем с точностью до миллисекунд для первого элемента и хеширует это плюс GetTickCount для второго. Два идентификатора, два свежих значения на каждом запуске
  • Случайные байты. Стандартная защита зависит от идентификатора и от настоящей случайности. Для AES-256 ключ шифрования файла, соли валидации и ключа и каждый вектор инициализации CBC берутся из системного источника случайности (ISO 32000-2 §7.6.4.4.7 требует случайных солей). Поскольку /U, /UE, /O и /OE вычисляются из этих байтов, зашифрованный документ меняется целиком, даже когда открытый текст не меняется. Более старые алгоритмы подмешивают первый элемент /ID в ключ (ISO 32000-1 §7.6.3.3, §7.6.3.4), так что одного свежего идентификатора достаточно, чтобы перешифровать файл другим ключом
  • Порядок. Словарь PDF — неупорядоченное отображение, а писатель, обходящий свой список в памяти, выдаёт ключи в порядке вставки. Любой путь в коде, строящий словарь ресурсов в другой последовательности, или загруженный документ, разобранный из другой раскладки, даёт законный, но текстово иной файл
Четыре источника энтропии, из-за которых два сохранения одного документа в HotPDF различаются: FCreationDate, проставляемая из Now, питает даты D: и пакет XMP, массив /ID в трейлере хеширует имя файла, часы и GetTickCount, AES берёт материал ключа из системного источника случайности, а словари сериализуются в порядке вставки в память
Каждый источник законен сам по себе, и ISO 32000-1 хочет, чтобы они были, но вместе они превращают файл в функцию от того, когда и где он записан, а не от того, что в нём лежит

Что именно прибивает ReproducibleOutput?

Установка ReproducibleOutput := True до BeginDoc или до SaveLoadedDocument заменяет каждый из четырёх источников фиксированным значением, и делает это в тех же путях кода, которые иначе потянулись бы к часам или к генератору случайных чисел, так что отдельный проход очистки не нужен. Заметьте, чего в списке выше нет: содержимого. Шрифты, потоки страниц, данные изображений и таблица перекрёстных ссылок уже детерминированы для одного и того же входа; шум живёт целиком в метаданных и в слое защиты, поэтому одно точное свойство и может его убрать. По умолчанию свойство равно 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 воспроизводимая ветка присваивает FCreationDate := EncodeDate(2026, 1, 1) и засевает идентификатор документа значением MD5CalcString('HotPDF-reproducible-seed') вместо дайджеста от имени файла с часами. Это одно присваивание покрывает обе даты в Info и обе даты XMP, потому что все четыре рисуются из одного поля. Когда файл наконец записывается, BuildDocumentIdentifiers запрашивает у ComputeCanonicalDocumentIdentifier идентификатор трейлера: тот экспортирует весь граф объектов в каноническом порядке, обнуляет цифры в любой найденной строке даты D:, чтобы метки времени не просочились обратно через хеш, и берёт MD5 от результата. Оба элемента /ID получают это значение. Тот же идентификатор, выведенный из содержимого, используется, когда загруженный документ шифруется, ни разу не проходя через BeginDoc, — а это случай ActivateProtection для файла, открытого через LoadFromFile

Случайные байты — самая неочевидная подстановка. Процедура ключа AES-256 оборачивает свой источник случайности в локальный хелпер, который под флагом вызывает FillChar(P^, Count, $5A) для 32-байтового ключа шифрования файла и для каждой 8-байтовой соли, а шифраторы строк и потоков AES-128 и AES-256 переключаются с AESGenerateRandomIV на AESGenerateStaticIV, заполняющий вектор инициализации значением 14 * (1 + I) для слота I. Когда ключ, соли и векторы зафиксированы, /U, /UE, /O, /OE и каждый зашифрованный поток на втором прогоне выходят одинаковыми. Наконец, SaveToStream включает DeterministicDictionaryOrder всякий раз, когда выставлен флаг воспроизводимости, и сериализатор сортирует каждый словарь вставками по сырым байтам имён ключей, сначала более короткий префикс, а при равенстве — по исходному индексу. Это тот же порядок, что использует диагностический писатель, описанный в статье про ручную правку PDF и её последующую починку; воспроизводимый флаг заимствует только порядок, а не остальную текстовую раскладку того писателя

Что прибивает ReproducibleOutput в HotPDF: дата создания становится EncodeDate 2026, 1, 1, идентификатор трейлера берётся из ComputeCanonicalDocumentIdentifier по каноническому графу с обнулёнными цифрами D:, ключи и соли AES заполняются байтами $5A, а AESGenerateStaticIV заполняет каждый слот, и DeterministicDictionaryOrder сортирует каждый словарь
Подстановки выполняются в тех же путях кода, которые иначе потянулись бы к часам или к генератору случайных чисел, поэтому отдельный проход очистки не нужен, а оба элемента /ID получают одно и то же значение из содержимого

Почему фиксированная дата всё ещё выдавала системные часы?

Исправление в v2.752.2 существует потому, что фиксированная дата создания изначально решалась в конструкторе, а конструктор не может знать свойство, которое вызывающий ещё не выставил. Обычная последовательность вызовов: Create, затем ReproducibleOutput := True, затем BeginDoc. Во время конструирования FReproducibleOutput всё ещё False, так что FCreationDate получала Now и её сохраняла. Идентификатор и случайные байты были зафиксированы правильно, поэтому два файла совпадали почти везде и расходились ровно в двух строках даты и двух полях XMP. Перенос присваивания в воспроизводимую ветку BeginDoc, рядом с засеянным идентификатором, поставил решение в точку, где свойство имеет своё окончательное значение

Регрессионный тест, который это пропустил, стоит больше самого исправления. Два сохранения, уложившиеся в одну секунду системных часов, пишут одну и ту же строку D: случайно, и побайтовое сравнение проходит для бага, который падает на любой более медленной машине. Исправленный тест спит 1100 мс между двумя сохранениями, чтобы метка времени PDF гарантированно перешла через границу секунды, прогоняет случай для обычного вывода, AES-128 и AES-256 с настоящими паролями на двух зашифрованных вариантах и сравнивает два буфера через CompareMem, сообщая при неудаче первое различающееся смещение, чтобы расхождение указывало на конкретный объект, а не на весь файл. Побайтовое сравнение доказывает детерминизм и ничего больше, поэтому держите отдельную проверку, которая перезагружает зашифрованный вывод с паролем пользователя и читает число страниц; изменение, делающее файл одновременно стабильным и нечитаемым, не должно проскочить на одном зелёном сравнении

function SaveOnce(const Target: string): TBytes;
var
  Pdf: THotPDF;
  Stream: TFileStream;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.AutoLaunch := False;
    Pdf.FileName := Target;
    Pdf.ReproducibleOutput := True;
    Pdf.OwnerPassword := 'owner';
    Pdf.UserPassword := 'user';
    Pdf.CryptKeyLength := aes256;
    Pdf.ActivateProtection := True;
    Pdf.BeginDoc;
    Pdf.CurrentPage.SetFont('Arial', [], 12);
    Pdf.CurrentPage.TextOut(40, 40, 0, 'reproducible save');
    Pdf.EndDoc;
  finally
    Pdf.Free;
  end;
  Stream := TFileStream.Create(Target, fmOpenRead or fmShareDenyWrite);
  try
    SetLength(Result, Stream.Size);
    if Stream.Size > 0 then
      Stream.ReadBuffer(Result[0], Stream.Size);
  finally
    Stream.Free;
  end;
end;

// в теле теста
A := SaveOnce(PathA);
TThread.Sleep(1100);          // заставляем метку времени PDF сменить секунду
B := SaveOnce(PathB);
Assert.AreEqual<Integer>(Length(A), Length(B));
Assert.IsTrue(CompareMem(@A[0], @B[0], Length(A)),
  'two saves under ReproducibleOutput must be byte-identical');

Остаётся ли воспроизводимый зашифрованный PDF защищённым?

Нет. Документ, зашифрованный под ReproducibleOutput, не защищён ни в каком осмысленном смысле, и флаг обязан быть выключен для всего, что покидает каталог тестов. Ключ шифрования файла AES-256 — это тридцать два байта $5A, соли — восемь байтов $5A, а векторы инициализации следуют опубликованной арифметической закономерности. Пароль всё ещё прикрывает обёртки /UE и /OE, но обёрнутый ключ — константа, так что любой, кто знает эту константу, может расшифровать каждый поток содержимого вообще без пароля. Фиксация солей ещё и убирает уникальность на документ, на которую опирается ISO 32000-2 §7.6.4.4.7, чтобы одинаковые пароли не давали одинаковых строк /U в разных файлах. Прочитайте статью про настройку AES-256 о том, что обещают свойства шифрования, когда источник случайности цел; под воспроизводимым флагом эти обещания приостановлены

Компромисс с идентификатором тоньше. ISO 32000-1 §14.4 предполагает, что второй элемент /ID меняется при каждом изменении, чтобы инструменты могли отличить обновлённый файл от его предка, а воспроизводимое сохранение пишет одно и то же значение в оба слота. Поскольку это значение — хеш канонического графа объектов, два документа с разным содержимым всё равно получают разные идентификаторы, и это лучше константы. Но сид, который BeginDoc использует для вывода ключа, — одна и та же строка для каждого документа на каждой машине, и читатель, опирающийся на /ID при различении файлов, — например кеш аннотаций или побочный файл данных формы, — смешает все воспроизводимые файлы, которым случилось дать одинаковый хеш

Чего флаг не покрывает?

ReproducibleOutput убирает энтропию, которую писатель вносит сам; он не может убрать энтропию, приходящую из окружения или через пути кода, которыми он не управляет, и на трёх таких случаях легко споткнуться

  • Суффикс часового пояса. _DateTimeToPdfDate дописывает локальное смещение UTC, так что D:20260101000000+08'00' на одном агенте сборки и D:20260101000000-05'00' на другом — разные байты для одной и той же фиксированной даты. Воспроизводимость держится между прогонами на одной машине или между машинами в одном часовом поясе; прибейте пояс агента, если ваши эталонные файлы путешествуют
  • Инкрементальные обновления. SaveIncrementalUpdate вычисляет идентификатор изменения из пути приёмника, GetTickCount и текущего времени без воспроизводимой ветки, потому что инкрементальная секция по определению есть новое изменение. Сравнивайте полные перезаписи, а не дописанные дельты
  • Ярлык сквозной передачи. SaveLoadedDocument обычно копирует неизменённый незашифрованный исходный файл байт в байт вместо повторной сериализации. Воспроизводимый флаг отключает этот ярлык и заставляет выполнить полную перезапись, чтобы применились правила порядка и идентификатора; значит, воспроизводимое сохранение загруженного файла медленнее обычного и никогда не является копией входа. Сравнивайте его с предыдущим воспроизводимым сохранением, а не с оригиналом
Где воспроизводимые сохранения HotPDF останавливаются: _DateTimeToPdfDate всё ещё дописывает локальное смещение UTC, поэтому эталонные файлы различаются между часовыми поясами, у SaveIncrementalUpdate нет воспроизводимой ветки, потому что дельта — это новое изменение, а ярлык сквозной передачи отключён, так что загруженный файл всегда перезаписывается целиком
Воспроизводимость держится между прогонами на одной машине или на машинах в одном поясе, а воспроизводимое сохранение нужно сравнивать с предыдущим воспроизводимым сохранением, а не с исходным входом

Ещё один урок из того же выпуска — о том, что доказывает проходящая проверка и чего она не доказывает. Тестовая фикстура PDF/X-6 вызывала CharProcs.DeleteValue('A'), что освобождало напрямую удерживаемый поток глифа, затем вставляла тот же указатель обратно и вдобавок отдавала один прямой объект ExtGState и словарю ресурсов, и узору. Проверка соответствия проходила через раз на этом обращении к освобождённой памяти и двойном владении, потому что читала то, что случилось держать освобождённая память. Когда структурная проверка мигает, смотрите на владение тестовым входом раньше, чем на валидатор. Воспроизводимый вывод делает эту дисциплину дешевле: как только два сохранения побайтово одинаковы, единственный оставшийся источник мигания — сам граф объектов, и структурное сравнение от каталога вниз его найдёт

Описанные здесь свойства ReproducibleOutput, DeterministicDictionaryOrder и шифрования поставляются в стандартном HotPDF Delphi Component для Delphi и C++Builder, и тот же флаг управляет собственным регрессионным корпусом библиотеки, так что поведение, которое вы получаете в своём наборе тестов, — это поведение, которым компонент и тестируется