Tehnički članak

Reproducibilno PDF izdanje u Delphi-ju: identični bajtovi

HotPDF Delphi Component proizvodi bajt identičan PDF izlaz kroz čuvanja kada je svojstvo ReproducibleOutput True: fiksira Info /CreationDate i /ModDate na fiksni datum, zamenjuje identifikator dokumenta zasnovan na sistemskom satu seed-ovanim hash-om ili hash-om izvedenim iz sadržaja, menja svaki slučajni bajt koji bi AES putanje šifrovanja inače izvukle konstantama, i sortira svaki rečnik koji serijalizuje. Flag postoji zbog regression suite-ova i poređenja build artefakata, ne zbog produkcijskih dokumenata, i razlozi za tu granicu su zanimljiv deo. Scenario koji pokreće ovu funkciju je golden-file test. Iscrtate fakturu, commitujete PDF, i tvrdite da će sutrašnji build proizvesti iste bajtove. Nikada ne proizvede. Fajl se lepo otvara u svakom pregledaču, tekst je identičan, stablo stranica je identično, a diff se ipak upali na četiri ili pet mesta. Svako ko je pokušao da stavi PDF generator pod regresioni test na nivou bajtova udario je u taj zid, a popravka nije „ukloni vremenske žigove“ nego precizno popisivanje svakog mesta gde writer konsultuje nešto drugo osim samog dokumenta

Zašto se dva čuvanja istog PDF-a razlikuju?

Dva čuvanja istog dokumenta se razlikuju zato što PDF writer, uključujući HotPDF, konsultuje četiri izvora entropije koji nemaju veze sa sadržajem stranice: sistemski sat, identifikator dokumenta, kriptografski generator slučajnih brojeva, i redosled unosa u rečniku u memoriji. Svaki je sam po sebi legitiman. ISO 32000-1 ih traži. Oni prosto čine da fajl bude funkcija toga kada i gde je upisan, a ne onoga što sadrži

  • Sat. Info rečnik nosi /CreationDate i /ModDate (ISO 32000-1 §14.3.3, tabela 317) kao D:YYYYMMDDHHmmSS stringove sa sufiksom vremenske zone (§7.9.4), a XMP paket ponavlja isti trenutak kao xmp:CreateDate i xmp:ModifyDate. HotPDF oba upisuje iz FCreationDate, koji konstruktor inicijalizuje na Now, pa se dva čuvanja razlikuju u sekundi u kojoj su upisana
  • Identifikator. /ID niz u trailer-u (ISO 32000-1 §14.4) drži trajni identifikator i identifikator izmene. HotPDF-ov podrazumevani recept hash-uje ime fajla zajedno sa trenutnim vremenom do milisekunde za prvi element, a za drugi hash-uje to plus GetTickCount. Dva identifikatora, dve sveže vrednosti pri svakom pokretanju
  • Slučajni bajtovi. Standardna sigurnost zavisi od identifikatora i od prave slučajnosti. Za AES-256 ključ šifrovanja fajla, salt-ovi za validaciju i ključ, i svaki CBC inicijalizacioni vektor izvlače se iz sistemskog izvora slučajnosti (ISO 32000-2 §7.6.4.4.7 zahteva slučajne salt-ove). Pošto se /U, /UE, /O i /OE svi računaju iz tih bajtova, šifrovan dokument se menja u celosti čak i kada se čist tekst ne menja. Stariji algoritmi uklapaju prvi element /ID u ključ (ISO 32000-1 §7.6.3.3, §7.6.3.4), pa je svež identifikator sam dovoljan da prekuje fajl
  • Redosled. PDF rečnik je neuređeno mapiranje, a writer koji prolazi kroz svoju listu u memoriji emituje ključeve redosledom umetanja. Svaka putanja koda koja gradi rečnik resursa u drugačijem nizu, ili učitan dokument koji je parsiran iz drugačijeg rasporeda, proizvodi legalan ali tekstualno drugačiji fajl
Četiri izvora entropije zbog kojih se dva HotPDF čuvanja istog dokumenta razlikuju: FCreationDate upisan iz Now hrani D: datume i XMP paket, /ID u trailer-u hash-uje ime fajla, sat i GetTickCount, AES izvlači ključni materijal iz sistemskog izvora slučajnosti, a rečnici se serijalizuju redosledom umetanja u memoriji
Svaki izvor je sam po sebi legitiman i ISO 32000-1 ih traži, ali zajedno pretvaraju fajl u funkciju toga kada i gde je upisan, a ne onoga što sadrži

Šta ReproducibleOutput fiksira?

Postavljanje ReproducibleOutput := True pre BeginDoc ili pre SaveLoadedDocument zamenjuje svaki od četiri izvora fiksnom vrednošću, i to radi u istim putanjama koda koje bi inače posegle za satom ili generatorom slučajnosti, pa nikakav poseban prolaz čišćenja nije potreban. Primetite šta nedostaje na gornjoj listi: sadržaj. Fontovi, stream-ovi stranica, podaci slika i cross-reference tabela su već deterministički za isti ulaz; šum živi isključivo u metapodacima i sigurnosnom sloju, i zato ga jedno ciljano svojstvo može ukloniti. Svojstvo je podrazumevano False i ništa ga u biblioteci ne uključuje umesto vas

var
  Pdf: THotPDF;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.AutoLaunch := False;
    Pdf.FileName := 'golden-invoice.pdf';
    Pdf.ReproducibleOutput := True;     // pre BeginDoc
    Pdf.BeginDoc;
    Pdf.CurrentPage.SetFont('Arial', [], 12);
    Pdf.CurrentPage.TextOut(40, 40, 0, 'Invoice 2026-0042');
    Pdf.EndDoc;
  finally
    Pdf.Free;
  end;
end;

Unutar BeginDoc reproducibilna grana dodeljuje FCreationDate := EncodeDate(2026, 1, 1) i seed-uje identifikator dokumenta sa MD5CalcString('HotPDF-reproducible-seed') umesto digest-a imena fajla i sata. Ta jedna dodela pokriva oba Info datuma i oba XMP datuma, jer se sva četiri ispisuju iz istog polja. Kada se fajl konačno upiše, BuildDocumentIdentifiers traži od ComputeCanonicalDocumentIdentifier identifikator za trailer: izvozi ceo graf objekata u kanonskom redosledu, nulira cifre svakog D: datumskog string-a koji nađe da vremenski žigovi ne bi mogli da se vrate kroz hash, i uzima MD5 rezultata. Oba elementa /ID dobijaju tu vrednost. Isti identifikator izveden iz sadržaja koristi se i kada se učitan dokument šifruje bez prolaska kroz BeginDoc, što je slučaj za ActivateProtection nad fajlom koji ste otvorili sa LoadFromFile

Slučajni bajtovi su najmanje očigledna zamena. AES-256 rutina za ključ obavija svoj izvor slučajnosti lokalnim pomoćnikom koji, pod flag-om, poziva FillChar(P^, Count, $5A) za 32-bajtni ključ šifrovanja fajla i za svaki salt od 8 bajtova, a AES-128 i AES-256 šifratori stringova i stream-ova prelaze sa AESGenerateRandomIV na AESGenerateStaticIV, koji puni inicijalizacioni vektor sa 14 * (1 + I) za slot I. Sa ključem, salt-ovima i vektorima svim fiksiranim, /U, /UE, /O, /OE i svaki šifrovani stream izlaze identično pri drugom pokretanju. Na kraju, SaveToStream uključuje DeterministicDictionaryOrder kad god je reproducibilni flag postavljen, a serijalizator tada insertion-sort-uje svaki rečnik po sirovim bajtovima imena svojih ključeva, kraći prefiks prvi, sa originalnim indeksom kao razrešavanjem jednakosti. To je isti redosled koji koristi dijagnostički writer, opisan u članku o ručnom uređivanju PDF-a i njegovoj popravci; reproducibilni flag pozajmljuje samo redosled, ne i ostatak plain-text rasporeda tog writer-a

Šta ReproducibleOutput fiksira u HotPDF-u: datum kreiranja postaje EncodeDate 2026, 1, 1, identifikator u trailer-u dolazi iz ComputeCanonicalDocumentIdentifier nad kanonskim grafom sa nuliranim D: ciframa, AES ključevi i salt-ovi pune se $5A bajtovima a AESGenerateStaticIV puni svaki slot, i DeterministicDictionaryOrder sortira svaki rečnik
Zamene se izvršavaju u istim putanjama koda koje bi inače posegle za satom ili generatorom slučajnosti, pa nikakav poseban prolaz čišćenja nije potreban i oba elementa /ID dobijaju istu vrednost izvedenu iz sadržaja

Zašto je fiksni datum ipak odao sistemski sat?

Popravka u v2.752.2 postoji zato što je fiksni datum kreiranja prvobitno odlučivan u konstruktoru, a konstruktor ne može da zna svojstvo koje pozivalac još nije postavio. Normalan redosled poziva je Create, pa ReproducibleOutput := True, pa BeginDoc. U trenutku konstrukcije FReproducibleOutput je još False, pa je FCreationDate dobio Now i zadržao ga. Identifikator i slučajni bajtovi bili su ispravno fiksirani, pa su se dva fajla slagala skoro svuda a razlikovala se tačno u dva datumski stringa i dva XMP polja. Premeštanje dodele u reproducibilnu granu BeginDoc-a, pored seed-ovanog identifikatora, smestilo je odluku na mesto gde svojstvo ima svoju konačnu vrednost

Regresioni test koji je ovo promašio vredi više od same popravke. Dva čuvanja koja se oba izvrše unutar iste sekunde sistemskog sata upisuju isti D: string slučajno, i poređenje bajtova prolazi za bug koji pada na bilo kojoj sporijoj mašini. Ispravljeni test spava 1100 ms između dva čuvanja da PDF vremenski žig zagarantovano pređe granicu sekunde, pokreće slučaj za običan, AES-128 i AES-256 izlaz sa pravim lozinkama na dve šifrovane varijante, i poredi dva bafera sa CompareMem, prijavljujući prvi offset razlike pri neuspehu da diff pokaže na konkretan objekat umesto na ceo fajl. Poređenje bajtova dokazuje determinizam i ništa drugo, pa zadržite odvojenu tvrdnju koja ponovo učitava šifrovani izlaz sa korisničkom lozinkom i čita broj stranica; promena koja istovremeno čini fajl stabilnim i nečitljivim ne sme da se provuče na krilima zelenog diff-a

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;

// u telu testa
A := SaveOnce(PathA);
TThread.Sleep(1100);          // nateraj drugu sekundu PDF vremenskog žiga
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');

Da li je reproducibilan šifrovan PDF i dalje bezbedan?

Ne. Dokument šifrovan pod ReproducibleOutput nije zaštićen ni u jednom smislenom smislu, i flag mora da bude isključen za sve što napušta testni direktorijum. AES-256 ključ šifrovanja fajla je trideset dva bajta $5A, salt-ovi su osam bajtova $5A, a inicijalizacioni vektori prate objavljen aritmetički obrazac. Lozinka i dalje štiti omotače /UE i /OE, ali je umotani ključ konstanta, pa svako ko zna konstantu može da dešifruje svaki content stream bez ikakve lozinke. Fiksirani salt-ovi uklanjaju i jedinstvenost po dokumentu na koju se ISO 32000-2 §7.6.4.4.7 oslanja da iste lozinke ne daju iste /U stringove kroz fajlove. Pročitajte članak o podešavanju AES-256 da vidite šta svojstva šifrovanja obećavaju kada je izvor slučajnosti netaknut; pod reproducibilnim flag-om ta obećanja su suspendovana

Kompromis oko identifikatora je suptilniji. ISO 32000-1 §14.4 namerava da se drugi element /ID menja pri svakoj izmeni da bi alati mogli da razlikuju ažuriran fajl od njegovog pretka, a reproducibilno čuvanje upisuje istu vrednost u oba slota. Pošto je ta vrednost hash kanonskog grafa objekata, dva dokumenta sa različitim sadržajem i dalje dobijaju različite identifikatore, što je bolje od konstante. Ali seed koji BeginDoc koristi za izvođenje ključa je isti string za svaki dokument na svakoj mašini, i čitač koji se oslanja na /ID da razlikuje fajlove, recimo cache anotacija ili sidecar sa podacima formulara, pomešaće svaki reproducibilni fajl koji se slučajno isto hash-uje

Šta flag ne pokriva?

ReproducibleOutput uklanja entropiju koju writer unosi sam; ne može da ukloni entropiju koja ulazi kroz okruženje ili kroz putanje koda koje ne kontroliše, a na tri od njih je lako sapleti se

  • Sufiks vremenske zone. _DateTimeToPdfDate dodaje lokalni UTC offset, pa su D:20260101000000+08'00' na jednom build agentu i D:20260101000000-05'00' na drugom različiti bajtovi za isti fiksni datum. Reproducibilnost važi kroz pokretanja na jednoj mašini, ili kroz mašine koje dele vremensku zonu; fiksirajte zonu agenta ako vaši golden fajlovi putuju
  • Inkrementalna ažuriranja. SaveIncrementalUpdate računa svoj identifikator izmene iz ciljne putanje, GetTickCount i trenutnog vremena bez reproducibilne grane, jer je inkrementalna sekcija po definiciji nova izmena. Poredite puna prepisivanja, ne dodate delte
  • Passthrough prečica. SaveLoadedDocument normalno kopira neizmenjen, nešifrovan izvorni fajl bajt po bajt umesto da ga ponovo serijalizuje. Reproducibilni flag isključuje tu prečicu i tera potpuno prepisivanje da bi pravila o redosledu i identifikatoru važila, što znači da je reproducibilno čuvanje učitanog fajla sporije od podrazumevanog i nikada nije kopija ulaza. Diff-ujte ga prema prethodnom reproducibilnom čuvanju, nikada prema originalu
Gde se HotPDF reproducibilna čuvanja zaustavljaju: _DateTimeToPdfDate i dalje dodaje lokalni UTC offset pa se golden fajlovi razlikuju kroz vremenske zone, SaveIncrementalUpdate nema reproducibilnu granu jer je delta nova izmena, a passthrough prečica je isključena pa se učitan fajl uvek potpuno prepisuje
Reproducibilnost važi kroz pokretanja na jednoj mašini ili kroz mašine koje dele zonu, a reproducibilno čuvanje vredi diff-ovati prema prethodnom reproducibilnom čuvanju, nikada prema originalnom ulazu

Još jedna pouka iz istog izdanja, o tome šta prolazna provera dokazuje a šta ne. PDF/X-6 test fikstur zvao je CharProcs.DeleteValue('A'), što je oslobodilo direktno držani stream glifa, zatim ponovo umetnulo isti pointer, i odvojeno predalo jedan direktni ExtGState objekat i rečniku resursa i pattern-u. Validator usklađenosti prolazio je naizmenično na tom use-after-free i dvostrukom vlasništvu jer je čitao šta god je oslobođena memorija slučajno držala. Kada strukturna provera treperi, pogledajte vlasništvo nad testnim ulazom pre nego što pogledate validator. Reproducibilni izlaz tu disciplinu čini jeftinijom: kada su dva čuvanja bajt identična, jedini preostali izvor treperenja je sam graf objekata, a strukturni diff od kataloga naniže će ga naći

Svojstva ReproducibleOutput, DeterministicDictionaryOrder i šifrovanja opisana ovde isporučuju se u standardnom HotPDF Delphi Component za Delphi i C++Builder, i isti flag pokreće sopstveni regresioni korpus biblioteke, pa je ponašanje koje dobijate u test suite-u ponašanje sa kojim se komponenta testira