losLab PDF Library може да произвежда байт идентичен PDF изход за идентичен вход, щом извикате SetDeterministicDocumentID(1). По подразбиране масивът /ID в trailer-а е MD5 дайджест на текущото време на системния часовник, така че два пъска на един и същ генератор се различават поне в тези байтове. Детерминираният режим извежда /ID от стабилен seed вместо това, което възстановява възпроизводимите билдове
Симптомът обикновено се проявява в CI, преди някой да го потърси. Шаблонът не се е променил, входният запис не се е променил, шрифтовете не са се променили, а генерираният PDF все още хешира различно при всяко изпълнение на pipeline-а. Кешовете за билдове никога не улучват. Хранилището с адресиране по съдържание натрупва по един свеж blob на нощен билд. Разликите на байтово ниво светват при файлове, които никой не е пипал. Проследите ли разликата до реалните байтове, почти винаги е една и съща шепа шестнайсетични цифри, седящи в trailer-а на файла
За какво служи масивът с trailer ID
Trailer /ID е маркер за идентичност на файла, не контролна сума на съдържанието. ISO 32000-1 §14.4 го дефинира като масив от два байтови низа: първият елемент е постоянният идентификатор, назначен при създаването на документа, който е предназначен да преживее всяка по-късна редакция, а вторият елемент е променящият се идентификатор, който писателят опреснява всеки път, когато файлът бъде модифициран. Заедно те позволяват на система да реши дали два файла са ревизии на един документ или два несвързани документа. §7.5.5 прави записа на практика задължителен, тъй като trailer-ът трябва да носи /ID винаги когато носи и /Encrypt
В спецификацията не е казано как да се изчисли стойността. Препоръката е дайджест от неща като текущото време, пътя на файла, размера на файла и речника с информация за документа, а системният часовник е съставката, която прави резултата уникален. Точно това е свойството, което искате за идентичност, и точно това свойство разрушава възпроизводимостта, затова то трябва да бъде изричен превключвател, а не тиха промяна на поведението
Защо един и същ билд произвежда различен PDF всеки път?
Защото идентификаторът по подразбиране се извежда от момента на генериране. Исторически losLab PDF Library изгражда низовете на /ID от MD5 на текущата времева марка, така че документ, създаден два пъти на разстояние една секунда, носи два различни постоянни идентификатора, дори когато всеки друг байт във файла е идентичен. Разходът надолу по веригата е реален: система за билдове, която ключира артефакти по хеш, никога не може да преизползва PDF стъпка, дедупликиращо обектно хранилище пази по едно копие на билд вместо по едно копие на документ, а рецензент, гледащ бинарна разлика, трябва да докаже, че единствената промяна е шум, преди да се довери на останалата част от разликата. Детерминираното генериране на /ID съществува, за да премахне този шум, в същия дух като работата по стабилността на layout-а, описана в бележките за обектни потоци и потоци за кръстосани референции
Превключване към възпроизводим идентификатор
Детерминираният режим е opt-in, per документ, и изключен по подразбиране, така че съществуващият изход остава непроменен, докато не го поискате. SetDeterministicDocumentID приема 0 или 1 и връща 1, когато стойността е приета, 0 за всичко извън обхвата; GetDeterministicDocumentID отчита текущото състояние. SetDocumentIDSeed подава изричен низ seed, който има превес над всичко друго, а подаването на празен seed връща извеждания seed. GetDocumentFileID отчита /ID[0] след запазването, така че можете да го логвате или да правите проверка върху него
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;
Опресняването се случва в момента на запазване, не когато превключите флага, така че активирането на детерминиран режим късно в изграждането на документа все пак взема ефект. Това означава още, че променен seed достига до файла при следващото пълно запазване: задайте seed A, запазете, задайте seed B, запазете, и двата файла носят различни идентификатори, а възстановяването на seed A възстановява оригиналната стойност. Изричен seed е правилният избор, когато документът ви има естествен стабилен ключ, като номер на фактура, ревизия на запис или идентификатор на git commit, защото развързва идентификатора от случайни метаданни
Откъде идва seed-ът, когато не подадете такъв?
Без изричен seed losLab PDF Library извежда такъв от състоянието на документа, което би трябвало да е инвариантно при идентични повторни генерирания: заглавието с PDF версия, броя страници и всеки запис в речника с информация за документа. Стойностите тип низ и име се вземат дословно, другите типове обекти допринасят със сериализираната си форма, и цялото нещо се хешира в низовете на /ID. Важното следствие е, че CreationDate и ModDate са част от речника с информация и следователно съзнателно част от seed-а. Два пъска печелят един и същ идентификатор само когато наистина произвеждат едни и същи метаданни на документа
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, подава първия елемент на идентификатора на файла в изчислението на ключа за шифроване за стандартния handler за сигурност при ревизии от 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');
Свържете това сравнение с тестовия си пакет, преди да разчитате на възпроизводим изход навсякъде другаде, защото то се проваля шумно в момента, в който нова функционалност повторно въведе времева марка. Възпроизводимостта иначе е свойство, което тихо се разпада, а едно единствено твърдение върху две запазвания в паметта струва почти нищо за изпълнение при всеки билд
API-то за детерминиран идентификатор, показано тук, се доставя с losLab PDF Library за Delphi и C++Builder, заедно с пълната справка за информация за документа, шифроване и инкрементално запазване