HotPDF Delphi Component produce ieșire PDF identică la octet între salvări când proprietatea ReproducibleOutput este True: fixează /CreationDate și /ModDate din Info la o dată fixă, înlocuiește identificatorul de document bazat pe ceasul de perete cu un hash cu sămânță sau derivat din conținut, substituie constante pentru fiecare octet aleator pe care căile de criptare AES l-ar trage altfel și sortează fiecare dicționar pe care îl serializează. Flag-ul există pentru suite de regresie și compararea artefactelor de build, nu pentru documente de producție, iar motivele pentru granița aceea sunt partea interesantă. Scenariul care a condus funcția este un test de tip golden-file. Randați o factură, comiteți PDF-ul și asertați că build-ul de mâine produce aceiași octeți. Nu se întâmplă niciodată. Fișierul se deschide bine în orice vizualizator, textul este identic, arborele de pagini este identic, iar diff-ul se aprinde tot în patru sau cinci locuri. Oricine a încercat să pună un generator PDF sub un test de regresie la nivel de octet s-a lovit de zidul acesta, iar reparația nu este „scoate marcajele temporale”, ci o contabilizare precisă a fiecărui loc unde writer-ul consultă altceva decât documentul însuși
De ce diferă două salvări ale aceluiași PDF?
Două salvări ale aceluiași document diferă pentru că un writer PDF, inclusiv HotPDF, consultă patru surse de entropie care nu au nicio legătură cu conținutul paginilor: ceasul de perete, identificatorul de document, generatorul de numere aleatoare criptografice și ordinea din memorie a intrărilor de dicționar. Fiecare este legitimă în sine. ISO 32000-1 le vrea acolo. Pur și simplu fac fișierul o funcție de când și unde a fost scris, nu de ce conține
- Ceasul. Dicționarul Info cară
/CreationDateși/ModDate(ISO 32000-1 §14.3.3, tabelul 317) ca șiruriD:YYYYMMDDHHmmSScu un sufix de fus orar (§7.9.4), iar pachetul XMP repetă același moment caxmp:CreateDateșixmp:ModifyDate. HotPDF le ștampilează pe amândouă dinFCreationDate, pe care constructorul îl inițializează laNow, așa că cele două salvări diferă prin secunda în care au fost scrise - Identificatorul. Tabloul
/IDdin trailer (ISO 32000-1 §14.4) ține un identificator permanent și unul de modificare. Rețeta implicită a HotPDF hash-uiește numele fișierului împreună cu ora curentă până la milisecundă pentru primul element și hash-uiește acel rezultat plusGetTickCountpentru al doilea. Două identificatoare, două valori proaspete la fiecare rulare - Octeții aleatori. Securitatea standard depinde de identificator și de aleatorism autentic. Pentru AES-256, cheia de criptare a fișierului, sărurile de validare și de cheie și fiecare vector de inițializare CBC sunt trase din sursa de aleatorism a sistemului (ISO 32000-2 §7.6.4.4.7 cere săruri aleatoare). Pentru că
/U,/UE,/Oși/OEsunt toate calculate din octeții aceia, un document criptat se schimbă în întregime chiar și când textul în clar nu se schimbă. Algoritmii mai vechi pliază primul element/IDîn cheie (ISO 32000-1 §7.6.3.3, §7.6.3.4), așa că un identificator proaspăt este singur de ajuns ca să re-cheie fișierul - Ordinea. Un dicționar PDF este o mapare neordonată, iar un writer care își parcurge lista din memorie emite cheile în ordinea inserării. Orice cale de cod care construiește un dicționar de resurse într-o altă secvență, sau un document încărcat care a fost parsat dintr-un alt aspect, produce un fișier legal, dar textual diferit
Ce fixează ReproducibleOutput?
Setarea ReproducibleOutput := True înainte de BeginDoc sau înainte de SaveLoadedDocument înlocuiește fiecare dintre cele patru surse cu o valoare fixă și o face în aceleași căi de cod care altfel ar căuta ceasul sau generatorul de aleatorism, așa că nu e nevoie de vreo trecere separată de curățare. Observați ce lipsește din lista de mai sus: conținutul. Fonturile, stream-urile de pagină, datele de imagine și tabelul de referințe încrucișate sunt deja deterministe pentru aceeași intrare; zgomotul trăiește în întregime în metadate și în stratul de securitate, și de aceea o singură proprietate țintită îl poate elimina. Proprietatea are implicit False și nimic din librărie nu o activează pentru dumneavoastră
var
Pdf: THotPDF;
begin
Pdf := THotPDF.Create(nil);
try
Pdf.AutoLaunch := False;
Pdf.FileName := 'golden-invoice.pdf';
Pdf.ReproducibleOutput := True; // înainte de BeginDoc
Pdf.BeginDoc;
Pdf.CurrentPage.SetFont('Arial', [], 12);
Pdf.CurrentPage.TextOut(40, 40, 0, 'Invoice 2026-0042');
Pdf.EndDoc;
finally
Pdf.Free;
end;
end;
În interiorul lui BeginDoc, ramura reproductibilă atribuie FCreationDate := EncodeDate(2026, 1, 1) și însămânțează identificatorul de document cu MD5CalcString('HotPDF-reproducible-seed') în loc de digestul nume-de-fișier-plus-ceas. Acea singură atribuire acoperă ambele date din Info și ambele date din XMP, pentru că toate patru sunt redate din același câmp. Când fișierul este în final scris, BuildDocumentIdentifiers cere lui ComputeCanonicalDocumentIdentifier identificatorul de trailer: exportă întregul graf de obiecte în ordine canonică, pune pe zero cifrele oricărui șir de dată D: pe care îl găsește, ca marcajele temporale să nu se scurgă înapoi prin hash, și ia MD5-ul rezultatului. Ambele elemente ale lui /ID primesc valoarea aceea. Același identificator derivat din conținut este folosit când un document încărcat este criptat fără să treacă vreodată prin BeginDoc, cazul lui ActivateProtection pe un fișier pe care l-ați deschis cu LoadFromFile
Octeții aleatori sunt substituția cea mai puțin evidentă. Rutina de cheie AES-256 își împachetează sursa de aleatorism într-un helper local care, sub flag, apelează FillChar(P^, Count, $5A) pentru cheia de criptare de 32 de octeți a fișierului și pentru fiecare sare de 8 octeți, iar criptoarele de șiruri și stream-uri AES-128 și AES-256 trec de la AESGenerateRandomIV la AESGenerateStaticIV, care umple vectorul de inițializare cu 14 * (1 + I) pentru slotul I. Cu cheia, sărurile și vectorii toți fixați, /U, /UE, /O, /OE și fiecare stream criptat ies identice la a doua rulare. În final, SaveToStream activează DeterministicDictionaryOrder ori de câte ori flag-ul de reproductibilitate este setat, iar serializerul sortează atunci fiecare dicționar prin inserție după octeții bruți ai numelor de chei, prefixul mai scurt primul, cu indexul original ca departajare. Este aceeași ordonare pe care o folosește writer-ul de diagnostic, descrisă în articolul despre editarea manuală a unui PDF și repararea lui după aceea; flag-ul de reproductibilitate împrumută doar ordonarea, nu restul aspectului de text simplu al writer-ului acela
De ce scurgea data fixă tot ceasul de perete?
Reparația din v2.752.2 există pentru că data de creare fixă era decisă inițial în constructor, iar constructorul nu poate cunoaște o proprietate pe care apelantul nu a setat-o încă. Secvența normală de apeluri este Create, apoi ReproducibleOutput := True, apoi BeginDoc. La momentul construirii, FReproducibleOutput este încă False, așa că FCreationDate primea Now și îl păstra. Identificatorul și octeții aleatori erau fixați corect, așa că cele două fișiere cădeau de acord aproape peste tot și nu cădeau de acord exact în două șiruri de dată și două câmpuri XMP. Mutarea atribuirii în ramura reproductibilă a lui BeginDoc, lângă identificatorul însămânțat, a pus decizia în punctul unde proprietatea are valoarea ei finală
Testul de regresie care a ratat asta valorează mai mult decât reparația. Două salvări care rulează amândouă în aceeași secundă de ceas scriu același șir D: din întâmplare, iar comparația de octeți trece pentru un bug care cade pe orice mașină mai lentă. Testul corectat doarme 1100 ms între cele două salvări, ca marcajul temporal PDF să traverseze garantat o graniță de secundă, rulează cazul pentru ieșire simplă, AES-128 și AES-256 cu parole reale pe cele două variante criptate, și compară cele două buffere cu CompareMem, raportând primul offset care diferă la eșec, ca diff-ul să arate spre un obiect anume în loc de un fișier întreg. O comparație de octeți dovedește determinism și nimic altceva, așa că păstrați o aserțiune separată care reîncarcă ieșirea criptată cu parola de utilizator și citește un număr de pagini; o schimbare care face fișierul stabil și ilizibil în același timp nu trebuie să treacă pe baza unui diff verde
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;
// în corpul testului
A := SaveOnce(PathA);
TThread.Sleep(1100); // forțează altă secundă în marcajul temporal PDF
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');
Este un PDF criptat reproductibil totuși sigur?
Nu. Un document criptat sub ReproducibleOutput nu este protejat în niciun sens care contează, iar flag-ul trebuie oprit pentru orice iese din directorul de teste. Cheia de criptare AES-256 a fișierului este treizeci și doi de octeți de $5A, sărurile sunt opt octeți de $5A, iar vectorii de inițializare urmează un tipar aritmetic publicat. Parola încă străjuiește învelișurile /UE și /OE, dar cheia învelită este o constantă, așa că oricine cunoaște constanta poate decripta fiecare stream de conținut fără nicio parolă. Faptul că sărurile sunt fixe elimină și unicitatea per document pe care se bazează ISO 32000-2 §7.6.4.4.7 ca parole identice să nu dea șiruri /U identice între fișiere. Citiți articolul despre configurarea AES-256 pentru ce promit proprietățile de criptare când sursa de aleatorism este intactă; sub flag-ul de reproductibilitate promisiunile acelea sunt suspendate
Compromisul cu identificatorul este mai subtil. ISO 32000-1 §14.4 vrea ca al doilea element /ID să se schimbe la fiecare modificare, ca uneltele să poată distinge un fișier actualizat de strămoșul lui, iar o salvare reproductibilă scrie aceeași valoare în ambele sloturi. Pentru că valoarea aceea este un hash al grafului canonic de obiecte, două documente cu conținut diferit primesc tot identificatori diferiți, ceea ce este mai bine decât o constantă. Dar sămânța pe care BeginDoc o folosește pentru derivarea cheii este același șir pentru fiecare document de pe fiecare mașină, iar un cititor care se bazează pe /ID ca să distingă fișiere, de exemplu un cache de adnotări sau un fișier lateral de date de formular, va confunda fiecare fișier reproductibil care se nimerește să dea același hash
Ce nu acoperă flag-ul?
ReproducibleOutput elimină entropia pe care o introduce writer-ul singur; nu poate elimina entropia care intră prin mediu sau prin căi de cod pe care nu le controlează, iar trei dintre ele sunt ușor de încurcat
- Fusul orar.
_DateTimeToPdfDateadaugă offsetul UTC local, așa căD:20260101000000+08'00'pe un agent de build șiD:20260101000000-05'00'pe altul sunt octeți diferiți pentru aceeași dată fixă. Reproductibilitatea ține între rulări pe o singură mașină sau între mașini care împart un fus orar; fixați fusul orar al agentului dacă fișierele voastre de referință călătoresc - Actualizările incrementale.
SaveIncrementalUpdateîși calculează identificatorul de modificare din calea țintă,GetTickCountși ora curentă, fără nicio ramură reproductibilă, pentru că o secțiune incrementală este prin definiție o modificare nouă. Comparați rescrieri complete, nu delte adăugate - Scurtătura de passthrough.
SaveLoadedDocumentcopiază în mod normal un fișier sursă nemodificat și necriptat octet cu octet, în loc să îl reserializeze. Flag-ul de reproductibilitate dezactivează scurtătura aceea și forțează o rescriere completă, ca regulile de ordonare și de identificator să se aplice, ceea ce înseamnă că salvarea reproductibilă a unui fișier încărcat este mai lentă decât cea implicită și nu este niciodată o copie a intrării. Comparați-o cu o salvare reproductibilă anterioară, niciodată cu originalul
Încă o lecție din aceeași versiune, despre ce dovedește și ce nu dovedește o verificare care trece. Un fixture de test PDF/X-6 apela CharProcs.DeleteValue('A'), care elibera un stream de glif ținut direct, apoi re-insera același pointer și, separat, preda un singur obiect ExtGState direct atât unui dicționar de resurse, cât și unui pattern. Validatorul de conformitate trecea intermitent pe acea utilizare după eliberare și proprietate dublă pentru că citea orice se nimerea să țină memoria eliberată. Când o verificare structurală pâlpâie, uitați-vă la proprietatea intrării de test înainte de a vă uita la validator. Ieșirea reproductibilă face disciplina aceea mai ieftină: odată ce două salvări sunt identice la octet, singura sursă rămasă de pâlpâială este graful de obiecte însuși, iar un diff structural de la catalog în jos îl va găsi
Proprietățile ReproducibleOutput, DeterministicDictionaryOrder și cele de criptare descrise aici sunt livrate în HotPDF Delphi Component standard pentru Delphi și C++Builder, iar același flag conduce corpusul de regresie al librăriei, așa că comportamentul pe care îl primiți într-o suită de teste este comportamentul cu care componenta este testată