Wygeneruj tę samą fakturę dwa razy z tego samego szablonu i tych samych danych, potem zdiffuj oba PDF-y. Dwa miejsca się różnią: łańcuchy hex w tablicy /ID przyczepu i łańcuch ModDate w słowniku informacji o dokumencie, oba wzięte z zegara. W losLab PDF Library SetDeterministicDocumentID(1) plus przypięty ModDate czyni pliki identycznymi bajt w bajt, dla dokumentu bez XMP UUID-ów per przebieg i załączników
Oczywista pierwsza próba to przypięcie dat i ponowne uruchomienie. CreationDate i ModDate teraz się zgadzają, a hasze nadal się różnią, już tylko w dwóch 32-cyfrowych łańcuchach hex blisko samego końca pliku, wewnątrz /ID [<…> <…>]. Ten identyfikator jest liczony osobno od metadanych i dopóki nie przestanie zależeć od czasu zapisu, cache buildów kluczowany hashem pliku nigdy nie trafia, a content addressable store trzyma jeden blob na przebieg zamiast jednego na dokument
Po co jest tablica ID w przyczepie
Przyczepowy /ID identyfikuje plik; nie liczy sumy kontrolnej jego treści. ISO 32000-1 §14.4 definiuje go jako tablicę dwóch łańcuchów bajtowych. Pierwszy to identyfikator trwały, nadawany przy tworzeniu dokumentu i mający przetrwać każdą późniejszą edycję. Drugi to identyfikator zmienny, który writer odświeża przy każdej modyfikacji pliku. System zarządzania dokumentami porównuje parę, żeby rozstrzygnąć, czy dwa pliki to rewizje jednego dokumentu, czy dwa niepowiązane dokumenty. Tabela 15 w §7.5.5 czyni ten wpis wymaganym, gdy przyczep niesie /Encrypt, i opcjonalnym inaczej, a §14.4 zauważa, że jego pominięcie może zawadzić przepływom pracy polegającym na tożsamości pliku
Specyfikacja nigdy nie mówi, jak policzyć tę wartość. Sugestia to skrót MD5 nad rzeczami takimi jak bieżący czas, ścieżka pliku, rozmiar pliku i wartości w słowniku informacji, a zegar jest składnikiem, który czyni wynik unikalnym. Unikalność to dokładnie to, czego chcesz od znacznika tożsamości, i dokładnie to, co wywala powtarzalność. Domyślnie losLab PDF Library podąża za tą sugestią w jej najprostszej formie, MD5 bieżącego znacznika czasu, więc dwa dokumenty stworzone sekundę odstępu dostają różne identyfikatory trwałe, nawet gdy każdy inny bajt się zgadza
Włączanie powtarzalnego identyfikatora
Tryb deterministyczny to przełącznik per dokument i jest domyślnie wyłączony, więc istniejące wyjście nie zmienia się, dopóki o to nie poprosisz. SetDeterministicDocumentID przyjmuje 0 albo 1 i zwraca 1, gdy wartość została przyjęta, 0 dla czegokolwiek innego; GetDeterministicDocumentID raportuje bieżące ustawienie. SetDocumentIDSeed dostarcza jawny łańcuch seeda, a z seedem na miejscu identyfikator to MD5 tego łańcucha, zapisany w oba elementy tablicy. GetDocumentFileID czyta /ID[0] z powrotem po zapisie, co jest wygodne do logowania albo do asercji w teście
var
Lib: TPDFlib;
FileID: WideString;
begin
Lib := TPDFlib.Create;
try
Lib.SetDeterministicDocumentID(1); // zaraz po 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; // ta sama wartość przy każdym przebiegu
finally
Lib.Free;
end;
end;
Identyfikator jest liczony w chwili zapisu. Przerzucenie przełącznika późno w budowie dokumentu nadal działa, a zmieniony seed dociera do pliku przy następnym pełnym zapisie: zapisz z seedem A, zapisz znowu z seedem B, i oba wyjścia niosą różne identyfikatory, podczas gdy powrót do seeda A daje ci znowu oryginalną wartość
Przerzucaj jednak przełącznik zaraz po stworzeniu obiektu. Fonty, obrazy, stany grafiki i inne zasoby stron dostają nazwy w słowniku zasobów strony, i przy włączonym trybie deterministycznym te nazwy pochodzą z licznika. Cokolwiek dodanego przed przełącznikiem zachowuje nazwę wygenerowaną losowo, co zostawia identyfikator stabilny, ale zmienia po kilka bajtów w strumieniach treści przy każdym przebiegu
Dlaczego identyfikator nadal się zmienia, gdy seed nie jest ustawiony?
Bo słownik informacji jest częścią hasza. Zostaw seed pusty albo podaj pusty łańcuch do SetDocumentIDSeed, żeby go wyczyścić, a losLab PDF Library wyprowadza identyfikator z samego pliku: przepuszcza zserializowane ciało przez SHA-256 w trakcie zapisu i używa pierwszych 16 bajtów skrótu dla obu elementów /ID. Każda zmiana treści strony zmienia identyfikator, i o to w całości chodzi. Haczyk: CreationDate i ModDate też mieszkają w tym ciele, a rutyny zapisu stemplują ModDate bieżącym czasem, chyba że wołający go ustawił
Lib.SetDeterministicDocumentID(1);
Lib.SetDocumentIDSeed(''); // wyprowadź /ID z zapisanych bajtów
// Hasz ciała pokrywa słownik Info, więc daty muszą być
// wejściami builda. Klucz 8 blokuje też stemplowanie ModDate przy zapisie.
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');
Klucz 8 pełni tu podwójną rolę i to jest linia, którą ludzie najczęściej pomijają. Ustawienie ModDate przez SetInformation oznacza datę jako dostarczoną przez wołającego i od tej chwili SaveToFile i SaveToString zostawiają ją w spokoju. Bez tego każdy zapis pisze świeży znacznik czasu, haszowane ciało się zmienia, i identyfikator wyprowadzany z treści zmienia się razem z nim. Bierz daty z rekordu źródłowego albo ze stałej epoki. Now nie ma biznesu w powtarzalnym buildzie
Kilka rzeczy jest trzymanych poza haszem celowo. Losowo generowane nazwy zasobów i UUID we wpisie XMP xmpMM:InstanceID są normalizowane przed haszowaniem, więc dwa przebiegi różniące się tylko tymi tokenami dostają ten sam /ID. Bajty pliku nadal różnią się w tych miejscach, więc dokument w stylu PDF/A ze świeżym UUID-em instancji per przebieg daje ci powtarzalny identyfikator, ale nie plik identyczny bajt w bajt. Osadzone załączniki to drugie źródło dryfu: AddAssociatedFileFromString stempluje własny /ModDate załącznika bieżącym czasem, i ta data jest haszowana jak każdy inny bajt ciała
Jawny seed czy hasz treści
Dla artefaktów builda zostawilibyśmy seed pusty. Identyfikator wyprowadzany z treści zmienia się dokładnie wtedy, gdy zmieniają się bajty, więc cache kluczowany na nim nigdy nie poda starego pliku, a ty nie dostajesz drugiego klucza do synchronizacji z danymi. Jawny seed pasuje do innej roboty: archiwum downstream, które traktuje /ID[0] jako tożsamość dokumentu i oczekuje tego samego identyfikatora przez regeneracje tej samej faktury, nawet po zmianie szablonu. To lepiej pasuje do intencji §14.4, w zamian za to, że dwa widocznie różne pliki mogą dzielić identyfikator
Jedna konsekwencja ścieżki treści zasługuje na świadomą decyzję. Gdy wczytujesz istniejący PDF i zapisujesz go w pełni z włączonym trybem deterministycznym, przychodzący /ID jest wyrzucany, a nowy zależy tylko od tego, co zostaje zapisane. Dwa pliki źródłowe różniące się tylko identyfikatorami przyczepu wychodzą z tym samym /ID. Tego chce normalizujący krok builda. Jeśli edytujesz dokument klienta i jego tożsamość ma przetrwać, zostaw tryb deterministyczny wyłączony dla tej roboty albo zapisuj przyrostowo
Dlaczego przepisanie ID psuje zaszyfrowany PDF?
Bo w zaszyfrowanym dokumencie /ID[0] to materiał kluczowy. ISO 32000-1 §7.6.3.3, algorytm 2, wlewa pierwszy element identyfikatora pliku do obliczenia klucza szyfrowania dla standardowego uchwytu bezpieczeństwa w rewizjach od 2 do 4, razem z dopełnionym hasłem, wartością /O i bitami uprawnień. Ten klucz produkuje potem łańcuch /U, który czytnik sprawdza przy otwarciu. Klucz jest wyprowadzany, gdy wołasz Encrypt albo wczytujesz zaszyfrowany plik, przed jakimkolwiek zapisem, więc zamiana identyfikatora w trakcie zapisu wyprodukowałaby dobrze uformowany plik, którego sprawdzenie hasła zawodzi dla wszystkich, łącznie z tobą
losLab PDF Library odświeża więc deterministyczny identyfikator tylko przy niezaszyfrowanych pełnych zapisach. Zaszyfrowany dokument zachowuje cokolwiek /ID już miał, a przełącznik nie ma na tej ścieżce efektu. Ponowne otwarcie takiego pliku hasłem użytkownika działa jak zwykle. Nie wkładaj też zaszyfrowanego wyjścia do porównania bajtów: szyfrowanie AES pod §7.6.2 zaczyna każdy zaszyfrowany łańcuch i strumień losowym 16-bajtowym wektorem inicjalizującym, więc dwa szyfrowania tego samego dokumentu nigdy nie zgadzają się bajt w bajt. Jeśli potrzebujesz tam sprawdzenia regresyjnego, odszyfruj oba i porównaj, co dostajesz z powrotem. Obsługę rewizji i bity uprawnień omawia głębiej przejście przez audyt szyfrowania i uprawnień PDF
Zapisy przyrostowe zachowują oryginalny identyfikator
Aktualizacja przyrostowa zostawia każdy wcześniejszy bajt pliku na miejscu i dopisuje za nim nową rewizję. Trwałość /ID[0] to to, co mówi konsumentowi, że dopisana rewizja należy do tego samego dokumentu co poprzednie. Przepisanie go zaprzeczyłoby rewizjom już siedzącym w pliku i podkopałoby przepływy pracy z podpisami, bo podpis pokrywa zakres bajtów jednej konkretnej rewizji. Tryb dopisywania nigdy nie dotyka identyfikatora w trybie deterministycznym, co zachowuje w nienaruszonym stanie zachowanie opisane w artykule o przyrostowych aktualizacjach PDF i dopisywaniu do strumienia
Trzymanie wyjścia powtarzalnym w czasie
Każda późniejsza zmiana generatora to szansa, by wpuścić wartość zegara z powrotem do pliku: watermark drukujący czas generacji, ścieżka kodu dopinająca plik, inna opcja zapisu. Opcje zapisu mają znaczenie równie duże jak treść: pakowanie obiektów w skompresowane strumienie obiektów produkuje kompletnie inny układ pliku niż klasyczna tabela odwołań krzyżowych, jak wyjaśniają notatki o strumieniach obiektów i strumieniach odwołań krzyżowych, więc trzymaj te ustawienia stałymi per potok. Najtańsza obrona to test, który buduje ten sam dokument dwa razy w pamięci i porównuje bajty
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;
// Dwa niezależne buildy muszą dać jedną sekwencję bajtów
if BuildQuote('quote-8812') = BuildQuote('quote-8812') then
WriteLn('reproducible')
else
WriteLn('nondeterminism leaked into the output');
Ten sam harness potrafi porównać szeregowe i równoległe uruchomienie RecompressStreams na tym samym źródle: wczytaj je do dwóch instancji TPDFlib, włącz deterministyczne identyfikatory i przypnij ModDate na obu, ustaw SetOptimizationWorkers(1) na jednym i wyższą liczbę workerów na drugim, i zdiffuj oba wyniki SaveToString. Z zegarem wyjętym z obu plików każda pozostała różnica pochodzi z samej rekompresji
Powtarzalne identyfikatory przyczepu, kontrola metadanych i zapis przyrostowy to wszystko części losLab PDF Library for Delphi i C++Builder, której referencja wylicza też wspomniane tu opcje szyfrowania