losLab PDF Library potrafi wygenerować identyczny bajtowo wynik PDF dla identycznych danych wejściowych, wystarczy wywołać SetDeterministicDocumentID(1). Domyślnie tablica /ID w trailerze to skrót MD5 z zegara systemowego, więc dwa uruchomienia tego samego generatora różnią się przynajmniej tymi bajtami. Tryb deterministyczny wyprowadza /ID ze stabilnego seeda zamiast tego, co przywraca powtarzalność buildów
Objaw zwykle ujawnia się w CI, zanim ktokolwiek zacznie go szukać. Szablon się nie zmienił, rekord wejściowy się nie zmienił, czcionki się nie zmieniły, a wygenerowany PDF wciąż ma inny hash przy każdym uruchomieniu pipeline'u. Cache buildów nigdy nie trafia. Content addressable storage gromadzi świeży blob przy każdym nocnym buildzie. Różnice na poziomie bajtów zapalają się na plikach, których nikt nie dotykał. Prześledź różnicę aż do faktycznych bajtów, a niemal zawsze okaże się, że to ta sama garść cyfr szesnastkowych siedząca w trailerze pliku
Do czego służy tablica ID w trailerze
Trailer /ID to znacznik tożsamości pliku, nie suma kontrolna treści. ISO 32000-1 §14.4 definiuje ją jako tablicę dwóch ciągów bajtowych: pierwszy element to trwały identyfikator przypisany przy tworzeniu dokumentu, który ma przetrwać każdą późniejszą edycję, a drugi element to identyfikator zmienny, odświeżany przez zapisujący program przy każdej modyfikacji pliku. Razem pozwalają systemowi rozstrzygnąć, czy dwa pliki to rewizje jednego dokumentu, czy dwa niepowiązane dokumenty. §7.5.5 czyni ten wpis w praktyce niemal obowiązkowym, ponieważ trailer musi nieść /ID zawsze, gdy niesie też /Encrypt
Specyfikacja nigdzie nie mówi, jak obliczyć tę wartość. Zaleceniem jest skrót z takich rzeczy jak bieżący czas, ścieżka pliku, rozmiar pliku i słownik informacji o dokumencie, a to zegar systemowy jest składnikiem czyniącym wynik unikalnym. To dokładnie ta właściwość, której chcesz dla tożsamości, i dokładnie ta właściwość, która niszczy powtarzalność — dlatego musi to być jawny przełącznik, a nie cicha zmiana zachowania
Dlaczego ten sam build produkuje za każdym razem inny PDF?
Ponieważ domyślny identyfikator jest wyprowadzany z momentu generacji. Historycznie losLab PDF Library budowała ciągi /ID ze skrótu MD5 bieżącego znacznika czasu, więc dokument utworzony dwukrotnie w odstępie jednej sekundy niesie dwa różne trwałe identyfikatory, nawet gdy każdy inny bajt pliku jest identyczny. Koszt po stronie odbiorczej jest realny: system budujący, który indeksuje artefakty po hashu, nigdy nie może ponownie użyć kroku generowania PDF, deduplikujący magazyn obiektów przechowuje jedną kopię na build zamiast jednej kopii na dokument, a recenzent patrzący na binarny diff musi udowodnić, że jedyną zmianą jest szum, zanim zaufa reszcie różnicy. Deterministyczne generowanie /ID istnieje, by usunąć ten szum, w tym samym duchu co praca nad stabilnością układu opisana w notatkach o strumieniach obiektów i strumieniach cross reference
Przełączanie na powtarzalny identyfikator
Tryb deterministyczny jest opcjonalny, per dokument, i domyślnie wyłączony, więc istniejący wynik pozostaje niezmieniony, dopóki o niego nie poprosisz. SetDeterministicDocumentID przyjmuje 0 lub 1 i zwraca 1, gdy wartość została zaakceptowana, 0 dla czegokolwiek spoza zakresu; GetDeterministicDocumentID raportuje bieżący stan. SetDocumentIDSeed dostarcza jawny ciąg seeda, który wygrywa ze wszystkim innym, a przekazanie pustego seeda przywraca seed wyprowadzany. GetDocumentFileID odczytuje /ID[0] po zapisie, więc możesz to zalogować albo na tym zbudować asercję
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;
Odświeżenie następuje w momencie zapisu, nie w momencie przełączenia flagi, więc włączenie trybu deterministycznego późno w trakcie budowania dokumentu wciąż zadziała. Oznacza to też, że zmieniony seed dociera do pliku przy kolejnym pełnym zapisie: ustaw seed A, zapisz, ustaw seed B, zapisz, a te dwa pliki niosą różne identyfikatory, natomiast przywrócenie seeda A przywraca oryginalną wartość. Jawny seed jest właściwym wyborem zawsze, gdy dokument ma naturalny stabilny klucz, taki jak numer faktury, rewizja rekordu czy identyfikator commita git, ponieważ oddziela identyfikator od przypadkowych metadanych
Skąd bierze się seed, gdy go nie podasz?
Bez jawnego seeda losLab PDF Library wyprowadza go ze stanu dokumentu, który powinien być niezmienny przy identycznych regeneracjach: nagłówka wersji PDF, liczby stron oraz każdego wpisu w słowniku informacji o dokumencie. Wartości typu string i name są brane dosłownie, inne typy obiektów wnoszą swoją zserializowaną formę, a całość jest haszowana do ciągów /ID. Ważną konsekwencją jest to, że CreationDate i ModDate są częścią słownika informacji, a więc celowo częścią seeda. Dwa uruchomienia zyskują ten sam identyfikator tylko wtedy, gdy naprawdę produkują te same metadane dokumentu
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');
Przypięcie ModDate pod kluczem 8 pełni podwójną rolę, i to jest właśnie to, na czym ludzie się łapią. Sam deterministyczny /ID nie czyni pliku identycznym bajtowo, ponieważ ścieżka zapisu stempluje ModDate bieżącym czasem, chyba że wywołujący ustawił go jawnie. Ustawienie klucza 8 oznacza wartość jako dostarczoną przez wywołującego i tłumi ten stempel. Jeśli chcesz powtarzalnego pliku, a nie tylko powtarzalnego identyfikatora, traktuj znaczniki czasu w metadanych jak dane wejściowe buildu: wyprowadzaj je z rekordu źródłowego albo ze stałej epoki, nigdy z Now
Dlaczego przepisanie ID psuje zaszyfrowany PDF?
Ponieważ /ID[0] w zaszyfrowanym dokumencie to nie tylko metadane, to materiał kluczowy. ISO 32000-1 §7.6.3.3 Algorithm 2 wprowadza pierwszy element identyfikatora pliku do obliczenia klucza szyfrowania dla standardowego uchwytu bezpieczeństwa w rewizjach od 2 do 4, obok dopełnionego hasła, wartości /O i bitów uprawnień. Wyprowadzony klucz produkuje następnie ciąg walidacyjny /U, który czytnik sprawdza przy otwieraniu, a klucz pliku jest wyprowadzany i buforowany, gdy wywołujesz Encrypt albo gdy zaszyfrowany dokument jest wczytywany — oba te momenty zachodzą przed zapisem. Przepisanie identyfikatora podczas zapisu wyemitowałoby więc strukturalnie poprawny plik, którego sprawdzenie /U zawodzi przy ponownym otwarciu: nie subtelne uszkodzenie, lecz dokument, którego nikt nie otworzy, łącznie z Tobą. Dlatego deterministyczne odświeżanie jest ograniczone do dokumentów niebędących nośnikiem stanu szyfrowania, i dlatego zaszyfrowany dokument zachowuje jakiekolwiek /ID już miał, tryb deterministyczny czy nie, a ustawienie po prostu nie ma na tej ścieżce żadnego efektu. Powiązaną obsługę rewizji i semantykę uprawnień omawia artykuł o audytowaniu szyfrowania i uprawnień PDF. Zwróć też uwagę, że ścieżka przywracania szyfrowania odświeża wyłącznie /ID[1], identyfikator zmiany, dokładnie tak jak zamierza §14.4
Dlaczego zapisy przyrostowe zachowują oryginalny identyfikator
Druga granica to tryb dopisywania. Aktualizacja przyrostowa pozostawia nietkniętym każdy wcześniejszy bajt pliku i zapisuje po nim nową rewizję, a trwałość /ID[0] zgodnie z §14.4 jest tym, co mówi odbiorcy, że nowa rewizja należy do tego samego dokumentu co poprzednia. Przepisanie go zerwałoby to powiązanie, zaprzeczyłoby rewizjom już siedzącym w pliku i zakłóciłoby semantykę podpisów, ponieważ podpis obejmuje zakres bajtów konkretnej rewizji konkretnego dokumentu. losLab PDF Library odświeża więc deterministyczny identyfikator wyłącznie przy pełnych zapisach i nigdy podczas trybu dopisywania, co zachowuje nienaruszoną gwarancję opisaną w artykule o przyrostowych aktualizacjach PDF i dopisywaniu do strumienia
Jeden punkt zbiorczy dla generowania identyfikatora
Całe generowanie /ID w losLab PDF Library przechodzi teraz przez jedną wewnętrzną procedurę, NewFileIDString, co czyni przełącznik deterministyczny wiarygodnym, a nie łatą na jednej ścieżce kodu. Tworzenie pustego dokumentu, leniwe tworzenie brakującej tablicy /ID na żądanie i ścieżka przywracania odcisku szyfrowania — wszystkie ją wywołują, więc jest dokładnie jedno miejsce, w którym zegar systemowy mógłby się z powrotem przedostać. Oznacza to też, że przyszłe warianty, takie jak identyfikator wyprowadzany z treści, to zmiana jednej funkcji, a nie audyt całego serializera
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');
Wpleć to porównanie do swojego zestawu testów, zanim zaczniesz gdziekolwiek indziej polegać na powtarzalnym wyniku, ponieważ zawodzi ono głośno w chwili, gdy jakaś nowa funkcja na powrót wprowadzi znacznik czasu. Powtarzalność to właściwość, która w przeciwnym razie cicho się degraduje, a pojedyncza asercja nad dwoma zapisami w pamięci kosztuje niemal nic przy uruchamianiu na każdym buildzie
Pokazane tu API deterministycznego identyfikatora jest dostarczane razem z losLab PDF Library dla Delphi i C++Builder, obok pełnej dokumentacji informacji o dokumencie, szyfrowania i zapisu przyrostowego