Teknisk artikel

Reproducerbart PDF-output i Delphi: byte-identiske saves

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 /CreationDate og /ModDate (ISO 32000-1 §14.3.3, Tabel 317) som D:YYYYMMDDHHmmSS-strenge med et tidszone-suffix (§7.9.4), og XMP-pakken gentager samme øjeblik som xmp:CreateDate og xmp:ModifyDate. HotPDF stempler begge fra FCreationDate, som konstruktoren initialiserer til Now, 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 plus GetTickCount for 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, /O og /OE alle 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
De fire entropikilder, der får to HotPDF-gemninger af ét dokument til at adskille sig: FCreationDate stemplet fra Now føder D:-datoerne og XMP-pakken, trailer-/ID'en hasher filnavn, ur og GetTickCount, AES trækker nøglemateriale fra systemets tilfældighedskilde, og dictionaries serialiserer i memory-indsættelsesrækkefølge
Hver kilde er legitim i sig selv, og ISO 32000-1 vil have dem dér, men tilsammen forvandler de filen til en funktion af, hvornår og hvor den blev skrevet, snarere end af, hvad den indeholder

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

Hvad ReproducibleOutput fastlåser i HotPDF: oprettelsesdatoen bliver EncodeDate 2026, 1, 1, trailer-id'en kommer fra ComputeCanonicalDocumentIdentifier over den kanoniske graf med D:-cifre nulstillet, AES-nøgler og salte fyldes med $5A-bytes, og AESGenerateStaticIV fylder hver plads, og DeterministicDictionaryOrder sorterer hver dictionary
Substitutionerne kører i de samme kodeveje, der ellers ville nå ud efter ur eller tilfældighedsgenerator, så der behøves ingen separat oprydningsgennemløb, og begge /ID-elementer modtager samme indholdsafledte værdi

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. _DateTimeToPdfDate appender den lokale UTC-offset, så D:20260101000000+08'00' på én build-agent og D: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. SaveIncrementalUpdate beregner sin ændrings-id ud fra target-stien, GetTickCount og klokkeslættet uden nogen reproducerbar gren, for en inkrementel sektion er per definition en ny ændring. Sammenlign fulde omskrivninger, ikke appendede deltas
  • Passthrough-genvejen. SaveLoadedDocument kopierer 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
Hvor HotPDF-reproducerbare gemninger stopper: _DateTimeToPdfDate appender stadig den lokale UTC-offset, så golden-filer adskiller sig på tværs af tidszoner, SaveIncrementalUpdate har ingen reproducerbar gren, for en delta er en ny ændring, og passthrough-genvejen er deaktiveret, så en loadet fil altid fuldt omskrives
Reproducerbarhed holder på tværs af gennemløb på én maskine eller på tværs af maskiner, der deler en zone, og en reproducerbar gemning bør diffe op mod en tidligere reproducerbar gemning, aldrig op mod originalinputtet

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