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 — неупорядоченное отображение, а писатель, обходящий свой список в памяти, выдаёт ключи в порядке вставки. Любой путь в коде, строящий словарь ресурсов в другой последовательности, или загруженный документ, разобранный из другой раскладки, даёт законный, но текстово иной файл
Что именно прибивает 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 и её последующую починку; воспроизводимый флаг заимствует только порядок, а не остальную текстовую раскладку того писателя
Почему фиксированная дата всё ещё выдавала системные часы?
Исправление в 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обычно копирует неизменённый незашифрованный исходный файл байт в байт вместо повторной сериализации. Воспроизводимый флаг отключает этот ярлык и заставляет выполнить полную перезапись, чтобы применились правила порядка и идентификатора; значит, воспроизводимое сохранение загруженного файла медленнее обычного и никогда не является копией входа. Сравнивайте его с предыдущим воспроизводимым сохранением, а не с оригиналом
Ещё один урок из того же выпуска — о том, что доказывает проходящая проверка и чего она не доказывает. Тестовая фикстура PDF/X-6 вызывала CharProcs.DeleteValue('A'), что освобождало напрямую удерживаемый поток глифа, затем вставляла тот же указатель обратно и вдобавок отдавала один прямой объект ExtGState и словарю ресурсов, и узору. Проверка соответствия проходила через раз на этом обращении к освобождённой памяти и двойном владении, потому что читала то, что случилось держать освобождённая память. Когда структурная проверка мигает, смотрите на владение тестовым входом раньше, чем на валидатор. Воспроизводимый вывод делает эту дисциплину дешевле: как только два сохранения побайтово одинаковы, единственный оставшийся источник мигания — сам граф объектов, и структурное сравнение от каталога вниз его найдёт
Описанные здесь свойства ReproducibleOutput, DeterministicDictionaryOrder и шифрования поставляются в стандартном HotPDF Delphi Component для Delphi и C++Builder, и тот же флаг управляет собственным регрессионным корпусом библиотеки, так что поведение, которое вы получаете в своём наборе тестов, — это поведение, которым компонент и тестируется