HotPDF Delphi Component produceert byte-identieke PDF-output over saves heen wanneer de property ReproducibleOutput True is: hij pint de Info-/CreationDate en /ModDate op een vaste datum, vervangt de documentidentifier uit de klok door een geseede of uit de inhoud afgeleide hash, zet constanten in de plaats van elke random byte die de AES-versleutelingspaden anders zouden trekken, en sorteert elke dictionary die hij serialiseert. De flag bestaat voor regressiesuites en het vergelijken van build-artefacten, niet voor productiedocumenten, en de redenen voor die grens zijn het interessante deel. Het scenario dat de functie drijft is een golden-file-test. U rendert een factuur, commit de PDF, en stelt vast dat de build van morgen dezelfde bytes oplevert. Dat gebeurt nooit. Het bestand opent prima in elke viewer, de tekst is identiek, de paginaboom is identiek, en de diff licht nog steeds op vier of vijf plekken op. Iedereen die een PDF-generator onder een byte-niveau-regressietest heeft proberen te zetten is tegen deze muur gelopen, en de oplossing is niet "de timestamps eruit slopen" maar een precieze boekhouding van elke plek waar de writer iets anders raadpleegt dan het document zelf
Waarom verschillen twee saves van dezelfde PDF?
Twee saves van hetzelfde document verschillen omdat een PDF-writer, HotPDF inbegrepen, vier bronnen van entropie raadpleegt die niets met pagina-inhoud te maken hebben: de klok, de documentidentifier, de cryptografische randomgenerator, en de geheugenorde van dictionary-entries. Elke bron is op zichzelf legitiem. ISO 32000-1 wil ze erin. Ze maken het bestand alleen tot een functie van wanneer en waar het is geschreven in plaats van van wat het bevat
- De klok. De Info-dictionary draagt
/CreationDateen/ModDate(ISO 32000-1 §14.3.3, Tabel 317) alsD:YYYYMMDDHHmmSS-strings met een tijdzonesuffix (§7.9.4), en het XMP-packet herhaalt hetzelfde moment alsxmp:CreateDateenxmp:ModifyDate. HotPDF stempelt beide uitFCreationDate, dat de constructor opNowinitialiseert, dus de twee saves verschillen in de seconde waarin ze zijn geschreven - De identifier. De trailer-
/ID-array (ISO 32000-1 §14.4) bevat een permanente identifier en een wijzigingsidentifier. Het standaardrecept van HotPDF hasht de bestandsnaam samen met de huidige tijd tot op de milliseconde voor het eerste element, en hasht dat plusGetTickCountvoor het tweede. Twee identifiers, twee verse waarden bij elke run - De random bytes. Standard security hangt af van de identifier en van echte randomheid. Voor AES-256 worden de file encryption key, de validatie- en keysalts en elke CBC-initialisatievector uit de randombron van het systeem getrokken (ISO 32000-2 §7.6.4.4.7 eist random salts). Omdat
/U,/UE,/Oen/OEallemaal uit die bytes worden berekend, verandert een versleuteld document in zijn geheel ook wanneer de plaintext dat niet doet. De oudere algoritmen vouwen het eerste/ID-element in de sleutel (ISO 32000-1 §7.6.3.3, §7.6.3.4), dus een verse identifier alleen al is genoeg om het bestand van een nieuwe sleutel te voorzien - De orde. Een PDF-dictionary is een ongeordende mapping, en een writer die zijn in-memory lijst doorloopt schrijft keys in invoegorde uit. Elk codepad dat een resourcedictionary in een andere volgorde opbouwt, of een geladen document dat uit een andere lay-out is geparst, levert een geldig maar tekstueel ander bestand op
Wat legt ReproducibleOutput precies vast?
ReproducibleOutput := True zetten vóór BeginDoc of vóór SaveLoadedDocument vervangt elk van de vier bronnen door een vaste waarde, en dat gebeurt in dezelfde codepaden die anders de klok of de randomgenerator zouden raadplegen, dus een aparte opruimronde is niet nodig. Merk op wat er in de lijst hierboven ontbreekt: de inhoud. Fonts, paginastreams, imagedata en de cross-referencetabel zijn al deterministisch voor dezelfde input; de ruis zit volledig in de metadata en de beveiligingslaag, en daarom kan één gerichte property hem weghalen. De property staat standaard op False en niets in de library zet hem voor u aan
var
Pdf: THotPDF;
begin
Pdf := THotPDF.Create(nil);
try
Pdf.AutoLaunch := False;
Pdf.FileName := 'golden-invoice.pdf';
Pdf.ReproducibleOutput := True; // vóór BeginDoc
Pdf.BeginDoc;
Pdf.CurrentPage.SetFont('Arial', [], 12);
Pdf.CurrentPage.TextOut(40, 40, 0, 'Invoice 2026-0042');
Pdf.EndDoc;
finally
Pdf.Free;
end;
end;
Binnen BeginDoc wijst de reproduceerbare tak FCreationDate := EncodeDate(2026, 1, 1) toe en seedt hij de documentidentifier met MD5CalcString('HotPDF-reproducible-seed') in plaats van de digest van bestandsnaam plus klok. Die ene toewijzing dekt beide Info-datums en beide XMP-datums, want alle vier worden uit hetzelfde veld gerenderd. Wanneer het bestand uiteindelijk wordt geschreven, vraagt BuildDocumentIdentifiers de trailer-identifier op bij ComputeCanonicalDocumentIdentifier: die exporteert de hele objectgraaf in canonieke orde, zet de cijfers van elke D:-datumstring die hij vindt op nul zodat de timestamps niet via de hash terug kunnen lekken, en neemt de MD5 van het resultaat. Beide elementen van /ID krijgen die waarde. Dezelfde uit de inhoud afgeleide identifier wordt gebruikt wanneer een geladen document wordt versleuteld zonder ooit door BeginDoc te gaan, wat het geval is bij ActivateProtection op een bestand dat u met LoadFromFile hebt geopend
De random bytes zijn de minst voor de hand liggende vervanging. De AES-256-sleutelroutine wikkelt zijn randombron in een lokale helper die, onder de flag, FillChar(P^, Count, $5A) aanroept voor de file encryption key van 32 bytes en voor elke salt van 8 bytes, en de string- en streamencryptors voor AES-128 en AES-256 stappen over van AESGenerateRandomIV op AESGenerateStaticIV, dat de initialisatievector vult met 14 * (1 + I) voor slot I. Met de sleutel, de salts en de vectoren allemaal vast komen /U, /UE, /O, /OE en elke versleutelde stream bij de tweede run identiek uit. Ten slotte zet SaveToStream DeterministicDictionaryOrder aan zodra de reproduceerbare flag gezet is, en sorteert de serializer elke dictionary dan met een insertion sort op de ruwe bytes van zijn keynamen, kortere prefix eerst, met de oorspronkelijke index als tie-breaker. Dat is dezelfde ordening die de diagnostische writer gebruikt, beschreven in het artikel over het met de hand bewerken van een PDF en het daarna repareren; de reproduceerbare flag leent alleen de ordening, niet de rest van de plaintext-lay-out van die writer
Waarom lekte de vaste datum alsnog de klok?
De fix in v2.752.2 bestaat omdat de vaste aanmaakdatum oorspronkelijk in de constructor werd bepaald, en de constructor kan een property die de aanroeper nog niet heeft gezet niet kennen. De normale aanroepreeks is Create, dan ReproducibleOutput := True, dan BeginDoc. Op het moment van constructie is FReproducibleOutput nog False, dus kreeg FCreationDate Now en hield die vast. De identifier en de random bytes waren wel correct gepind, dus de twee bestanden kwamen bijna overal overeen en verschilden precies in twee datumstrings en twee XMP-velden. De toewijzing naar de reproduceerbare tak van BeginDoc verplaatsen, naast de geseede identifier, legde de beslissing op het punt waar de property zijn definitieve waarde heeft
De regressietest die dit miste is meer waard dan de fix. Twee saves die beide binnen dezelfde klokseconde lopen schrijven toevallig dezelfde D:-string, en de bytevergelijking slaagt voor een bug die op elke tragere machine faalt. De gecorrigeerde test slaapt 1100 ms tussen de twee saves zodat de PDF-timestamp gegarandeerd een secondegrens overschrijdt, draait het geval voor platte, AES-128- en AES-256-output met echte wachtwoorden op de twee versleutelde varianten, en vergelijkt de twee buffers met CompareMem, waarbij hij bij een fout de eerste afwijkende offset meldt zodat de diff naar een specifiek object wijst in plaats van naar een heel bestand. Een bytevergelijking bewijst determinisme en niets anders, dus houd een aparte assertie die de versleutelde output met het gebruikerswachtwoord herlaadt en een paginanummer leest; een wijziging die het bestand stabiel en onleesbaar tegelijk maakt mag er niet doorheen glippen op grond van een groene diff
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;
// in de testbody
A := SaveOnce(PathA);
TThread.Sleep(1100); // forceer een andere seconde in de PDF-timestamp
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');
Is een reproduceerbare versleutelde PDF nog veilig?
Nee. Een document dat onder ReproducibleOutput versleuteld is, is in geen enkele zin beschermd, en de flag moet uit staan voor alles wat de testmap verlaat. De AES-256 file encryption key is tweeëndertig bytes $5A, de salts zijn acht bytes $5A, en de initialisatievectoren volgen een gepubliceerd rekenpatroon. Het wachtwoord bewaakt nog steeds de /UE- en /OE-wrappers, maar de ingepakte sleutel is een constante, dus wie die constante kent kan elke contentstream zonder wachtwoord ontsleutelen. Doordat de salts vastliggen verdwijnt ook de uniekheid per document waarop ISO 32000-2 §7.6.4.4.7 leunt om te voorkomen dat identieke wachtwoorden in verschillende bestanden tot identieke /U-strings leiden. Lees het artikel over het opzetten van AES-256 voor wat de versleutelingsproperties beloven wanneer de randombron intact is; onder de reproduceerbare flag zijn die beloftes opgeschort
De afweging rond de identifier is subtieler. ISO 32000-1 §14.4 bedoelt dat het tweede /ID-element bij elke wijziging verandert, zodat tools een bijgewerkt bestand van zijn voorouder kunnen onderscheiden, en een reproduceerbare save schrijft dezelfde waarde in beide slots. Omdat die waarde een hash van de canonieke objectgraaf is, krijgen twee documenten met verschillende inhoud nog steeds verschillende identifiers, wat beter is dan een constante. Maar de seed die BeginDoc voor sleutelafleiding gebruikt is voor elk document op elke machine dezelfde string, en een reader die op /ID afgaat om bestanden te onderscheiden, bijvoorbeeld een annotatiecache of een sidecar met formulierdata, gooit elk reproduceerbaar bestand dat toevallig dezelfde hash oplevert op één hoop
Wat dekt de flag niet?
ReproducibleOutput haalt de entropie weg die de writer zelf introduceert; het kan geen entropie weghalen die via de omgeving of via codepaden die het niet beheert binnenkomt, en over drie daarvan struikelt u makkelijk
- De tijdzonesuffix.
_DateTimeToPdfDatevoegt de lokale UTC-offset toe, dusD:20260101000000+08'00'op de ene buildagent enD:20260101000000-05'00'op de andere zijn verschillende bytes voor dezelfde vaste datum. Reproduceerbaarheid geldt over runs op één machine, of over machines die dezelfde tijdzone delen; pin de zone van de agent als uw golden files reizen - Incrementele updates.
SaveIncrementalUpdateberekent zijn wijzigingsidentifier uit het doelpad,GetTickCounten de huidige tijd, zonder reproduceerbare tak, want een incrementele sectie is per definitie een nieuwe wijziging. Vergelijk volledige herschrijvingen, niet aangehangen delta's - De passthrough-shortcut.
SaveLoadedDocumentkopieert een ongewijzigd, onversleuteld bronbestand normaal byte voor byte in plaats van het opnieuw te serialiseren. De reproduceerbare flag schakelt die shortcut uit en forceert een volledige herschrijving zodat de ordenings- en identifierregels gelden, wat betekent dat de reproduceerbare save van een geladen bestand trager is dan de standaard en nooit een kopie van de input is. Diff hem tegen een eerdere reproduceerbare save, nooit tegen het origineel
Nog één les uit dezelfde release, over wat een geslaagde check wel en niet bewijst. Een PDF/X-6-testfixture riep CharProcs.DeleteValue('A') aan, wat een direct vastgehouden glyphstream vrijgaf, en voegde daarna dezelfde pointer opnieuw in, en gaf apart nog één direct ExtGState-object aan zowel een resourcedictionary als een pattern. De conformiteitsvalidator slaagde af en toe op die use-after-free en dubbele eigendom omdat hij las wat het vrijgegeven geheugen toevallig bevatte. Wanneer een structurele check flikkert, kijk dan naar het eigendom van de testinput voordat u naar de validator kijkt. Reproduceerbare output maakt die discipline goedkoper: zodra twee saves byte-identiek zijn, is de enige overgebleven flikkering de objectgraaf zelf, en een structurele diff vanaf de catalog naar beneden vindt hem
De hier beschreven properties ReproducibleOutput, DeterministicDictionaryOrder en versleuteling zitten in de standaard HotPDF Delphi Component voor Delphi en C++Builder, en dezelfde flag drijft het eigen regressiecorpus van de library, dus het gedrag dat u in een testsuite krijgt is het gedrag waarmee de component getest wordt