losLab PDF Library може створювати побайтово ідентичний вивід PDF для ідентичних вхідних даних, щойно ви викличете SetDeterministicDocumentID(1). За замовчуванням масив /ID трейлера — це дайджест MD5 від системного годинника, тож два запуски того самого генератора відрізняються щонайменше цими байтами. Детермінований режим виводить /ID натомість зі стабільного сіда, що відновлює відтворюваність збірок
Симптом зазвичай проявляється в CI ще до того, як хтось почне його шукати. Шаблон не змінювався, вхідний запис не змінювався, шрифти не змінювалися, а згенерований PDF усе одно хешується по-різному на кожному запуску конвеєра. Кеші збірок ніколи не влучають. Content-addressable сховище накопичує новий блоб на кожну нічну збірку. Побайтові регресійні diff'и спалахують на файлах, яких ніхто не торкався. Простежте diff аж до реальних байтів — і майже завжди це та сама жменька шістнадцяткових цифр у трейлері файлу
Для чого потрібен масив ID у трейлері
Трейлерний /ID — це маркер ідентичності файлу, а не контрольна сума вмісту. ISO 32000-1 §14.4 визначає його як масив із двох рядків байтів: перший елемент — постійний ідентифікатор, присвоєний при створенні документа, покликаний пережити кожне подальше редагування, а другий — змінний ідентифікатор, який записувач оновлює щоразу, коли файл модифікується. Разом вони дозволяють системі визначити, чи два файли є ревізіями одного документа, чи двома непов'язаними документами. §7.5.5 робить цей запис фактично обов'язковим на практиці, оскільки трейлер повинен нести /ID, коли він також несе /Encrypt
У специфікації нічого не сказано про те, як обчислювати значення. Рекомендується дайджест таких речей, як поточний час, шлях файлу, розмір файлу та словник інформації про документ, і саме системний годинник — той інгредієнт, що робить результат унікальним. Це рівно та властивість, яку хочеться мати для ідентичності, і рівно та властивість, що руйнує відтворюваність, тому це має бути явним перемикачем, а не тихою зміною поведінки
Чому та сама збірка щоразу видає інший PDF?
Тому що ідентифікатор за замовчуванням виводиться з моменту генерації. Історично losLab PDF Library будувала рядки /ID з MD5 поточного часової мітки, тож документ, створений двічі з різницею в секунду, несе два різні постійні ідентифікатори, навіть коли кожен інший байт у файлі ідентичний. Витрати нижче за течією реальні: система збірки, що ключує артефакти за хешем, ніколи не може повторно використати крок з PDF, дедуплікаційне сховище об'єктів зберігає одну копію на збірку замість однієї копії на документ, а рецензент, що дивиться на бінарний diff, повинен довести, що єдина зміна — це шум, перш ніж довіряти решті diff. Детермінована генерація /ID існує саме для усунення цього шуму, у тому ж дусі, що й робота зі стабільності макета, описана в нотатках про потоки об'єктів і потоки перехресних посилань
Перехід на відтворюваний ідентифікатор
Детермінований режим — опційний, налаштовується для кожного документа окремо і за замовчуванням вимкнений, тож наявний вивід не змінюється, доки ви його не запросите. SetDeterministicDocumentID приймає 0 або 1 і повертає 1, коли значення прийнято, 0 для будь-чого поза діапазоном; GetDeterministicDocumentID повідомляє поточний стан. SetDocumentIDSeed задає явний рядок-сід, що переважає над усім іншим, а передавання порожнього сіда повертає до виведеного сіда. GetDocumentFileID зчитує /ID[0] після збереження, тож ви можете його логувати або перевіряти assert'ом
var
Lib: TPDFlib;
FileID: WideString;
begin
Lib := TPDFlib.Create;
try
Lib.SetDeterministicDocumentID(1);
Lib.SetDocumentIDSeed('invoice-4471-rev3');
Lib.SetOrigin(1);
Lib.DrawText(100, 700, 'Invoice 4471');
Lib.SaveToFile('invoice.pdf');
FileID := Lib.GetDocumentFileID; // identical on every run
finally
Lib.Free;
end;
end;
Оновлення відбувається в момент збереження, а не коли ви перемикаєте прапорець, тож увімкнення детермінованого режиму пізно в процесі побудови документа все одно набуває чинності. Це також означає, що змінений сід досягає файлу при наступному повному збереженні: встановіть сід A, збережіть, встановіть сід B, збережіть — і два файли нестимуть різні ідентифікатори, а відновлення сіда A відновлює початкове значення. Явний сід — правильний вибір щоразу, коли ваш документ має природний стабільний ключ, такий як номер рахунку-фактури, ревізія запису або ідентифікатор комміта git, бо це відв'язує ідентифікатор від випадкових метаданих
Звідки береться сід, коли ви його не задаєте?
Без явного сіда losLab PDF Library виводить його зі стану документа, який мав би бути незмінним при ідентичних повторних генераціях: заголовок версії PDF, кількість сторінок і кожен запис словника інформації про документ. Значення рядків і імен беруться дослівно, інші типи об'єктів дають свою серіалізовану форму, і все це хешується в рядки /ID. Важливий наслідок полягає в тому, що CreationDate і ModDate є частиною словника інформації і тому за задумом частиною сіда. Два запуски отримують той самий ідентифікатор, лише коли вони справді видають однакові метадані документа
Lib.SetDeterministicDocumentID(1);
// No SetDocumentIDSeed: the seed is derived from document state,
// so the timestamps in the Info dictionary have to be pinned.
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');
Закріплення ModDate ключем 8 виконує подвійну роль, і саме тут криється пастка. Сам по собі детермінований /ID не робить файл побайтово ідентичним, бо шлях збереження проставляє ModDate поточним часом, якщо викликач не встановив його явно. Встановлення ключа 8 позначає значення як надане викликачем і придушує цю позначку. Якщо вам потрібен відтворюваний файл, а не лише відтворюваний ідентифікатор, ставтеся до часових міток метаданих як до вхідних даних збірки: виводьте їх із вихідного запису або з фіксованої епохи, ніколи з Now
Чому перезапис ID ламає зашифрований PDF?
Тому що /ID[0] у зашифрованому документі — це не просто метадані, це ключовий матеріал. ISO 32000-1 §7.6.3.3 Алгоритм 2 подає перший елемент ідентифікатора файлу в обчислення ключа шифрування для стандартного обробника безпеки в ревізіях 2–4, поряд із доповненим паролем, значенням /O і бітами дозволів. Виведений ключ потім породжує рядок перевірки /U, який читач перевіряє при відкритті, а ключ файлу виводиться та кешується, коли ви викликаєте Encrypt або коли завантажується зашифрований документ — обидва відбуваються перед збереженням. Тому перезапис ідентифікатора під час збереження видав би структурно валідний файл, перевірка /U якого провалилася б при повторному відкритті: не тонке пошкодження, а документ, який ніхто не зможе відкрити, включно з вами. Ось чому детермінований оновлювач обмежений документами, що не несуть стан шифрування, і чому зашифрований документ зберігає той /ID, що вже мав, незалежно від детермінованого режиму, і налаштування просто не має ефекту на цьому шляху. Пов'язана обробка ревізій і семантика дозволів охоплена в огляді аудиту шифрування та дозволів PDF. Зауважте також, що шлях відновлення шифрування оновлює лише /ID[1], ідентифікатор зміни, точно так, як і задумано в §14.4
Чому інкрементні збереження зберігають початковий ідентифікатор
Друга межа — це режим доповнення. Інкрементне оновлення залишає кожен попередній байт файлу недоторканим і записує нову ревізію після нього, і сталість /ID[0] у розумінні §14.4 — це саме те, що повідомляє споживачу, що нова ревізія належить тому ж документу, що й попередня. Перезапис її розірвав би цей зв'язок, суперечив би ревізіям, що вже перебувають у файлі, і втрутився б у семантику підпису, оскільки підпис покриває діапазон байтів конкретної ревізії конкретного документа. Тому losLab PDF Library оновлює детермінований ідентифікатор лише при повних збереженнях і ніколи в режимі доповнення, що зберігає гарантію, описану в статті про інкрементні оновлення PDF і доповнення до потоку недоторканою
Одна точка звуження для генерації ідентифікатора
Уся генерація /ID у losLab PDF Library тепер проходить через єдину внутрішню процедуру, NewFileIDString, і саме це робить детермінований перемикач надійним, а не латкою на одному шляху коду. Створення порожнього документа, ліниве створення відсутнього масиву /ID за потреби та шлях відновлення відбитка шифрування — усі викликають її, тож є рівно одне місце, звідки системний годинник міг би просочитися назад. Це також означає, що майбутні варіанти, наприклад ідентифікатор, виведений з вмісту, — зміна однієї функції, а не аудит усього серіалізатора
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;
// Regression guard: two independent builds, one byte sequence.
if BuildQuote('quote-8812') = BuildQuote('quote-8812') then
WriteLn('reproducible')
else
WriteLn('nondeterminism leaked into the output');
Вбудуйте це порівняння у свій набір тестів, перш ніж покладатися на відтворюваний вивід деінде, бо воно голосно провалиться в мить, коли якась нова функція повторно внесе часову мітку. Відтворюваність — властивість, що інакше тихо розкладається, а один assert над двома збереженнями в пам'яті майже нічого не коштує запускати на кожній збірці
API детермінованого ідентифікатора, показаний тут, постачається разом із losLab PDF Library для Delphi та C++Builder, поряд з повною довідкою про інформацію документа, шифрування та інкрементне збереження