HotPDF Delphi Component gir byte-identiske PDF-utdata på tvers av lagringer når egenskapen ReproducibleOutput er True: den fester Info-/CreationDate og /ModDate til en fast dato, erstatter den veggklokkebaserte dokumentidentifikatoren med en seedet eller innholdsavledet hash, bytter ut hver tilfeldig byte AES-krypteringsveiene ellers ville trukket med konstanter, og sorterer hver ordbok den serialiserer. Flagget finnes for regresjonspakker og sammenligning av byggeartefakter, ikke for produksjonsdokumenter, og grunnene til den grensen er den interessante delen. Scenarioet som driver funksjonen, er en golden-file-test. Du gjengir en faktura, committer PDF-en og verifiserer at morgendagens bygg produserer de samme bytene. Det gjør den aldri. Filen åpner seg fint i alle visningsprogrammer, teksten er identisk, sidetreet er identisk, og diffen lyser likevel opp på fire-fem steder. Alle som har prøvd å sette en PDF-generator under en byte-nivå-regresjonstest, har truffet denne veggen, og fiksen er ikke «fjern tidsstemplene», men en presis oversikt over hvert sted skriveren konsulterer noe annet enn dokumentet selv
Hvorfor er to lagringer av samme PDF forskjellige?
To lagringer av samme dokument er forskjellige fordi en PDF-skriver, HotPDF inkludert, konsulterer fire entropikilder som ikke har noe med sideinnholdet å gjøre: veggklokken, dokumentidentifikatoren, den kryptografiske tilfeldighetsgeneratoren og minnerekkefølgen til ordbokoppføringer. Hver av dem er legitim i seg selv. ISO 32000-1 vil ha dem der. De gjør ganske enkelt filen til en funksjon av når og hvor den ble skrevet, i stedet for av hva den inneholder
- Klokken. Info-ordboken bærer
/CreationDateog/ModDate(ISO 32000-1 §14.3.3, tabell 317) somD:YYYYMMDDHHmmSS-strenger med tidssonesuffiks (§7.9.4), og XMP-pakken gjentar samme øyeblikk somxmp:CreateDateogxmp:ModifyDate. HotPDF stempler begge fraFCreationDate, som konstruktøren initialiserer tilNow, så de to lagringene skiller seg i sekundet de ble skrevet - Identifikatoren. Trailer-arrayen
/ID(ISO 32000-1 §14.4) holder en permanent identifikator og en modifikasjonsidentifikator. Standardoppskriften i HotPDF hasher filnavnet sammen med gjeldende tid ned til millisekundet for det første elementet, og hasher det plussGetTickCountfor det andre. To identifikatorer, to ferske verdier på hver kjøring - De tilfeldige bytene. Standard sikkerhet avhenger av identifikatoren og av ekte tilfeldighet. For AES-256 trekkes filkrypteringsnøkkelen, validerings- og nøkkelsaltene og hver CBC-initialiseringsvektor fra systemets tilfeldighetskilde (ISO 32000-2 §7.6.4.4.7 krever tilfeldige salter). Fordi
/U,/UE,/Oog/OEalle beregnes fra de bytene, endres et kryptert dokument i sin helhet selv når klarteksten ikke gjør det. De eldre algoritmene folder det første/ID-elementet inn i nøkkelen (ISO 32000-1 §7.6.3.3, §7.6.3.4), så en fersk identifikator alene er nok til å gi filen en ny nøkkel - Rekkefølgen. En PDF-ordbok er en uordnet mapping, og en skriver som går gjennom sin egen liste i minnet, skriver ut nøkler i innsettingsrekkefølge. Enhver kodevei som bygger en ressurordbok i en annen sekvens, eller et lastet dokument som ble parset fra en annen layout, gir en lovlig men tekstlig forskjellig fil
Hva fester ReproducibleOutput?
Å sette ReproducibleOutput := True før BeginDoc eller før SaveLoadedDocument erstatter hver av de fire kildene med en fast verdi, og den gjør det i de samme kodeveiene som ellers ville strekke seg etter klokken eller tilfeldighetsgeneratoren, så ingen separat oppryddingsrunde trengs. Legg merke til hva som mangler i listen over: innholdet. Fonter, sidestrømmer, bildedata og kryssreferansetabellen er allerede deterministiske for samme inndata; støyen bor utelukkende i metadataene og sikkerhetslaget, og det er derfor én målrettet egenskap kan fjerne den. Egenskapen har False som standard, og ingenting i biblioteket slår den på for deg
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;
Inne i BeginDoc tilordner den reproduserbare grenen FCreationDate := EncodeDate(2026, 1, 1) og seeder dokumentidentifikatoren med MD5CalcString('HotPDF-reproducible-seed') i stedet for digesten av filnavn pluss klokke. Den ene tilordningen dekker både begge Info-datoene og begge XMP-datoene, fordi alle fire gjengis fra det samme feltet. Når filen til slutt skrives, ber BuildDocumentIdentifiers ComputeCanonicalDocumentIdentifier om trailer-identifikatoren: den eksporterer hele objektgrafen i kanonisk rekkefølge, nullstiller sifrene i enhver D:-datostreng den finner så tidsstemplene ikke kan lekke tilbake gjennom hashen, og tar MD5 av resultatet. Begge elementene i /ID får den verdien. Den samme innholdsavledede identifikatoren brukes når et lastet dokument krypteres uten å gå gjennom BeginDoc i det hele tatt, som er tilfellet for ActivateProtection på en fil du åpnet med LoadFromFile
De tilfeldige bytene er den minst opplagte substitusjonen. AES-256-nøkkelrutinen pakker tilfeldighetskilden sin inn i en lokal hjelper som under flagget kaller FillChar(P^, Count, $5A) for den 32 byte store filkrypteringsnøkkelen og for hvert 8-byte salt, og AES-128- og AES-256-kryptererne for strenger og strømmer bytter fra AESGenerateRandomIV til AESGenerateStaticIV, som fyller initialiseringsvektoren med 14 * (1 + I) for spor I. Med nøkkelen, saltene og vektorene alle faste kommer /U, /UE, /O, /OE og hver kryptert strøm ut identisk på andre kjøring. Til slutt slår SaveToStream på DeterministicDictionaryOrder når det reproduserbare flagget er satt, og serialisereren innsettingssorterer deretter hver ordbok etter de rå bytene i nøkkelnavnene, korteste prefiks først, med den opprinnelige indeksen som skilleverdi. Det er samme rekkefølge som diagnostikkskriveren bruker, beskrevet i artikkelen om å håndredigere en PDF og reparere den etterpå; det reproduserbare flagget låner bare rekkefølgen, ikke resten av den skriverens klartekstlayout
Hvorfor lakk den faste datoen fortsatt veggklokken?
Fiksen i v2.752.2 finnes fordi den faste opprettelsesdatoen opprinnelig ble bestemt i konstruktøren, og konstruktøren kan ikke kjenne en egenskap kalleren ennå ikke har satt. Den normale kallsekvensen er Create, deretter ReproducibleOutput := True, deretter BeginDoc. Ved konstruksjonstidspunktet er FReproducibleOutput fortsatt False, så FCreationDate fikk Now og beholdt den. Identifikatoren og de tilfeldige bytene var riktig festet, så de to filene var enige nesten overalt og uenige i nøyaktig to datostrenger og to XMP-felt. Å flytte tilordningen inn i den reproduserbare grenen av BeginDoc, ved siden av den seedede identifikatoren, la beslutningen der egenskapen har sin endelige verdi
Regresjonstesten som bommet på dette, er verdt mer enn fiksen. To lagringer som begge kjører innenfor samme veggklokkesekund, skriver den samme D:-strengen ved et tilfelle, og byte-sammenligningen passerer for en feil som feiler på enhver tregere maskin. Den korrigerte testen sover 1100 ms mellom de to lagringene så PDF-tidsstemplet garantert krysser en sekundgrense, kjører tilfellet for vanlige, AES-128- og AES-256-utdata med ekte passord på de to krypterte variantene, og sammenligner de to bufferne med CompareMem, og rapporterer den første avvikende offseten ved feil så diffen peker på et bestemt objekt i stedet for en hel fil. En byte-sammenligning beviser determinisme og ingenting annet, så behold en separat påstand som laster det krypterte utdata på nytt med brukerpassordet og leser et sidetall; en endring som gjør filen stabil og uleselig på samme tid, må ikke slippe gjennom på styrken av en grønn 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 frem et annet PDF-tidsstempelsekund
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 reproduserbar kryptert PDF fortsatt sikker?
Nei. Et dokument kryptert under ReproducibleOutput er ikke beskyttet i noen meningsfull forstand, og flagget må være av for alt som forlater testkatalogen. AES-256-filkrypteringsnøkkelen er trettito byte med $5A, saltene er åtte byte med $5A, og initialiseringsvektorene følger et offentliggjort aritmetisk mønster. Passordet gater fortsatt /UE- og /OE-innpakningene, men den innpakkede nøkkelen er en konstant, så den som kjenner konstanten, kan dekryptere hver innholdsstrøm helt uten passord. At saltene er faste fjerner også den per-dokument-unikheten ISO 32000-2 §7.6.4.4.7 lener seg på for å hindre at like passord gir like /U-strenger på tvers av filer. Les artikkelen om AES-256-oppsett for hva krypteringsegenskapene lover når tilfeldighetskilden er intakt; under det reproduserbare flagget er de løftene satt ut av spill
Avveiningen rundt identifikatoren er mer subtil. ISO 32000-1 §14.4 mener at det andre /ID-elementet skal endre seg ved hver modifikasjon så verktøy kan skille en oppdatert fil fra sin stamfar, og en reproduserbar lagring skriver samme verdi i begge sporene. Fordi den verdien er en hash av den kanoniske objektgrafen, får to dokumenter med forskjellig innhold fortsatt forskjellige identifikatorer, noe som er bedre enn en konstant. Men seeden som BeginDoc bruker til nøkkelutledning, er den samme strengen for hvert dokument på hver maskin, og en leser som bruker /ID til å skille filer, for eksempel en annotasjonscache eller en sidefil for skjemadata, vil blande sammen hver reproduserbar fil som tilfeldigvis hasher likt
Hva dekker ikke flagget?
ReproducibleOutput fjerner entropien skriveren selv introduserer; den kan ikke fjerne entropi som kommer inn gjennom miljøet eller gjennom kodeveier den ikke kontrollerer, og tre av dem er lette å snuble over
- Tidssonesuffikset.
_DateTimeToPdfDatelegger til den lokale UTC-offseten, såD:20260101000000+08'00'på én byggeagent ogD:20260101000000-05'00'på en annen er forskjellige byte for samme faste dato. Reproduserbarhet holder på tvers av kjøringer på én maskin, eller på tvers av maskiner som deler tidssone; fest tidssonen til agenten hvis golden-filene dine skal reise - Inkrementelle oppdateringer.
SaveIncrementalUpdateberegner sin modifikasjonsidentifikator fra målstien,GetTickCountog gjeldende tid uten noen reproduserbar gren, fordi en inkrementell seksjon per definisjon er en ny modifikasjon. Sammenlign fulle omskrivinger, ikke tilføyde deltaer - Passthrough-snarveien.
SaveLoadedDocumentkopierer normalt en uendret, ukryptert kildefil byte for byte i stedet for å serialisere den på nytt. Det reproduserbare flagget slår av den snarveien og tvinger frem en full omskriving så rekkefølge- og identifikatorreglene gjelder, noe som betyr at den reproduserbare lagringen av en lastet fil er tregere enn standarden og aldri er en kopi av inndataene. Diff den mot en tidligere reproduserbar lagring, aldri mot originalen
En lærdom til fra samme utgivelse, om hva en bestått sjekk beviser og ikke beviser. En PDF/X-6-testfikstur kalte CharProcs.DeleteValue('A'), som frigjorde en direkte holdt glyfstrøm, og satte deretter inn samme peker på nytt, og ga i tillegg ett direkte ExtGState-objekt til både en ressurordbok og et mønster. Samsvarsvalidatoren passerte av og til på den use-after-free-en og det doble eierskapet fordi den leste hva enn det frigjorte minnet tilfeldigvis holdt. Når en strukturell sjekk blafrer, se på eierskapet til testinndataene før du ser på validatoren. Reproduserbare utdata gjør den disiplinen billigere: når først to lagringer er byte-identiske, er den eneste gjenstående kilden til blafring objektgrafen selv, og en strukturell diff fra katalogen og ned vil finne den
Egenskapene ReproducibleOutput, DeterministicDictionaryOrder og kryptering beskrevet her leveres i standardutgaven av HotPDF Delphi Component for Delphi og C++Builder, og det samme flagget driver bibliotekets eget regresjonskorpus, så oppførselen du får i en testpakke, er oppførselen komponenten testes med