PDFium Component czyni odtwarzalne wyjście PDF jednym wołaniem: TPdf.SaveAsReproducible przepisuje dokument tak, że dwukrotny zapis tej samej treści daje pliki identyczne bajt w bajt. Usuwa /CreationDate i /ModDate ze słownika informacji o dokumencie, nie zapisuje przyczepu /ID, trzyma kolejność obiektów stabilną i raportuje, co zmienił, w TPdfReproducibleReport. Pliki zaszyfrowane są odmawiane, a podpisane potrzebują jawnej zgody
Potrzeba zwykle wychodzi w potoku builda. Ktoś checkuje wygenerowane PDF-y do repozytorium albo do zestawu testów golden-file, a każdy przebieg produkuje diff, choć na żadnej stronie nic się nie zmieniło. Albo archiwum deduplikuje po SHA-256 i po cichu chowa pięćdziesiąt kopii tej samej faktury, bo każdy zapis wbił do pliku nową godzinę. Writer PDF wbudowujący zegar ścienny jest w porządku dla aplikacji desktopowej i wrogi wobec czegokolwiek, co traktuje bajty jako tożsamość
Dlaczego dwukrotny zapis tego samego PDF-a daje różne bajty?
Dwa zapisy niezmienionego dokumentu różnią się przez dokładnie dwa rodzaje danych: znaczniki czasu w słowniku informacji o dokumencie i identyfikator pliku w przyczepie. Wszystko inne, co sumienny writer emituje — numeracja obiektów, kolejność obiektów, układ odwołań krzyżowych — da się zrobić deterministycznie, i ścieżka pełnego rewrite w PDFium Component już to robi: obiekty są zapisywane po rosnącym numerze obiektu, a tabela xref idzie w tej samej kolejności
Znaczniki czasu to oczywisty winowajca. ISO 32000-1 §14.3.3 definiuje /CreationDate i /ModDate w słowniku /Info, a natywny zapis PDFium pisze świeży /ModDate za każdym razem, gdy serializuje dokument. Identyfikator pliku to subtelniejszy przypadek. ISO 32000-1 §14.4 opisuje /ID jako dwuelementową tablicę stringów bajtowych, gdzie pierwszy element identyfikuje oryginalny plik, a drugi zmienia się wraz z rewizją pliku. writery zwykle wyprowadzają te stringi hashowaniem czegoś o pliku, a jeśli to coś zawiera znacznikowane bajty, identyfikator dziedziczy niedeterminizm
Używanie TPdf.SaveAsReproducible w Delphi
TPdf.SaveAsReproducible ma dwa przeciążenia, jedno piszące do TStream i jedno do nazwy pliku, i oba zwracają Boolean z out Report: TPdfReproducibleReport oraz opcjonalnym parametrem AllowSignedDocument domyślnie False. Wartość zwracana odzwierciedla Report.Succeeded; gdy jest False, Report.ErrorMessage mówi dlaczego. Przeciążenie plikowe zapisuje do pliku tymczasowego obok celu i podmienia cel dopiero po udanym zapisie, więc nieudany przebieg nie zostawia półnapisanego PDF-a tam, gdzie stało twoje poprzednie wyjście builda
var
Pdf: TPdf;
Report: TPdfReproducibleReport;
begin
Pdf:= TPdf.Create(nil);
try
Pdf.FileName:= 'invoice-2026-10.pdf';
Pdf.Active:= True;
if Pdf.SaveAsReproducible('build\invoice-2026-10.pdf', Report) then
Writeln(Format('%d -> %d bytes, %d objects kept, %d pruned, ' +
'%d timestamps stripped', [Report.SourceByteCount,
Report.OutputByteCount, Report.KeptObjectCount,
Report.PrunedObjectCount, Report.StrippedTimestampCount]))
else
Writeln('Reproducible save failed: ', Report.ErrorMessage);
finally
Pdf.Free;
end;
end;
Pola raportu warto logować w CI. StrippedTimestampCount to zwykle 2 dla dokumentu, którego /Info niesie obie daty, i 0 dla takiego bez słownika /Info w ogóle. PrunedObjectCount liczy obiekty, które były w natywnym zapisie, ale nie były osiągalne z katalogu ani ze słownika /Info; odtwarzalny rewrite je wyrzuca, więc oczekuj wyjścia tego samego rozmiaru albo mniejszego niż zwykły zapis
Dlaczego samo wycięcie znaczników czasu nie wystarczyło
Pierwsza implementacja usunęła oba znaczniki i nadal produkowała dwa różne pliki. Strony się zgadzały, słownik /Info się zgadzał, a przyczepy różniły się drugim elementem /ID. Przyczyną źródłową była kolejność: rewrite najpierw prosi PDFium o natywny zapis, a potem post-processuje te bajty, i builder przyczepu liczył /ID hashowaniem natywnego wyjścia. To natywne wyjście wciąż zawierało świeży /ModDate, więc hash, a z nim identyfikator, zmieniał się przy każdym przebiegu, choć sam znacznik nigdy nie dotarł do końcowego pliku
To dobry przykład ogólnej reguły buildów odtwarzalnych: usunięcie wartości niedeterministycznej nie wystarczy, jeśli cokolwiek zostało z niej najpierw wyprowadzone. Poprawka polegała na przestaniu nieść /ID w ogóle. TPdf.SaveAsReproducible zapisuje przyczep zawierający wyłącznie /Size, /Root i, gdy źródło ma, /Info. Bez niczego zależnego od czasu w przyczepie dwa kolejne zapisy tego samego dokumentu porównują się równo bajt po bajcie, i dokładnie tak jest to zachowanie weryfikowane
function SavesAreIdentical(Pdf: TPdf): Boolean;
var
A, B: TMemoryStream;
RA, RB: TPdfReproducibleReport;
begin
A:= TMemoryStream.Create;
B:= TMemoryStream.Create;
try
Result:= Pdf.SaveAsReproducible(A, RA) and
Pdf.SaveAsReproducible(B, RB) and
(A.Size = B.Size) and CompareMem(A.Memory, B.Memory, A.Size);
finally
B.Free;
A.Free;
end;
end;
Usuwanie kluczy słownika bez psucia stringów
Znaczniki czasu są usuwane skanem świadomym tokenów ciała obiektu /Info, nie wyszukiwaniem tekstowym. Naiwna podmiana bajtów dla /ModDate trafiłaby też w string /Subject albo /Keywords, który akurat zawiera ten tekst, a wycięcie bajtów z literału stringa po cichu psuje metadane zamiast paść głośno. PDFium Component najpierw neutralizuje literały stringów i komentarze w skanowej kopii ciała, potem przyjmuje trafienie tylko tam, gdzie zaczyna się prawdziwy token nazwy, używa gramatyki obiektów PDF, by znaleźć, gdzie kończy się wartość tego klucza, i pomija całą parę klucz-wartość
Praktyczną konsekwencją jest to, że każdy inny wpis w /Info, tytuł, autor, producent, klucze własne, przeżywa bez dotknięcia. Usuwane są tylko dwa klucze zdefiniowane jako daty i to wyłącznie ze słownika, na który faktycznie wskazuje wpis /Info przyczepu
Ile kosztuje pominięcie /ID dla PDF-ów zaszyfrowanych i podpisanych?
Pominięcie /ID jest bezpieczne dla zwykłego wyjścia PDF 1.x, ale wyklucza szyfrowanie i zmienia, co dzieje się z sygnaturami. W ISO 32000-1 przyczepowy /ID jest opcjonalny, chyba że obecny jest wpis /Encrypt, bo standardowa obsługa bezpieczeństwa miesza pierwszy string identyfikatora do klucza szyfrowania. Plik bez /ID nie może być poprawnie zaszyfrowanym plikiem, więc TPdf.SaveAsReproducible nawet nie próbuje: gdy przyczep niesie /Encrypt, zwraca False, a Report.ErrorMessage mówi, że dokument jest zaszyfrowany. Odszyfruj najpierw, jeśli twój potok pozwala, albo trzymaj zaszyfrowane deliverable poza sprawdzeniami identyczności bajtów. Jeśli celujesz w PDF 2.0, zauważ, że ISO 32000-2 zaostrza regułę i wymaga /ID w każdym pliku, więc odtwarzalne wyjście pasuje najnaturalniej do celów PDF 1.x; jak komponent obchodzi się z celami wersji, omawia artykuł o dokładnej zgodności z wersją PDF w Delphi
Dokumenty podpisane to inny problem. TPdf.SaveAsReproducible to pełny, nieprzyrostowy rewrite, więc offsety bajtów się przesuwają i /ByteRange istniejących sygnatur przestaje pokrywać podpisane bajty. Odtwarzalne wyjście podpisanego pliku jest więc świadomą decyzją o oddaniu ważności istniejących sygnatur w zamian za stabilne bajty, dlatego metoda podnosi EPdfError, gdy SignatureCount jest większe od zera, chyba że przekażesz AllowSignedDocument = True
if Pdf.SignatureCount > 0 then
// Jawna zgoda: rewrite przesuwa offsety bajtów, więc istniejące
// sygnatury trzeba nastawiać na nieprzejście weryfikacji później
Ok:= Pdf.SaveAsReproducible('normalized.pdf', Report, True)
else
Ok:= Pdf.SaveAsReproducible('normalized.pdf', Report);
Jeśli podpisany oryginał jest zapisem prawdy, trzymaj go jak jest i używaj odtwarzalnego wyjścia wyłącznie dla kopii pochodnych, jak znormalizowana wersja, którą diffujesz w CI. Do doklejania do chronionego pliku bez ruszania tego, co już jest, zobacz artykuł o tym, dlaczego zaszyfrowanego PDF-a nie da się po cichu łatać w Delphi
Granice do poznania, zanim polegniesz na identyczności bajtów
Identyczność bajtów trzyma się dla tego samego dokumentu zapisanego tym samym buildem komponentu i pokrywa dokładnie to, czego metoda dotyka. TPdf.SaveAsReproducible wycina tylko dwie daty /Info; strumień metadanych XMP z własnym xmp:CreateDate albo xmp:ModifyDate przechodzi jako te bajty, które dokument już trzyma. To stabilne między zapisami, ale znaczy, że daty XMP i /Info nie opisują już tego samego, co ma znaczenie dla profili PDF/A wymagających ich zgody; reguły omawia artykuł o równoważności metadanych PDF/A Info i XMP. Inna binarka PDFium albo wersja komponentu może też serializować obiekty inaczej, więc przypnij oba w każdym potoku porównującym hashe między maszynami
W tych granicach metoda daje ci to, czego potrzebują diffy CI, testy golden-file i archiwa adresowane treścią: to samo wejście, te same bajty. TPdf.SaveAsReproducible i TPdfReproducibleReport są częścią PDFium Component dla Delphi i C++Builder, obok reszty jego API zapisu, optymalizacji i zgodności
FAQ
Czy TPdf.SaveAsReproducible zmienia treść stron?
Nie. Usuwa klucze /CreationDate i /ModDate ze słownika /Info, pomija przyczepowy /ID i wyrzuca obiekty nieosiągalne z katalogu ani z /Info; treść stron jest zapisywana bez zmian
Czy da się wyprodukować odtwarzalne wyjście z zaszyfrowanego PDF-a?
Nie. Szyfrowanie zależy od przyczepowego /ID, który odtwarzalne wyjście pomija, więc metoda zwraca False z wyjaśniającym ErrorMessage dla źródeł zaszyfrowanych
Dlaczego podpisany PDF potrzebuje AllowSignedDocument?
Odtwarzalny zapis to pełny rewrite, który przesuwa offsety bajtów, więc istniejące sygnatury przestają pasować do swoich podpisanych zakresów bajtów. Przekazanie AllowSignedDocument = True potwierdza, że przyjmujesz ten kompromis