HotPDF Delphi Component producerer byte-identisk PDF-output på tværs af gemninger, når ReproducibleOutput-propertyen er True: den fastlåser Info /CreationDate og /ModDate til en fast dato, erstatter wall-clock dokument-id'en med en seeded eller indholdsafledt hash, substituerer konstanter for hver tilfældig byte, som AES-krypteringsvejene ellers ville trække, og sorterer hver dictionary, den serialiserer. Flaget findes til regressionssuiter og build-artefakt-sammenligning, ikke til produktionsdokumenter, og grunderne til den grænse er den interessante del. Scenariet, der driver featuren, er en golden-file-test. Du renderer en faktura, committer PDF'en og assert'er, at morgendagens build producerer de samme bytes. Det gør den aldrig. Filen åbner fint i alle viewers, teksten er identisk, sidetræet er identisk, og diffen lyser stadig op fire-fem steder. Enhver, der har forsøgt at sætte en PDF-generator under en byte-niveau regressionstest, har ramt denne mur, og fixet er ikke "strip timestamps'ene", men en præcis opgørelse over hvert sted, writeren konsulterer noget andet end dokumentet selv
Hvorfor adskiller to gemninger af samme PDF sig?
To gemninger af samme dokument adskiller sig, fordi en PDF-writer, HotPDF inkluderet, konsulterer fire entropikilder, som ikke har noget med sideindhold at gøre: wall clock'en, dokument-id'en, den kryptografiske tilfældighedsgenerator og memory-rækkefølgen af dictionary-entries. Hver er legitim i sig selv. ISO 32000-1 vil have dem dér. De gør simpelthen filen til en funktion af, hvornår og hvor den blev skrevet, snarere end af, hvad den indeholder
- Ur'en. Info-dictionaryen bærer
/CreationDateog/ModDate(ISO 32000-1 §14.3.3, Tabel 317) somD:YYYYMMDDHHmmSS-strenge med et tidszone-suffix (§7.9.4), og XMP-pakken gentager samme øjeblik somxmp:CreateDateogxmp:ModifyDate. HotPDF stempler begge fraFCreationDate, som konstruktoren initialiserer tilNow, så de to gemninger adskiller sig i den sekund, de blev skrevet - Id'en. Trailer-
/ID-arrayet (ISO 32000-1 §14.4) holder en permanent identifikator og en ændringsidentifikator. HotPDFs default-opskrift hasher filnavnet sammen med klokkeslættet ned til millisekunded for det første element og hasher det plusGetTickCountfor det andet. To id'er, to friske værdier ved hvert gennemløb - De tilfældige bytes. Standard security afhænger af id'en og af ægte tilfældighed. For AES-256 trækkes file encryption key'en, validerings- og nøglesaltene og hver CBC-initialiseringsvektor fra systemets tilfældighedskilde (ISO 32000-2 §7.6.4.4.7 kræver tilfældige salte). Da
/U,/UE,/Oog/OEalle beregnes ud fra de bytes, ændrer et krypteret dokument sig i sin helhed, selv når klarteksten ikke gør. De ældre algoritmer folder det første/ID-element ind i nøglen (ISO 32000-1 §7.6.3.3, §7.6.3.4), så en frisk id alene er nok til at re-nøgle filen - Rækkefølgen. En PDF-dictionary er en uordnet mapping, og en writer, der gennemløber sin in-memory-liste, emitterer nøgler i indsættelsesrækkefølge. Enhver kodevej, der bygger en resource-dictionary i en anden sekvens, eller et loadet dokument, der blev parset fra et andet layout, producerer en lovlig men tekstligt forskellig fil
Hvad fastlåser ReproducibleOutput?
Sætter du ReproducibleOutput := True før BeginDoc eller før SaveLoadedDocument, erstattes hver af de fire kilder med en fast værdi, og det sker i de samme kodeveje, der ellers ville nå ud efter ur eller tilfældighedsgenerator, så der behøves ingen separat oprydningsgennemløb. Bemærk, hvad der mangler på listen ovenfor: indholdet. Fonts, sidestreams, billeddata og cross-reference-tabellen er allerede deterministiske for samme input; støjen bor helt i metadataene og sikkerhedslaget, hvilket er grunden til, at én målrettet property kan fjerne den. Propertyen default'er til False, og intet i biblioteket slår den til for dig
var
Pdf: THotPDF;
begin
Pdf := THotPDF.Create(nil);
try
Pdf.AutoLaunch := False;
Pdf.FileName := 'golden-invoice.pdf';
Pdf.ReproducibleOutput := True; // fø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;
Inder i BeginDoc tildeler den reproducerbare gren FCreationDate := EncodeDate(2026, 1, 1) og såer dokument-id'en med MD5CalcString('HotPDF-reproducible-seed') i stedet for filnavn-plus-ur-digestet. Den ene tildeling dækker både Info-datoerne og begge XMP-datoer, for alle fire renderes fra samme felt. Når filen endelig skrives, spørger BuildDocumentIdentifiers ComputeCanonicalDocumentIdentifier om trailer-id'en: den eksporterer hele objektgrafen i kanonisk rækkefølge, nulstiller cifrene af enhver D:-datostring, den finder, så timestamps ikke kan sive tilbage gennem hashen, og tager MD5 af resultatet. Begge elementer af /ID modtager den værdi. Det samme indholdsafledte id bruges, når et loadet dokument krypteres uden nogensinde at passere BeginDoc, hvilket er tilfældet for ActivateProtection på en fil, du åbnede med LoadFromFile
De tilfældige bytes er den mindst indlysende substitution. AES-256-nøglerutinen pakker sin tilfældighedskilde ind i en lokal helper, der under flaget kalder FillChar(P^, Count, $5A) for den 32-byte file encryption key og for hvert 8-byte salt, og AES-128- og AES-256-streng- og stream-kryptorerne skifter fra AESGenerateRandomIV til AESGenerateStaticIV, som fylder initialiseringsvektoren med 14 * (1 + I) for plads I. Med nøglen, saltene og vektorerne alle faste kommer /U, /UE, /O, /OE og hver krypteret stream identiske ud på det andet gennemløb. Til sidst slår SaveToStream DeterministicDictionaryOrder til, når endeligt det reproducerbare flag er sat, og serialisatoren insertion-sorterer derefter hver dictionary efter de rå bytes af sine nøglenavne, kortere præfiks først, med det oprindelige indeks som tie-breaker. Det er samme rækkefølge, som diagnostic-writeren bruger, beskrevet i artiklen om at håndredigere en PDF og reparere den bagefter; det reproducerbare flag låner kun rækkefølgen, ikke resten af den writers klartekstlayout
Hvorfor lækkede den faste dato stadig wall clock'en?
Fixet i v2.752.2 findes, fordi den faste oprettelsesdato oprindeligt blev besluttet i konstruktoren, og konstruktoren kan ikke kende en property, calleren endnu ikke har sat. Den normale kaldesekvens er Create, derefter ReproducibleOutput := True, derefter BeginDoc. Ved konstruktionstidspunktet er FReproducibleOutput stadig False, så FCreationDate modtog Now og beholdt den. Id'en og de tilfældige bytes var korrekt fastlåst, så de to filer var enige stort set over alt og uenige i præcis to datostrings og to XMP-felter. At flytte tildelingen ind i den reproducerbare gren af BeginDoc, ved siden af den såede id, satte beslutningen der, hvor propertyen har sin endelige værdi
Regressionstesten, der missete dette, er mere værd end fixet. To gemninger, der begge kører inden for samme wall-clock-sekund, skriver samme D:-string ved et uheld, og bytesammenligningen består for en bug, der fejler på enhver langsommere maskine. Den korrigerede test sover 1100 ms mellem de to gemninger, så PDF-timestampet garanteret krydser en sekundgrænse, kører tilfældet for plain, AES-128 og AES-256-output med rigtige passwords på de to krypterede varianter og sammenligner de to buffere med CompareMem og rapporterer den første afvigende offset ved fejl, så diffen peger på et specifikt objekt i stedet for en hel fil. En bytesammenligning beviser determinisme og intet andet, så behold en separat assertion, der genindlæser det krypterede output med user password'et og læser et sideantal; en ændring, der gør filen stabil og ulæselig på samme tid, må ikke sive igennem på kraften af en grøn 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;
// i testkroppen
A := SaveOnce(PathA);
TThread.Sleep(1100); // tving en anden PDF-timestamp-sekund frem
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');
Er en reproducerbar krypteret PDF stadig sikker?
Nej. Et dokument krypteret under ReproducibleOutput er ikke beskyttet i nogen meningsfuld forstand, og flaget skal være slået fra for alt, der forlader testmappen. AES-256 file encryption key'en er 32 bytes af $5A, saltene er otte bytes af $5A, og initialiseringsvektorerne følger et offentliggjort aritmetisk mønster. Passwordet gater stadig /UE- og /OE-wrapperne, men den indpakkede nøgle er en konstant, så enhver, der kender konstanten, kan dekryptere hver content stream uden et password overhovedet. At saltene er faste fjerner også den per-dokument unikhed, som ISO 32000-2 §7.6.4.4.7 støtter sig til for at holde identiske passwords fra at give identiske /U-strenge på tværs af filer. Læs AES-256-opsætningsartiklen for, hvad krypteringspropertyerne lover, når tilfældighedskilden er intakt; under det reproducerbare flag er de løfter suspenderet
Id-afvejningen er subtilere. ISO 32000-1 §14.4 har til hensigt, at det andet /ID-element skal ændre sig ved hver ændring, så værktøjer kan kende en opdateret fil fra dens forfader, og en reproducerbar gemning skriver samme værdi i begge pladser. Da den værdi er en hash af den kanoniske objektgraf, får to dokumenter med forskelligt indhold stadig forskellige id'er, hvilket er bedre end en konstant. Men seedet, som BeginDoc bruger til nøgleudledning, er den samme streng for hvert dokument på hver maskine, og en reader, der nøgler på /ID for at skelne filer, en annotations-cache eller en formular-data-sidecar for eksempel, vil sammenblande hver reproducerbar fil, der tilfældigvis hasher ens
Hvad dækker flaget ikke?
ReproducibleOutput fjerner den entropi, writeren selv introducerer; den kan ikke fjerne entropi, der kommer ind gennem miljøet eller gennem kodeveje, den ikke styrer, og tre af dem er lette at snuble over
- Tidszone-suffixet.
_DateTimeToPdfDateappender den lokale UTC-offset, såD:20260101000000+08'00'på én build-agent ogD:20260101000000-05'00'på en anden er forskellige bytes for den samme faste dato. Reproducerbarhed holder på tværs af gennemløb på én maskine, eller på tværs af maskiner, der deler tidszone; fastlås agentens zone, hvis dine golden-filer rejser - Incremental updates.
SaveIncrementalUpdateberegner sin ændrings-id ud fra target-stien,GetTickCountog klokkeslættet uden nogen reproducerbar gren, for en inkrementel sektion er per definition en ny ændring. Sammenlign fulde omskrivninger, ikke appendede deltas - Passthrough-genvejen.
SaveLoadedDocumentkopierer normalt en uændret, ukrypteret kildefil byte for byte i stedet for at re-serialisere den. Det reproducerbare flag deaktiverer den genvej og tvinger en fuld omskrivning igennem, så rækkefølge- og id-reglerne gælder, hvilket betyder, at den reproducerbare gemning af en loadet fil er langsommere end default og aldrig er en kopi af inputtet. Diff den op mod en tidligere reproducerbar gemning, aldrig op mod originalen
Endnu en lektie fra samme release, om hvad et bestået tjek beviser, og hvad det ikke gør. En PDF/X-6-testfixture kaldte CharProcs.DeleteValue('A'), hvilket frigjorde en direkte holdt glyph-stream, indsatte derefter den samme pointer igen og gav separat ét direkte ExtGState-objekt til både en resource-dictionary og et pattern. Conformance-validatoren bestod skiftevis det use-after-free og dobbelte ejerskab, fordi den læste, hvad end den frigjorte hukommelse tilfældigvis holdt. Når et strukturelt tjek flimrer, så kig på ejerskabet af test-inputtet, før du kigger på validatoren. Reproducerbart output gør den disciplin billigere: når to gemninger først er byte-identiske, er den eneste tilbageværende kilde til en flimren objektgrafen selv, og en strukturel diff fra catalog og ned finder den
De her beskrevne ReproducibleOutput-, DeterministicDictionaryOrder- og krypteringsproperties følger med i den standard HotPDF Delphi Component til Delphi og C++Builder, og det samme flag driver bibliotekets eget regressionskorpus, så den adfærd, du får i en test-suite, er den adfærd, komponenten er testet med