Сгенерируйте один и тот же счёт дважды из одного шаблона и одних данных, затем сравните два PDF. Различаются два места: hex-строки в массиве /ID трейлера и строка ModDate в информационном словаре документа — и то и другое взято с часов. В losLab PDF Library SetDeterministicDocumentID(1) плюс зафиксированный ModDate делает файлы побайтово идентичными — для документа без UUID в XMP на каждый прогон и вложений
Очевидная первая попытка — зафиксировать даты и перезапустить. CreationDate и ModDate теперь совпадают, а хэши всё ещё различаются — теперь уже только двумя hex-строками по 32 цифры у самого конца файла, внутри /ID [<…> <…>]. Тот идентификатор вычисляется отдельно от метаданных, и пока он не перестанет зависеть от времени сохранения, кэш сборки, ключуемый по хешу файла, не срабатывает никогда, а контент-адресуемое хранилище держит один блоб на прогон вместо одного на документ
Для чего нужен массив ID трейлера
Трейлерный /ID идентифицирует файл; он не контрольная сумма его содержимого. ISO 32000-1 §14.4 определяет его как массив из двух байтовых строк. Первая — постоянный идентификатор, назначаемый при создании документа и призванный пережить каждую последующую правку. Вторая — изменяемый идентификатор, который писатель освежает при каждой модификации файла. Система управления документами сравнивает пару, чтобы решить, ревизии ли это одного документа или два несвязанных. Table 15 в §7.5.5 делает запись обязательной, когда трейлер несёт /Encrypt, и опциональной иначе, а §14.4 замечает, что её отсутствие может мешать процессам, опирающимся на идентичность файла
Спецификация никогда не говорит, как вычислять значение. Она предлагает MD5-дайджест по вещам вроде текущего времени, пути файла, размера файла и значений информационного словаря, и часы — тот ингредиент, что делает результат уникальным. Уникальность — ровно то, чего ждёшь от маркера идентичности, и ровно то, что ломает воспроизводимость. По умолчанию losLab PDF Library следует тому предложению в его простейшей форме — MD5 от текущей метки времени, поэтому два документа, созданные с разницей в секунду, получают разные постоянные идентификаторы, даже когда все остальные байты совпадают
Включение воспроизводимого идентификатора
Детерминированный режим — переключатель на документ, и по умолчанию он выключен, так что существующий вывод не меняется, пока вы не попросите. SetDeterministicDocumentID принимает 0 или 1 и возвращает 1, когда значение принято, 0 для всего остального; GetDeterministicDocumentID сообщает текущую настройку. SetDocumentIDSeed подаёт явную строку seed, и при заданном seed идентификатор — это MD5 той строки, записанный в оба элемента массива. GetDocumentFileID читает /ID[0] обратно после сохранения, что удобно для журналирования или тестового утверждения
var
Lib: TPDFlib;
FileID: WideString;
begin
Lib := TPDFlib.Create;
try
Lib.SetDeterministicDocumentID(1); // сразу после Create
Lib.SetDocumentIDSeed('invoice-4471-rev3');
Lib.SetInformation(7, 'D:20260101000000Z'); // CreationDate
Lib.SetInformation(8, 'D:20260101000000Z'); // ModDate
Lib.SetOrigin(1);
Lib.DrawText(100, 700, 'Invoice 4471');
Lib.SaveToFile('invoice.pdf');
FileID := Lib.GetDocumentFileID; // одно значение при каждом прогоне
finally
Lib.Free;
end;
end;
Идентификатор вычисляется при сохранении. Щелчок переключателем на позднем этапе построения документа всё равно срабатывает, и изменённый seed доходит до файла при следующем полном сохранении: сохраните с seed A, сохраните снова с seed B — и два вывода несут разные идентификаторы, а возврат к seed A вернёт и исходное значение
Однако включайте переключатель сразу после создания объекта. Шрифтам, картинкам, графическим состояниям и прочим ресурсам страниц даются имена в словаре ресурсов страницы, и пока детерминированный режим включён, те имена идут от счётчика. Всё, что добавлено до включения, хранит случайно сгенерированное имя, из-за чего идентификатор остаётся стабильным, но несколько байтов в потоках содержимого меняются при каждом прогоне
Почему идентификатор всё ещё меняется, когда seed не задан?
Потому что информационный словарь — часть хеша. Оставьте seed пустым или передайте пустую строку в SetDocumentIDSeed, чтобы стереть его, — и losLab PDF Library выводит идентификатор из самого файла: при записи она прогоняет сериализованное тело через SHA-256 и берёт первые 16 байт дайджеста для обоих элементов /ID. Любое изменение содержимого страниц меняет идентификатор — в этом весь смысл. Загвоздка в том, что CreationDate и ModDate живут в том же теле, а подпрограммы сохранения штампуют ModDate текущим временем, если вызывающий его не задал
Lib.SetDeterministicDocumentID(1);
Lib.SetDocumentIDSeed(''); // вывести /ID из сохранённых байтов
// Хеш тела покрывает словарь Info, поэтому даты должны быть
// входными данными сборки. Ключ 8 вдобавок запрещает сохранению штамповать ModDate.
Lib.SetInformation(2, 'Quarterly Report'); // Title
Lib.SetInformation(5, 'reporting-service 4.2'); // Creator
Lib.SetInformation(7, 'D:20260101000000Z'); // CreationDate
Lib.SetInformation(8, 'D:20260101000000Z'); // ModDate
Lib.SaveToFile('report.pdf');
Ключ 8 здесь работает за двоих, и это строка, которую чаще всего забывают. Установка ModDate через SetInformation помечает дату как заданную вызывающим, и с того момента SaveToFile и SaveToString её не трогают. Без этого каждое сохранение пишет свежую метку, хешированное тело меняется, и производный от содержимого идентификатор меняется вместе с ним. Даты берите из исходной записи или из фиксированной эпохи. Now не место в воспроизводимой сборке
Несколько вещей нарочно держатся вне хеша. Случайно сгенерированные имена ресурсов и UUID в записи XMP xmpMM:InstanceID нормализуются перед хешированием, поэтому два прогона, различающиеся только этими токенами, получают одинаковый /ID. Байты файла в тех местах всё же различаются, так что документ в духе PDF/A со свежим instance UUID на каждый прогон даёт вам воспроизводимый идентификатор, но не побайтово идентичный файл. Вложенные файлы — другой источник дрейфа: AddAssociatedFileFromString штампует собственный /ModDate вложения текущим временем, и та дата хешируется как любой другой байт тела
Явный seed или хеш содержимого
Для сборочных артефактов мы бы оставили seed пустым. Идентификатор, производный от содержимого, меняется ровно тогда, когда меняются байты, поэтому кэш, ключуемый по нему, никогда не подсунет устаревший файл, и у вас не появляется второго ключа, который надо держать синхронно с данными. Явный seed подходит другой работе: нижестоящему архиву, который трактует /ID[0] как идентичность документа и ждёт одинаковый идентификатор между перегенерациями одного счёта, даже после смены шаблона. Это лучше отвечает духу §14.4 — ценой того, что два визуально разных файла могут делить идентификатор
Одно следствие содержимого пути заслуживает осознанного решения. Когда вы загружаете существующий PDF и сохраняете его полностью с включённым детерминированным режимом, входящий /ID отбрасывается, а новый зависит только от того, что записано. Два исходных файла, различающихся лишь трейлерными идентификаторами, выйдут с одинаковым /ID. Этого и хочет нормализующий шаг сборки. Если вы правите документ клиента и его идентичность должна пережить правку, оставьте детерминированный режим выключенным для той задачи или сохраняйте инкрементально
Почему перезапись ID ломает зашифрованный PDF?
Потому что в зашифрованном документе /ID[0] — ключевой материал. ISO 32000-1 §7.6.3.3 Algorithm 2 подаёт первый элемент идентификатора файла в вычисление ключа шифрования для стандартного security handler на ревизиях 2–4, вместе с дополненным паролем, значением /O и битами разрешений. Тот ключ затем производит строку /U, которую читатель проверяет при открытии. Ключ выводится при вызове Encrypt или загрузке зашифрованного файла, до любого сохранения, поэтому замена идентификатора во время сохранения произвела бы корректно выглядящий файл, чья парольная проверка провалится у всех, включая вас
Поэтому losLab PDF Library освежает детерминированный идентификатор только на незашифрованных полных сохранениях. Зашифрованный документ хранит тот /ID, который у него уже был, и переключатель на этом пути не действует. Переоткрытие такого файла с пользовательским паролем работает как обычно. И не кладите зашифрованный вывод в побайтовое сравнение: шифрование AES по §7.6.2 начинает каждую зашифрованную строку и поток со случайного 16-байтового вектора инициализации, поэтому две шифровки одного документа никогда не совпадут байт в байт. Если нужна регрессионная проверка — расшифруйте оба и сравните то, что получилось. Обработка ревизий и биты разрешений глубже разобраны в обзоре аудит шифрования и разрешений PDF
Инкрементные сохранения хранят исходный идентификатор
Инкрементное обновление оставляет на месте каждый предыдущий байт файла и дописывает за ним новую ревизию. Постоянство /ID[0] как раз и говорит потребителю, что дописанная ревизия принадлежит тому же документу, что и предыдущие. Переписать её — значит противоречить ревизиям, уже лежащим в файле, и подрезать подписные процессы, ведь подпись покрывает диапазон байтов одной конкретной ревизии. Режим дописывания в детерминированном режиме никогда не трогает идентификатор, что сохраняет нетронутым поведение, описанное в статье инкрементные обновления PDF и дописывание в поток
Держать вывод воспроизводимым со временем
Каждая последующая правка генератора — шанс вернуть значение с часов в файл: водяной знак, печатающий время генерации, ветка кода, прикрепляющая файл, другая опция сохранения. Опции сохранения значат не меньше содержимого: упаковка объектов в сжатые объектные потоки даёт совершенно иную раскладку файла, чем классическая таблица перекрёстных ссылок, как объяснено в заметках о потоках объектов и потоках перекрёстных ссылок, так что держите те настройки фиксированными на конвейер. Самая дешёвая защита — тест, строящий один и тот же документ дважды в памяти и сравнивающий байты
function BuildQuote(const Seed: WideString): AnsiString;
var
Lib: TPDFlib;
begin
Lib := TPDFlib.Create;
try
Lib.SetDeterministicDocumentID(1);
Lib.SetDocumentIDSeed(Seed);
Lib.SetInformation(7, 'D:20260101000000Z');
Lib.SetInformation(8, 'D:20260101000000Z');
Lib.SetOrigin(1);
Lib.DrawText(100, 700, 'Quote 8812');
Result := Lib.SaveToString;
finally
Lib.Free;
end;
end;
// Две независимые сборки обязаны дать одну последовательность байтов
if BuildQuote('quote-8812') = BuildQuote('quote-8812') then
WriteLn('reproducible')
else
WriteLn('nondeterminism leaked into the output');
Тот же стенд может сравнить последовательный и параллельный прогон RecompressStreams на одном источнике: загрузите его в два экземпляра TPDFlib, включите детерминированные идентификаторы и зафиксируйте ModDate в обоих, выставьте SetOptimizationWorkers(1) одному и большее число воркеров другому, и сравните два результата SaveToString. С вынутыми из обоих файлов часами любое оставшееся различие приходит от самой рекомпрессии
Воспроизводимые трейлерные идентификаторы, контроль метаданных и инкрементное сохранение — всё часть losLab PDF Library for Delphi and C++Builder, в чьём справочнике перечислены и упомянутые здесь опции шифрования