Два PDF от одного и того же кода счёта-фактуры, записанные с разницей в секунду, почти никогда не являются одним и тем же файлом. Бинарное сравнение спотыкается сперва в словаре Info, где /CreationDate уехал на секунду, затем в XMP-пакете, затем на /ID трейлера. Включите AES-256 — и сравнение рассыпется почти на каждом потоке, ведь ключ шифрования файла сам был свежесгенерирован. Маскировка дат перед сравнением чинит первые два различия и ни одно из остальных. Тест на золотой файл становится доверенным лишь тогда, когда каждое значение, которое писатель берёт откуда-то помимо документа, зафиксировано в точке, где оно читается
Четыре значения, которые не приходят из документа
HotPDF Delphi Component, как и любой корректный писатель PDF, заполняет четыре рода значений вне содержимого страницы: часы, идентификатор документа, криптографический источник случайности и порядок записей словарей в памяти. У ISO 32000-1 есть причина для каждого из них
- Часы. Словарь Info несёт
/CreationDateи/ModDate(ISO 32000-1 §14.3.3, таблица 317) строкамиD:YYYYMMDDHHmmSSс суффиксом часового пояса (§7.9.4), а XMP-пакет повторяет тот же момент какxmp:CreateDateиxmp:ModifyDate. Все они рендерятся из одной метки времени создания, которую компонент берёт изNowпри конструировании - Идентификатор. Массив
/IDв трейлере (§14.4) хранит постоянный идентификатор и идентификатор изменения. По умолчанию первый — это MD5 от имени файла плюс текущее время с точностью до миллисекунды, а второй снова хеширует это значение вместе с системным счётчиком тиков - Случайные байты. Документу AES-256 нужны 32-байтовый ключ шифрования файла, 8-байтовая validation-соль и 8-байтовая key-соль для
/U, ещё одна пара для/O, четыре байта-заполнителя внутри/Permsи 16-байтовый вектор инициализации перед каждой зашифрованной строкой и потоком. ISO 32000-2 §7.6.4.4.7 хочет, чтобы соли были случайными. RC4 и AES-128 выводят ключ из паролей и первого элемента/ID(ISO 32000-1 §7.6.3.3), так что свежего идентификатора самого по себе достаточно, чтобы переключить весь файл - Порядок. Словарь PDF — неупорядоченное отображение (§7.3.7). Писатель, обходящий свой список в памяти, выдаёт ключи в том порядке, как их добавил код, поэтому два пути кода, строящие один и тот же словарь ресурсов в разной последовательности, дают законный, но текстуально другой файл
Всё остальное и так следует из входа: потоки содержимого, сжатые данные изображений и смещения таблицы перекрёстных ссылок повторяются для одних и тех же вызовов рисования. Даже единственное поле, выглядящее случайным, им не является: шестибуквенный префикс внедряемого сабсета шрифта (ISO 32000-1 §9.6.4, часть ABCDEF+ в ABCDEF+Arial) вычисляется из набора глифов, которые сабсет оставил, поэтому тот же текст даёт тот же префикс в каждом прогоне. Шум сидит в метаданных и в слое безопасности — вот почему одно свойство способно его убрать
Что прибивает ReproducibleOutput
Установка ReproducibleOutput := True до BeginDoc заменяет каждый из четырёх источников фиксированным значением в тех же путях кода, которые иначе потянулись бы к часам или к генератору случайных чисел. Никакого прохода очистки после этого нет. Свойство по умолчанию равно 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 ставит метку времени создания на 1 января 2026 года, 00:00 локального времени, и поскольку и даты Info, и даты XMP рендерятся из одного этого значения, одно присваивание покрывает их все. Та же ветка засевает внутренний идентификатор документа константной строкой вместо имени файла и часов. При записи файла /ID трейлера вычисляется как MD5 по каноническому экспорту всего графа объектов, в котором цифры каждой строки даты D: сперва обнулены, поэтому метка времени не может просочиться обратно через хеш. Оба элемента /ID получают это значение. Загруженный документ, который вы шифруете, ни разу не вызвав BeginDoc (включите ActivateProtection после LoadFromFile), получает такой же идентификатор из содержимого
Случайные байты заменяются константами. Ключ файла AES-256, все четыре соли и заполнитель /Perms заполняются байтом $5A, а каждый вектор инициализации CBC — для строк и потоков AES-128 так же, как AES-256 — становится шаблоном 14 * (1 + I) для байта I. Этот шаблон ненулевой намеренно. Вектор из одних нулей — хрестоматийно слабый выбор для CBC, и регрессионному набору нет дела до того, чтобы его нормализовать. С зафиксированными ключом, солями и векторами /U, /UE, /O, /OE, /Perms и каждый зашифрованный поток выходят идентичными при втором прогоне
Наконец, SaveToStream включает DeterministicDictionaryOrder всякий раз, когда установлен воспроизводимый флаг, и делает это до выбора между линеаризованной, object-stream и классической раскладками, поэтому вывод с LinearizeOutput или UseObjectStreams тоже отсортирован. Ключи упорядочиваются по сырым байтам, ключ-префикс другого ключа сортируется первым, а ничьи разыгрывает исходная позиция. Это тот порядок, которым пользуется диагностический писатель, описанный в статье о ручной правке PDF и последующем ремонте; воспроизводимый флаг заимствует только этот порядок и ничего из текстового формата того писателя. Включать один лишь DeterministicDictionaryOrder — полумера. Ключи выстроятся, но даты Info и обе строки /ID трейлера всё ещё несут часы, поэтому два сохранения продолжают расходиться в тех же четырёх местах
Тест, который не может пройти по везению
Дата фиксируется в BeginDoc, потому что конструктор отрабатывает раньше, чем вызывающий успеет установить свойство. Обычная последовательность — Create, затем ReproducibleOutput := True, затем BeginDoc, и только последний видит флаг с его финальным значением. У этого порядка есть прямое следствие для дизайна тестов. Два сохранения, уложившиеся в одну и ту же секунду системных часов, случайно пишут одну и ту же строку D:, поэтому быстрый тест проходит независимо от того, прибита дата или нет, — и падает месяцы спустя на более медленном сборочном агенте
Лекарство дешёвое: ждите 1100 мс между двумя сохранениями, чтобы метка времени гарантированно пересекла границу секунды, прогоняйте случай для обычного, AES-128 и AES-256 вывода с настоящими паролями на шифрованных вариантах и сообщайте смещение первого различающегося байта, а не голое true или false. Смещение указывает на один объект. Булево — на весь файл
uses
System.SysUtils, System.IOUtils, HPDFDoc;
function SaveOnce(const Target: string; Encrypt: Boolean;
KeyType: THPDFKeyType): TBytes;
var
Pdf: THotPDF;
begin
Pdf := THotPDF.Create(nil);
try
Pdf.AutoLaunch := False;
Pdf.FileName := Target;
Pdf.ReproducibleOutput := True;
Pdf.VerifySavedOutput := True; // строгая перезагрузка сразу после EndDoc
if Encrypt then
begin
Pdf.OwnerPassword := 'owner';
Pdf.UserPassword := 'user';
Pdf.CryptKeyLength := KeyType; // aes128 или aes256
Pdf.ActivateProtection := True;
end;
Pdf.BeginDoc;
Pdf.CurrentPage.SetFont('Arial', [], 12);
Pdf.CurrentPage.TextOut(40, 40, 0, 'reproducible save');
Pdf.EndDoc;
finally
Pdf.Free;
end;
Result := TFile.ReadAllBytes(Target);
end;
// Ваш собственный помощник: -1 при равенстве, иначе смещение первого различия
function FirstDifference(const A, B: TBytes): Integer;
var
I, N: Integer;
begin
N := Length(A);
if Length(B) < N then
N := Length(B);
for I := 0 to N - 1 do
if A[I] <> B[I] then
Exit(I);
if Length(A) <> Length(B) then
Result := N
else
Result := -1;
end;
procedure CheckReproducible(Encrypt: Boolean; KeyType: THPDFKeyType);
var
A, B: TBytes;
Offset: Integer;
begin
A := SaveOnce('run-a.pdf', Encrypt, KeyType);
Sleep(1100); // гарантировать другую секунду метки времени
B := SaveOnce('run-b.pdf', Encrypt, KeyType);
Offset := FirstDifference(A, B);
if Offset >= 0 then
raise Exception.CreateFmt('Saves differ at byte offset %d', [Offset]);
end;
Вызывайте CheckReproducible(False, aes128), CheckReproducible(True, aes128) и CheckReproducible(True, aes256); на обычном прогоне тип ключа игнорируется. VerifySavedOutput в этом тесте отрабатывает свою цену. С ним EndDoc перечитывает только что записанный файл в свежем экземпляре THotPDF с отключённым восстановлением таблицы перекрёстных ссылок, используя пользовательский пароль, если документ защищён, и бросает исключение, если такая загрузка не удалась. Изменение, делающее вывод стабильным и одновременно нечитаемым, пролетело бы сквозь зелёный diff; перезагрузка его остановит
Остаётся ли воспроизводимый зашифрованный PDF защищённым?
Нет. Документ, зашифрованный под ReproducibleOutput, не имеет настоящей защиты, и флагу место в положении «выключено» для всего, что покидает тестовый каталог. Ключ файла AES-256 — тридцать два байта $5A. Пароль по-прежнему сторожит обёртки /UE и /OE, но завёрнуто в них опубликованное постоянное значение, поэтому всякий, кто его знает, расшифрует каждый поток содержимого без пароля. Фиксированные соли к тому же убивают уникальность на файл, на которую опирается ISO 32000-2 §7.6.4.4.7, чтобы один пароль не давал одну и ту же строку /U в двух файлах. Статья о настройке AES-256 описывает, что дают свойства шифрования при живом источнике случайности; под воспроизводимым флагом ничего из этого не действует. Прячьте свойство за тестовой конфигурацией, а не за настройкой, до которой дотянется производственный путь кода
AES-GCM обрабатывается иначе. С CryptKeyLength := aesgcm (только PDF 2.0) компонент берёт 8-байтовый префикс nonce из системного источника случайности, что бы ни говорил ReproducibleOutput, а затем считает вверх для каждой зашифрованной строки или потока. Это правильное поведение. GCM жестоко ломается, когда nonce повторяется под одним ключом: два шифротекста выдают XOR своих открытых текстов, и атакующий может восстановить достаточно, чтобы подделать теги аутентификации. Прибитый ключ плюс прибитый nonce сделали бы такое повторение неизбежным в каждом файле, что выдаст набор. Поэтому зашифрованные золотые файлы используют aes128 или aes256, работающие в режиме CBC
С идентификатором компромисс мягче. ISO 32000-1 §14.4 задумывает второй элемент /ID меняющимся при каждом изменении, а воспроизводимое сохранение пишет в оба слота одно значение. Поскольку это значение — хеш графа объектов, разный контент по-прежнему получает разные идентификаторы. Читатель, ключующий кэш по /ID, хранилище аннотаций или сайдкар данных форм, всё равно сочтёт два воспроизводимых сохранения одного контента одним файлом. В тестовом наборе это ровно то, что нужно
Входы, остающиеся за пределами писателя
ReproducibleOutput покрывает энтропию, которую писатель вносит сам. Ещё несколько входов приходят со сборочной машины или из операций, которые по определению фиксируют новый момент времени
- Суффикс часового пояса. Каждая строка даты PDF кончается локальным смещением UTC, поэтому одна и та же фиксированная дата читается как
D:20260101000000+08'00'на одном сборочном агенте иD:20260101000000-05'00'на другом. Золотые файлы держатся между прогонами на одной машине или между машинами в одном поясе. Если файлы ездят между офисами, прибейте часовой пояс агентов - Инкрементальные обновления.
SaveIncrementalUpdateсохраняет постоянный идентификатор источника и строит новый идентификатор изменения из предыдущего, целевого пути, счётчика тиков и текущего времени. Дописанная секция — новое изменение по определению. Сравнивайте полные перезаписи, а не дописанные дельты - Ярлык сквозной передачи.
SaveLoadedDocumentобычно копирует немодифицированный, незашифрованный источник байт в байт вместо повторной сериализации. Под флагом файл всегда переписывается, так что правила порядка и идентификатора применяются, что делает сохранение медленнее и никогда не копией входа. Сравнивайте его с предыдущим воспроизводимым сохранением, а не с оригиналом - Метки времени, добавленные вами.
AddDocumentAttachmentштампует/CreationDateи/ModDateв/Paramsвнедрённого файла (ISO 32000-1 §7.11.4) моментом вызова, а подпись записывает время подписания в/M. Эти даты описывают события, которых запросил вызывающий, поэтому флаг их не трогает. Держите вложения и подписи вне побайтовых случаев или маскируйте эти строки в сравнении - Установленные шрифты. Сабсет TrueType выкраивается из файла шрифта, установленного на машине, где идёт тест. Два агента с разными выпусками Arial внедрят разные программы глифов для одного текста, и префикс сабсета может измениться вместе с ними. Возите файлы шрифтов вместе со сборочным образом
Загруженные документы следуют тем же правилам. Установите свойство до загрузки, внесите правки и сохраните через SaveLoadedDocument; вывод — нормализованная перезапись, которую можно закоммитить как эталон для следующего прогона
var
Pdf: THotPDF;
begin
Pdf := THotPDF.Create(nil);
try
Pdf.AutoLaunch := False;
Pdf.ReproducibleOutput := True; // установить до загрузки
if Pdf.LoadFromFile('incoming.pdf') <= 0 then
raise Exception.Create('incoming.pdf did not load');
// ... правки под тестом ...
Pdf.SaveLoadedDocument('normalized.pdf'); // полная перезапись, отсортированные ключи
finally
Pdf.Free;
end;
end;
Побайтово одинаковые сохранения заодно загоняют в угол мигающую проверку соответствия. Когда писатель стабилен, проверка, проходящая на одном прогоне и падающая на следующем, реагирует на граф объектов, который строит тест, — скажем, один прямой словарь, отданный двум родителям, — поэтому осмотрите вход теста раньше валидатора. Структурный diff от каталога вниз показывает, какой объект переехал между двумя прогонами
ReproducibleOutput, DeterministicDictionaryOrder, VerifySavedOutput и свойства шифрования, использованные выше, — часть HotPDF Delphi Component, который поставляется и для Delphi, и для C++Builder