HotPDF Delphi Component produkuje bajt od bajtu identický PDF výstup napříč uloženími, když je property ReproducibleOutput True: připne Info /CreationDate a /ModDate na pevné datum, vymění wall-clock identifikátor dokumentu za hash odvozený ze seedu nebo z obsahu, substituuje konstanty za každý náhodný bajt, který by cesty AES šifrování jinak táhly, a sortuje každý slovník, který serializuje. Příznak existuje pro regresní sady a srovnávání build artefaktů, ne pro produkční dokumenty, a důvody té hranice jsou ta zajímavá část. Scénář, který feature táhne, je golden-file test. Vykreslíte fakturu, commitnete PDF a assertujete, že zítřní build produkuje tytéž bajty. Nikdy to neudělá. Soubor se otevírá dobře v každém prohlížeči, text je identický, strom stránek je identický a diff se stejně rozsvítí na čtyřech až pěti místech. Kdokoli, kdo se pokusil dát PDF generátor pod bajtovou regresní kontrolu, na tuhle zeď narazil a opravou není „oškrábat timestampy", ale přesný účet každého místa, kde writer sahá k něčemu jinému než k dokumentu samotnému
Proč se dvě uložení téhož PDF liší?
Dvě uložení téhož dokumentu se liší, protože PDF writer, HotPDF nevyjímaje, sahá ke čtyřem zdrojům entropie, které s obsahem stránek nemají nic společného: wall clock, identifikátor dokumentu, kryptografický generátor náhodných čísel a paměťové pořadí položek slovníku. Každý sám o sobě je legitimní. ISO 32000-1 je tam chce. Prostě dělají ze souboru funkci toho, kdy a kde byl napsaný, místo toho, co obsahuje
- Hodiny. Info slovník nese
/CreationDatea/ModDate(ISO 32000-1 §14.3.3, tabulka 317) jako řetězceD:YYYYMMDDHHmmSSs příponou časového pásma (§7.9.4) a XMP paket opakuje tentýž okamžik jakoxmp:CreateDateaxmp:ModifyDate. HotPDF razí obě zFCreationDate, které konstruktor inicializuje naNow, takže dvě uložení se liší ve vteřině, kdy byla napsaná - Identifikátor. Pole
/IDv traileru (ISO 32000-1 §14.4) drží trvalý identifikátor a identifikátor modifikace. Defaultní recept HotPDF hashuje název souboru spolu s aktuálním časem po milisekundy pro první prvek a hashuje to plusGetTickCountpro druhý. Dva identifikátory, dvě čerstvé hodnoty při každém běhu - Náhodné bajty. Standardní šifrování závisí na identifikátoru a na pravé náhodě. U AES-256 se klíč šifrování souboru, validační a klíčové soli a každý inicializační vektor CBC tahají ze systémového náhodného zdroje (ISO 32000-2 §7.6.4.4.7 vyžaduje náhodné soli). Protože
/U,/UE,/Oa/OEse všechna počítají z těch bajtů, šifrovaný dokument se mění celý, i když plaintext ne. Starší algoritmy zamotávají první prvek/IDdo klíče (ISO 32000-1 §7.6.3.3, §7.6.3.4), takže samotný čerstvý identifikátor stačí k re-key souboru - Pořadí. PDF slovník je neuspořádané zobrazení a writer, který projde svůj in-memory list, emituje klíče v pořadí vložení. Jakákoli kódová cesta, která staví resource slovník v jiné sekvenci, nebo načtený dokument parsovaný z jiného rozložení, produkuje legální, ale textově jiný soubor
Co ReproducibleOutput připne?
Nastavení ReproducibleOutput := True před BeginDoc nebo před SaveLoadedDocument vymění každý ze čtyř zdrojů za pevnou hodnotu a dělá to ve stejných kódových cestách, které by jinak sáhly po hodinách nebo náhodném generátoru, takže žádný zvláštní úklidový průchod není potřeba. Všimněte si, co v seznamu výše chybí: obsah. Fonty, page streamy, image data a cross-reference tabulka už jsou pro tentýž vstup deterministické; šum bydlí celý v metadatech a bezpečnostní vrstvě, proto to může jedna cílená property odstranit. Property defaultuje na False a nic v knihovně ji pro vás nezapne
var
Pdf: THotPDF;
begin
Pdf := THotPDF.Create(nil);
try
Pdf.AutoLaunch := False;
Pdf.FileName := 'golden-invoice.pdf';
Pdf.ReproducibleOutput := True; // před BeginDoc
Pdf.BeginDoc;
Pdf.CurrentPage.SetFont('Arial', [], 12);
Pdf.CurrentPage.TextOut(40, 40, 0, 'Invoice 2026-0042');
Pdf.EndDoc;
finally
Pdf.Free;
end;
end;
Uvnitř BeginDoc přiřazuje reproducible větev FCreationDate := EncodeDate(2026, 1, 1) a seeduje identifikátor dokumentu přes MD5CalcString('HotPDF-reproducible-seed') místo digestu název-souboru-plus-hodiny. To jediné přiřazení pokrývá obě Info data i obě XMP data, protože všech čtyři se renderují z téhož pole. Když se soubor konečně zapíše, ptá se BuildDocumentIdentifiers ComputeCanonicalDocumentIdentifier na trailer identifikátor: exportuje celý graf objektů v kanonickém pořadí, vynuluje číslice každého řetězce data D:, které najde, aby timestampy nemohly zpět prosakovat skrz hash, a vezme MD5 výsledku. Oba prvky /ID dostávají tu hodnotu. Tentýž z obsahu odvozený identifikátor se používá, když se šifruje načtený dokument, aniž by kdy prošel BeginDoc, což je případ ActivateProtection na souboru otevřeném přes LoadFromFile
Náhodné bajty jsou nejméně zjevná substituce. Klíčová rutina AES-256 balí svůj náhodný zdroj do lokálního helperu, který pod příznakem volá FillChar(P^, Count, $5A) pro 32bajtový klíč šifrování souboru a pro každou 8bajtovou sůl, a string i stream šifrátoři AES-128 a AES-256 přecházejí z AESGenerateRandomIV na AESGenerateStaticIV, který plní inicializační vektor hodnotou 14 * (1 + I) pro slot I. S klíčem, solmi i vektory všemi fixními vycházejí /U, /UE, /O, /OE a každý šifrovaný stream identicky při druhém běhu. Nakonec SaveToStream zapíná DeterministicDictionaryOrder, kdykoli je reproducible příznak nastavený, a serializér pak insertion-sortuje každý slovník podle surových bajtů jmen klíčů, kratší prefix první, s originálním indexem jako tie-breakerem. To je totéž pořadí, jaké používá diagnostický writer popsaný v článku o ruční editaci PDF a následné opravě; reproducible příznak si půjčuje jen to pořadí, ne zbytek plain-text rozložení toho writeru
Proč pevné datum pořád prosakovalo wall clock?
Oprava ve v2.752.2 existuje, protože pevné datum vytvoření se původně rozhodovalo v konstruktoru a konstruktor nemůže znát property, kterou volající ještě nenastavil. Normální sled volání je Create, pak ReproducibleOutput := True, pak BeginDoc. V momentě konstrukce je FReproducibleOutput pořád False, takže FCreationDate dostalo Now a nechalo si ho. Identifikátor i náhodné bajty byly připnuté správně, takže oba soubory souhlasili téměř všude a rozešli se přesně ve dvou řetězcích dat a dvou polích XMP. Přesunutí přiřazení do reproducible větve BeginDoc, vedle seedovaného identifikátoru, dalo rozhodnutí do bodu, kde property má svou finální hodnotu
Regresní test, který to minul, stojí za víc než oprava. Dvě uložení, která obě proběhnou uvnitř téže wall-clock vteřiny, napíšou tutéž řetězci D: náhodou a bajtové srovnání projde pro bug, který selže na jakémkoli pomalejším stroji. Opravený test spí 1100 ms mezi oběma uloženími, takže PDF timestamp je garantovaně překročí hranici vteřiny, protahuje případ pro plain, AES-128 a AES-256 výstup s reálnými hesly na dvou šifrovaných variantách a srovnává oba buffery přes CompareMem a hlásí první rozdílný offset při selhání, takže diff míří na konkrétní objekt místo celého souboru. Bajtové srovnání dokazuje determinismus a nic jiného, takže si nechte separátní assert, který znovu načte šifrovaný výstup uživatelským heslem a přečte počet stránek; změna, která udělá soubor stabilní a nečitelný zároveň, nesmí projít na síle zeleného diffu
function SaveOnce(const Target: string): TBytes;
var
Pdf: THotPDF;
Stream: TFileStream;
begin
Pdf := THotPDF.Create(nil);
try
Pdf.AutoLaunch := False;
Pdf.FileName := Target;
Pdf.ReproducibleOutput := True;
Pdf.OwnerPassword := 'owner';
Pdf.UserPassword := 'user';
Pdf.CryptKeyLength := aes256;
Pdf.ActivateProtection := True;
Pdf.BeginDoc;
Pdf.CurrentPage.SetFont('Arial', [], 12);
Pdf.CurrentPage.TextOut(40, 40, 0, 'reproducible save');
Pdf.EndDoc;
finally
Pdf.Free;
end;
Stream := TFileStream.Create(Target, fmOpenRead or fmShareDenyWrite);
try
SetLength(Result, Stream.Size);
if Stream.Size > 0 then
Stream.ReadBuffer(Result[0], Stream.Size);
finally
Stream.Free;
end;
end;
// v těle testu
A := SaveOnce(PathA);
TThread.Sleep(1100); // vynuť jinou vteřinu PDF timestampu
B := SaveOnce(PathB);
Assert.AreEqual<Integer>(Length(A), Length(B));
Assert.IsTrue(CompareMem(@A[0], @B[0], Length(A)),
'two saves under ReproducibleOutput must be byte-identical');
Je reproducibilní šifrované PDF pořád bezpečné?
Ne. Dokument šifrovaný pod ReproducibleOutput není chráněný v žádném smysluplném slova smyslu a příznak musí být vypnutý pro cokoli, co opouští adresář testů. Klíč šifrování souboru AES-256 je třicet dva bajtů $5A, soli jsou osm bajtů $5A a inicializační vektory následují publikovaný aritmetický vzor. Heslo pořád hlídá obaly /UE a /OE, ale zabalený klíč je konstanta, takže kdokoli, kdo zná konstantu, dešifruje každý content stream bez jakéhokoli hesla. Fixní soli taky odstraňují per-document unikátnost, na kterou se ISO 32000-2 §7.6.4.4.7 spoléhá, aby identická hesla nevedla napříč soubory na identické řetězce /U. Přečtěte si článek o nastavení AES-256 pro to, co šifrovací properties slibují, když je náhodný zdroj intaktní; pod reproducible příznakem jsou ty sliby suspendované
Trade-off identifikátoru je jemnější. ISO 32000-1 §14.4 chce, aby se druhý prvek /ID měnil při každé modifikaci, aby nástroje poznaly updatovaný soubor od jeho předka, a reproducibilní uložení zapisuje tutéž hodnotu do obou slotů. Protože ta hodnota je hash kanonického grafu objektů, dva dokumenty s jiným obsahem pořád dostanou jiné identifikátory, což je lepší než konstanta. Ale seed, který BeginDoc používá pro derivaci klíče, je tentýž řetězec pro každý dokument na každém stroji a reader, který klíčuje na /ID, aby rozeznával soubory, třeba cache anotací nebo form-data sidecar, slije každý reproducibilní soubor, který zrovna hashuje stejně
Co příznak nepokrývá?
ReproducibleOutput odstraňuje entropii, kterou writer vnáší sám od sebe; nemůže odstranit entropii, která vstupuje prostředím nebo kódovými cestami, které nekontroluje, a tři z nich jsou snadné k uklouznutí
- Přípona časového pásma.
_DateTimeToPdfDatepřipojuje lokální UTC offset, takžeD:20260101000000+08'00'na jednom build agentu aD:20260101000000-05'00'na jiném jsou různé bajty pro téže pevné datum. Reprodukovatelnost drží napříč běhy na jednom stroji nebo napříč stroji sdílejícími časové pásmo; připněte zónu agenta, pokud vaše golden soubory cestují - Inkrementální updaty.
SaveIncrementalUpdatepočítá svůj identifikátor modifikace z cílové cesty,GetTickCounta aktuálního času bez reproducible větve, protože inkrementální sekce je z definice nová modifikace. Srovnávejte plné přepisy, ne připojované delty - Zkratka passthrough.
SaveLoadedDocumentnormálně kopíruje nezměněný, nešifrovaný zdrojový soubor bajt od bajtu místo nové serializace. Reproducible příznak tu zkratku vypíná a vynucuje plný přepis, aby platila pravidla pořadí a identifikátoru, což znamená, že reproducibilní uložení načteného souboru je pomalejší než default a nikdy není kopií vstupu. Diffujte ho proti předchozímu reproducibilnímu uložení, nikdy proti originálu
Ještě jedna lekce ze stejného vydání, o tom, co projdoucí kontrola dokazuje a nedokazuje. Testovací fixture PDF/X-6 zavolala CharProcs.DeleteValue('A'), což uvolnilo přímo držený glyf stream, pak znovu vložila tentýž pointer a zvlášť podala jeden přímý ExtGState objekt jak resource slovníku, tak patternu. Conformance validátor procházel přerušovaně na tomhle use-after-free a dvojitém vlastnictví, protože četl cokoli, co uvolněná paměť zrovna držela. Když strukturální kontrola poblikává, podívejte se na vlastnictví testovacího vstupu, než se podíváte na validátor. Reproducible výstup dělá tuhle disciplínu levnější: jakmile jsou dvě uložení bajtově identická, jediný zbývající zdroj blikání je graf objektů sám a strukturální diff od catalogu dolů ho najde
ReproducibleOutput, DeterministicDictionaryOrder a šifrovací properties popsané tady dodává standardní HotPDF Delphi Component pro Delphi a C++Builder a tentýž příznak řídí vlastní regresní korpus knihovny, takže chování, které dostanete v test sadě, je chování, s nímž je komponenta testovaná