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

Детерминированный PDF ID в Delphi для воспроизводимых сборок

Сгенерируйте один и тот же счёт дважды из одного шаблона и одних данных, затем сравните два 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 от текущей метки времени, поэтому два документа, созданные с разницей в секунду, получают разные постоянные идентификаторы, даже когда все остальные байты совпадают

Диаграмма, сравнивающая идентичные сборки Delphi, чьи PDF trailer ID по умолчанию на основе настенных часов различаются, с побайтово идентичным детерминированным выводом
Идентификатор trailer по умолчанию замешивает в идентичность файла настенные часы, тогда как детерминированный режим выводит его из стабильного seed

Включение воспроизводимого идентификатора

Детерминированный режим — переключатель на документ, и по умолчанию он выключен, так что существующий вывод не меняется, пока вы не попросите. 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 текущим временем, если вызывающий его не задал

PDF Library for Delphi: диаграмма решений, где SetDocumentIDSeed имеет приоритет над производным сидом состояния документа, используемым для детерминированных PDF id
Явно заданный seed перекрывает всё остальное, а производный путь хеширует записи информационного словаря, включая CreationDate и 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

PDF Library for Delphi: карта ограничений детерминированного PDF id, охватывающая полные сохранения, зашифрованные документы, инкрементальные обновления и путь восстановления шифрования
Обновлять идентификатор разрешено только незашифрованным полным сохранениям, поскольку зашифрованные файлы считают /ID[0] ключевым материалом, а добавляемые ревизии должны оставаться связанными

Инкрементные сохранения хранят исходный идентификатор

Инкрементное обновление оставляет на месте каждый предыдущий байт файла и дописывает за ним новую ревизию. Постоянство /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, в чьём справочнике перечислены и упомянутые здесь опции шифрования