HotPDF Delphi Component producerar byteidentisk PDF-utdata mellan sparningar när egenskapen ReproducibleOutput är True: den fäster Info-posterna /CreationDate och /ModDate vid ett fast datum, ersätter dokumentidentifieraren som bygger på väggklockan med en seedad eller innehållshärledd hash, byter ut varje slumpbyte som AES-krypteringsvägarna annars skulle dra mot konstanter, och sorterar varje ordbok den serialiserar. Flaggan finns för regressionssviter och jämförelse av byggartefakter, inte för produktionsdokument, och skälen till den gränsen är det intressanta. Scenariot som driver funktionen är ett golden-file-test. Du renderar en faktura, committar PDF:en, och hävdar att morgondagens bygge producerar samma byte. Det gör det aldrig. Filen öppnas fint i varje viewer, texten är identisk, sidträdet är identiskt, och diffen lyser ändå på fyra eller fem ställen. Den som någon gång försökt sätta en PDF-generator under ett bytenivå-regressionstest har slagit i den här väggen, och fixen är inte "ta bort tidsstämplarna" utan en precis redovisning av varje plats där skrivaren konsulterar något annat än dokumentet självt
Varför skiljer sig två sparningar av samma PDF?
Två sparningar av samma dokument skiljer sig för att en PDF-skrivare, HotPDF inräknad, konsulterar fyra entropikällor som inte har något med sidinnehållet att göra: väggklockan, dokumentidentifieraren, den kryptografiska slumptalsgeneratorn, och minnesordningen hos ordboksposterna. Var och en är legitim för sig. ISO 32000-1 vill ha dem där. De gör helt enkelt filen till en funktion av när och var den skrevs i stället för av vad den innehåller
- Klockan. Info-ordboken bär
/CreationDateoch/ModDate(ISO 32000-1 §14.3.3, tabell 317) somD:YYYYMMDDHHmmSS-strängar med tidszonssuffix (§7.9.4), och XMP-paketet upprepar samma ögonblick somxmp:CreateDateochxmp:ModifyDate. HotPDF stämplar båda frånFCreationDate, som konstruktorn initierar tillNow, så de två sparningarna skiljer sig på sekunden de skrevs - Identifieraren. Trailerns
/ID-array (ISO 32000-1 §14.4) håller en permanent identifierare och en ändringsidentifierare. HotPDF:s standardrecept hashar filnamnet tillsammans med aktuell tid ned till millisekunden för det första elementet, och hashar det plusGetTickCountför det andra. Två identifierare, två färska värden varje körning - Slumpbyten. Standard-säkerhet beror på identifieraren och på äkta slump. För AES-256 hämtas filkrypteringsnyckeln, validerings- och nyckelsalterna samt varje CBC-initieringsvektor från systemets slumptalskälla (ISO 32000-2 §7.6.4.4.7 kräver slumpade salter). Eftersom
/U,/UE,/Ooch/OEalla beräknas från de byten ändras ett krypterat dokument i sin helhet även när klartexten inte gör det. De äldre algoritmerna viker in det första/ID-elementet i nyckeln (ISO 32000-1 §7.6.3.3, §7.6.3.4), så en färsk identifierare räcker för att nyckla om filen - Ordningen. En PDF-ordbok är en oordnad mappning, och en skrivare som går genom sin lista i minnet skickar ut nycklar i insättningsordning. Varje kodväg som bygger en resursordbok i en annan sekvens, eller ett inläst dokument som parsades från en annan layout, producerar en laglig men textuellt annorlunda fil
Vad låser ReproducibleOutput fast?
Att sätta ReproducibleOutput := True före BeginDoc eller före SaveLoadedDocument ersätter var och en av de fyra källorna med ett fast värde, och det gör det i samma kodvägar som annars skulle sträcka sig efter klockan eller slumptalsgeneratorn, så ingen separat städningsomgång behövs. Notera vad som saknas i listan ovan: innehållet. Typsnitt, sidströmmar, bilddata och cross-reference-tabellen är redan deterministiska för samma indata; bruset bor helt och hållet i metadatan och säkerhetslagret, vilket är varför en enda riktad egenskap kan ta bort det. Egenskapen är False som standard och ingenting i biblioteket slår på den åt dig
var
Pdf: THotPDF;
begin
Pdf := THotPDF.Create(nil);
try
Pdf.AutoLaunch := False;
Pdf.FileName := 'golden-invoice.pdf';
Pdf.ReproducibleOutput := True; // före BeginDoc
Pdf.BeginDoc;
Pdf.CurrentPage.SetFont('Arial', [], 12);
Pdf.CurrentPage.TextOut(40, 40, 0, 'Invoice 2026-0042');
Pdf.EndDoc;
finally
Pdf.Free;
end;
end;
Inuti BeginDoc tilldelar den reproducerbara grenen FCreationDate := EncodeDate(2026, 1, 1) och såddar dokumentidentifieraren med MD5CalcString('HotPDF-reproducible-seed') i stället för sammandraget av filnamn plus klocka. Den enda tilldelningen täcker båda Info-datumen och båda XMP-datumen, eftersom alla fyra renderas från samma fält. När filen till sist skrivs frågar BuildDocumentIdentifiers ComputeCanonicalDocumentIdentifier efter trailer-identifieraren: den exporterar hela objektgrafen i kanonisk ordning, nollar siffrorna i varje D:-datumsträng den hittar så att tidsstämplarna inte kan läcka tillbaka in genom hashen, och tar MD5:an av resultatet. Båda elementen i /ID får det värdet. Samma innehållshärledda identifierare används när ett inläst dokument krypteras utan att någonsin passera BeginDoc, vilket är fallet för ActivateProtection på en fil du öppnade med LoadFromFile
Slumpbyten är den minst uppenbara substitutionen. AES-256-nyckelrutinen lindar in sin slumptalskälla i en lokal hjälpare som, under flaggan, anropar FillChar(P^, Count, $5A) för den 32 byte långa filkrypteringsnyckeln och för varje 8-byte-salt, och sträng- och strömkrypterarna för AES-128 och AES-256 växlar från AESGenerateRandomIV till AESGenerateStaticIV, som fyller initieringsvektorn med 14 * (1 + I) för plats I. Med nyckeln, salterna och vektorerna alla fixerade kommer /U, /UE, /O, /OE och varje krypterad ström ut identiska vid andra körningen. Slutligen slår SaveToStream på DeterministicDictionaryOrder när den reproducerbara flaggan är satt, och serialiseraren infogningssorterar då varje ordbok efter råbyten i dess nyckelnamn, kortare prefix först, med det ursprungliga indexet som skiljedomare. Det är samma ordning den diagnostiska skrivaren använder, beskriven i artikeln om att handredigera en PDF och reparera den efteråt; den reproducerbara flaggan lånar bara ordningen, inte resten av den skrivarens klartextlayout
Varför läckte det fasta datumet ändå väggklockan?
Fixen i v2.752.2 finns för att det fasta skapelsedatumet ursprungligen bestämdes i konstruktorn, och konstruktorn kan inte känna till en egenskap som anroparen ännu inte har satt. Den normala anropssekvensen är Create, sedan ReproducibleOutput := True, sedan BeginDoc. Vid konstruktionstillfället är FReproducibleOutput fortfarande False, så FCreationDate fick Now och behöll det. Identifieraren och slumpbyten fästes korrekt, så de två filerna var överens nästan överallt och skilde sig i exakt två datumsträngar och två XMP-fält. Att flytta tilldelningen in i den reproducerbara grenen av BeginDoc, bredvid den seedade identifieraren, lade beslutet där egenskapen har sitt slutliga värde
Regressionstestet som missade detta är värt mer än fixen. Två sparningar som båda kör inom samma väggklockesekund skriver samma D:-sträng av en slump, och bytejämförelsen passerar för en bugg som fallerar på vilken långsammare maskin som helst. Det rättade testet sover 1100 ms mellan de två sparningarna så att PDF-tidsstämpeln garanterat passerar en sekundgräns, kör fallet för oformaterad, AES-128- och AES-256-utdata med riktiga lösenord på de två krypterade varianterna, och jämför de två buffertarna med CompareMem och rapporterar den första avvikande offseten vid fel så att diffen pekar på ett specifikt objekt i stället för på en hel fil. En bytejämförelse bevisar determinism och ingenting annat, så behåll ett separat hävdande som laddar om den krypterade utdatan med användarlösenordet och läser ett sidantal; en ändring som gör filen stabil och oläsbar på samma gång får inte slinka igenom på styrkan av 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); // tvinga fram en annan PDF-tidsstämpelsekund
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');
Är en reproducerbar krypterad PDF fortfarande säker?
Nej. Ett dokument som krypteras under ReproducibleOutput är inte skyddat i någon meningsfull mening, och flaggan måste vara av för allt som lämnar testkatalogen. AES-256-filkrypteringsnyckeln är trettiotvå byte av $5A, salterna är åtta byte av $5A, och initieringsvektorerna följer ett publicerat aritmetiskt mönster. Lösenordet grindar fortfarande /UE- och /OE-omslagen, men den inlindade nyckeln är en konstant, så den som känner till konstanten kan dekryptera varje innehållsström utan lösenord alls. Att salterna är fixerade tar också bort den per-dokument-unikhet som ISO 32000-2 §7.6.4.4.7 förlitar sig på för att hindra identiska lösenord från att ge identiska /U-strängar mellan filer. Läs artikeln om AES-256-uppsättningen för vad krypteringsegenskaperna lovar när slumptalskällan är intakt; under den reproducerbara flaggan är de löftena upphävda
Identifieraravvägningen är subtilare. ISO 32000-1 §14.4 avser att det andra /ID-elementet ska ändras vid varje ändring så att verktyg kan skilja en uppdaterad fil från sin förfader, och en reproducerbar sparning skriver samma värde i båda platserna. Eftersom det värdet är en hash av den kanoniska objektgrafen får två dokument med olika innehåll fortfarande olika identifierare, vilket är bättre än en konstant. Men seeden som BeginDoc använder för nyckelhärledning är samma sträng för varje dokument på varje maskin, och en läsare som nycklar på /ID för att skilja filer åt, till exempel en annotationscache eller en sidofil med formulärdata, kommer att blanda ihop varje reproducerbar fil som råkar hasha likadant
Vad täcker inte flaggan?
ReproducibleOutput tar bort den entropi skrivaren inför på egen hand; den kan inte ta bort entropi som kommer in genom miljön eller genom kodvägar den inte styr, och tre av dem är lätta att snubbla på
- Tidszonssuffixet.
_DateTimeToPdfDatelägger till den lokala UTC-offseten, såD:20260101000000+08'00'på en byggagent ochD:20260101000000-05'00'på en annan är olika byte för samma fasta datum. Reproducerbarheten håller mellan körningar på en maskin, eller mellan maskiner som delar tidszon; fäst agentens zon om dina golden-filer reser - Inkrementella uppdateringar.
SaveIncrementalUpdateräknar fram sin ändringsidentifierare från målsökvägen,GetTickCountoch aktuell tid utan någon reproducerbar gren, eftersom ett inkrementellt avsnitt per definition är en ny ändring. Jämför fulla omskrivningar, inte tillagda deltan - Genomgångsgenvägen.
SaveLoadedDocumentkopierar normalt en omodifierad, okrypterad källfil byte för byte i stället för att serialisera om den. Den reproducerbara flaggan stänger av den genvägen och tvingar fram en full omskrivning så att ordnings- och identifierarreglerna gäller, vilket betyder att den reproducerbara sparningen av en inläst fil är långsammare än standard och aldrig är en kopia av indatan. Diffa den mot en tidigare reproducerbar sparning, aldrig mot originalet
En lärdom till från samma release, om vad en passerande kontroll gör och inte gör anspråk på att bevisa. En PDF/X-6-testfixture anropade CharProcs.DeleteValue('A'), vilket frigjorde en direkt hållen glyfström, och satte sedan in samma pekare igen, och gav dessutom ett och samma direkta ExtGState-objekt till både en resursordbok och ett mönster. Konformitetsvalidatorn passerade intermittent på den use-after-free:n och dubbelägandet eftersom den läste vad det frigjorda minnet råkade innehålla. När en strukturell kontroll flimrar, titta på testindatans ägande innan du tittar på validatorn. Reproducerbar utdata gör den disciplinen billigare: när två sparningar väl är byteidentiska är den enda återstående källan till flimmer objektgrafen själv, och en strukturell diff från katalogen och nedåt hittar den
Egenskaperna ReproducibleOutput, DeterministicDictionaryOrder och kryptering som beskrivs här levereras i standardupplagan av HotPDF Delphi Component för Delphi och C++Builder, och samma flagga driver bibliotekets egen regressionskorpus, så beteendet du får i en testsvit är det beteende komponenten testas med