Tehnički članak

Deterministički PDF ID u Delphi za reproduktivne build-ove

losLab PDF Library može da proizvede bajt-identičan PDF izlaz za identičan ulaz čim pozovete SetDeterministicDocumentID(1). Podrazumevano, niz /ID u traileru je MD5 heš zidnog časovnika, pa se dva pokretanja istog generatora razlikuju barem u tim bajtovima. Deterministički režim izvodi /ID iz stabilnog semena umesto toga, čime se vraća reproduktivnost build-a

Simptom se obično pojavi u CI pre nego što iko krene da ga traži. Šablon se nije promenio, ulazni zapis se nije promenio, fontovi se nisu promenili, a generisani PDF i dalje ima drugačiji heš na svakom pokretanju pipeline-a. Build keš-evi nikad ne pogode. Content addressable storage gomila svež blob za svaki noćni build. Regresioni diff-ovi na bajt nivou se pale na fajlovima koje niko nije dirao. Pratite diff do stvarnih bajtova i skoro uvek se svede na istu šaku hex cifara koje sede u traileru fajla

Čemu služi niz ID u traileru

/ID u traileru je marker identiteta fajla, ne kontrolna suma sadržaja. ISO 32000-1 §14.4 definiše ga kao niz od dva bajtovna stringa: prvi element je trajni identifikator dodeljen pri kreiranju dokumenta i namenjen je da preživi svaku kasniju izmenu, a drugi element je promenljivi identifikator koji pisac osvežava svaki put kad se fajl izmeni. Zajedno oni omogućavaju sistemu da odluči da li su dva fajla revizije jednog dokumenta ili dva nepovezana dokumenta. §7.5.5 čini ovaj unos praktično obaveznim, jer trailer mora nositi /ID kad god nosi i /Encrypt

Specifikacija ne kaže kako se vrednost izračunava. Preporuka je heš stvari poput trenutnog vremena, putanje fajla, veličine fajla i rečnika informacija o dokumentu, a zidni časovnik je sastojak koji čini rezultat jedinstvenim. To je tačno svojstvo koje želite za identitet, i tačno svojstvo koje uništava reproduktivnost, zbog čega ovo mora biti eksplicitan prekidač, a ne tiha promena ponašanja

Zašto isti build svaki put proizvodi drugačiji PDF?

Zato što se podrazumevani identifikator izvodi iz trenutka generisanja. Istorijski, losLab PDF Library je gradio stringove /ID iz MD5 heša trenutnog timestamp-a, tako da dokument napravljen dva puta jednu sekundu razmaka nosi dva različita trajna identifikatora čak i kad je svaki drugi bajt u fajlu identičan. Cena nizvodno je stvarna: build sistem koji ključa artefakte po hešu nikad ne može ponovo iskoristiti PDF korak, deduplicirajuće skladište objekata drži po jednu kopiju po build-u umesto po jednu kopiju po dokumentu, a recenzent koji gleda binarni diff mora da dokaže da je jedina promena šum pre nego što poveruje ostatku diff-a. Deterministička generacija /ID postoji da ukloni taj šum, u istom duhu kao rad na stabilnosti rasporeda opisan u belešci o object streams i cross reference streams

Prelazak na reproduktivan identifikator

Deterministički režim je opt in, po dokumentu, i podrazumevano isključen, pa postojeći izlaz ostaje nepromenjen dok ga ne zatražite. SetDeterministicDocumentID prihvata 0 ili 1 i vraća 1 kad je vrednost prihvaćena, 0 za sve van opsega; GetDeterministicDocumentID prijavljuje trenutno stanje. SetDocumentIDSeed obezbeđuje eksplicitan string semena koji ima prednost nad svim ostalim, a prosleđivanje praznog semena vraća na izvedeno seme. GetDocumentFileID čita nazad /ID[0] posle snimanja tako da to možete logovati ili proveriti assert-om

var
  Lib: TPDFlib;
  FileID: WideString;
begin
  Lib := TPDFlib.Create;
  try
    Lib.SetDeterministicDocumentID(1);
    Lib.SetDocumentIDSeed('invoice-4471-rev3');
    Lib.SetOrigin(1);
    Lib.DrawText(100, 700, 'Invoice 4471');
    Lib.SaveToFile('invoice.pdf');
    FileID := Lib.GetDocumentFileID;   // identical on every run
  finally
    Lib.Free;
  end;
end;

Osvežavanje se dešava u trenutku snimanja, a ne kad prebacite prekidač, pa uključivanje determinističkog režima kasno tokom izgradnje dokumenta i dalje ima efekta. To takođe znači da promenjeno seme dopire do fajla na sledećem punom snimanju: postavite seme A, snimite, postavite seme B, snimite, i dva fajla nose različite identifikatore, dok vraćanje na seme A vraća originalnu vrednost. Eksplicitno seme je ispravan izbor kad god vaš dokument ima prirodan stabilan ključ kao što je broj fakture, revizija zapisa ili identifikator git commit-a, jer razdvaja identifikator od uzgrednih metapodataka

Odakle dolazi seme kad ga sami ne obezbedite?

Bez eksplicitnog semena, losLab PDF Library izvodi ga iz stanja dokumenta koje treba da bude invarijantno kroz identična regenerisanja: zaglavlje verzije PDF-a, broj stranica, i svaki unos u rečniku informacija o dokumentu. Vrednosti tipa string i name se uzimaju doslovno, drugi tipovi objekata doprinose svojim serijalizovanim oblikom, i sve to se heš-uje u stringove /ID. Bitna posledica je da su CreationDate i ModDate deo rečnika informacija i time deo semena po dizajnu. Dva pokretanja dobijaju isti identifikator samo kad zaista proizvedu iste metapodatke dokumenta

Lib.SetDeterministicDocumentID(1);
// No SetDocumentIDSeed: the seed is derived from document state,
// so the timestamps in the Info dictionary have to be pinned.
Lib.SetInformation(2, 'Quarterly Report');        // Title
Lib.SetInformation(5, 'reporting-service 4.2');   // Creator
Lib.SetInformation(7, 'D:20260101000000Z');       // CreationDate
Lib.SetInformation(8, 'D:20260101000000Z');       // ModDate
Lib.SaveToFile('report.pdf');

Fiksiranje ModDate pod ključem 8 obavlja dvostruku ulogu, i to je deo koji ljude uhvati na prepad. Sam deterministički /ID ne čini fajl bajt-identičnim, jer putanja snimanja žigosuje ModDate trenutnim vremenom osim ako ga pozivalac nije eksplicitno postavio. Postavljanje ključa 8 označava vrednost kao onu koju je dao pozivalac i potiskuje taj žig. Ako želite reproduktivan fajl, a ne samo reproduktivan identifikator, tretirajte timestamp-ove metapodataka kao ulaze build-a: izvodite ih iz izvornog zapisa ili iz fiksne epohe, nikad iz Now

Zašto prepisivanje ID-a razbija šifrovan PDF?

Zato što /ID[0] u šifrovanom dokumentu nije samo metapodatak, već ključni materijal. ISO 32000-1 §7.6.3.3 Algorithm 2 unosi prvi element identifikatora fajla u izračunavanje ključa šifrovanja za standardni security handler na revizijama 2 do 4, zajedno sa popunjenom lozinkom, vrednošću /O i bitovima dozvola. Izvedeni ključ zatim proizvodi validacioni string /U koji čitač proverava pri otvaranju, a ključ fajla se izvodi i keš-uje kad pozovete Encrypt ili kad se učita šifrovan dokument, a oboje se dešava pre snimanja. Prepisivanje identifikatora tokom snimanja bi zato proizvelo strukturno validan fajl čija /U provera propada pri ponovnom otvaranju: ne suptilna korupcija, već dokument koji niko ne može otvoriti, uključujući vas. Zato je deterministički refresh ograničen na dokumente koji ne nose stanje šifrovanja, i zato šifrovan dokument zadržava bilo koji /ID koji je već imao, bez obzira na deterministički režim, a podešavanje jednostavno nema efekta na tu putanju. Vezano rukovanje revizijama i semantika dozvola pokriveni su u pregledu PDF šifrovanja i revizije dozvola. Primetite i da putanja obnove šifrovanja osvežava samo /ID[1], identifikator promene, tačno kako §14.4 predviđa

Zašto inkrementalna snimanja čuvaju originalni identifikator

Druga granica je append režim. Inkrementalno ažuriranje ostavlja svaki raniji bajt fajla netaknutim i piše novu reviziju posle njega, a trajnost /ID[0] kroz §14.4 je upravo ono što govori konzumentu da nova revizija pripada istom dokumentu kao stara. Prepisivanje bi preseklo tu vezu, protivrečilo revizijama koje već sede u fajlu, i mešalo se u semantiku potpisa, jer potpis pokriva opseg bajtova specifične revizije specifičnog dokumenta. losLab PDF Library zato osvežava deterministički identifikator samo pri punim snimanjima i nikad tokom append režima, čime ostaje netaknuta garancija opisana u članku o inkrementalnim PDF ažuriranjima i append u stream

Jedno usko grlo za generisanje identifikatora

Sva generacija /ID u losLab PDF Library sada prolazi kroz jednu internu rutinu, NewFileIDString, što je ono što čini deterministički prekidač pouzdanim, a ne zakrpom na jednoj putanji koda. Kreiranje praznog dokumenta, lenjo kreiranje nedostajućeg niza /ID na zahtev, i putanja obnove otiska šifrovanja svi je pozivaju, tako da postoji tačno jedno mesto gde bi zidni časovnik mogao procuriti nazad. To takođe znači da su buduće varijante, poput identifikatora izvedenog iz sadržaja, promena jedne funkcije, a ne revizija celog serijalizatora

function BuildQuote(const Seed: WideString): AnsiString;
var
  Lib: TPDFlib;
begin
  Lib := TPDFlib.Create;
  try
    Lib.SetDeterministicDocumentID(1);
    Lib.SetDocumentIDSeed(Seed);
    Lib.SetInformation(7, 'D:20260101000000Z');
    Lib.SetInformation(8, 'D:20260101000000Z');
    Lib.SetOrigin(1);
    Lib.DrawText(100, 700, 'Quote 8812');
    Result := Lib.SaveToString;
  finally
    Lib.Free;
  end;
end;

// Regression guard: two independent builds, one byte sequence.
if BuildQuote('quote-8812') = BuildQuote('quote-8812') then
  WriteLn('reproducible')
else
  WriteLn('nondeterminism leaked into the output');

Uključite to poređenje u svoj test suite pre nego što se bilo gde drugde oslonite na reproduktivan izlaz, jer glasno propada onog trenutka kad neka nova funkcija ponovo uvede timestamp. Reproduktivnost je svojstvo koje inače tiho truli, a jedan assert preko dva snimanja u memoriji košta gotovo ništa da se izvrši na svakom build-u

API determinističkog identifikatora prikazan ovde isporučuje se uz losLab PDF Library za Delphi i C++Builder, zajedno sa kompletnom referencom informacija o dokumentu, šifrovanja i inkrementalnog snimanja