Tehnički članak

Deterministički PDF ID u Delphiju za reproducibilne izgradnje

losLab PDF Library može proizvesti bajt identičan PDF izlaz za identičan ulaz nakon poziva SetDeterministicDocumentID(1). Prema zadanim postavkama niz /ID u trailer-u je MD5 sažetak trenutka izvršavanja, pa se dva pokretanja istog generatora razlikuju barem u tim bajtovima. Deterministički način umjesto toga izvodi /ID iz stabilnog seeda, čime se vraća reproducibilnost izgradnje

Simptom se obično pojavi u CI-ju prije nego što ga itko počne tražiti. Predložak se nije promijenio, ulazni zapis se nije promijenio, fontovi se nisu promijenili, a generirani PDF svejedno svaki put ima drugačiji hash na svakom pokretanju pipelinea. Build cacheovi nikad ne pogode. Content-addressable pohrana svake noći skuplja svjež blob. Diffovi regresije na razini bajtova pale se na datotekama koje nitko nije dirao. Prati se diff do stvarnih bajtova, i gotovo uvijek se radi o istoj šačici hex znamenki koja sjedi u trailer-u datoteke

Čemu služi niz ID-a u trailer-u

Trailer /ID je oznaka identiteta datoteke, a ne kontrolni zbroj sadržaja. ISO 32000-1 §14.4 definira ga kao niz od dva bajtovna niza: prvi element je trajni identifikator dodijeljen pri stvaranju dokumenta i namijenjen je preživjeti svaku kasniju izmjenu, a drugi element je promjenjivi identifikator koji pisac osvježava svaki put kad se datoteka izmijeni. Zajedno omogućuju sustavu da odluči jesu li dvije datoteke revizije jednog dokumenta ili dva nepovezana dokumenta. §7.5.5 čini taj unos praktički obveznim, jer trailer mora nositi /ID kad god nosi i /Encrypt

Ništa u specifikaciji ne govori kako izračunati vrijednost. Preporuka je sažetak stvari poput trenutnog vremena, putanje datoteke, veličine datoteke i rječnika informacija o dokumentu, a upravo je trenutak izvršavanja sastojak koji rezultat čini jedinstvenim. To je točno svojstvo koje želite za identitet i točno svojstvo koje uništava reproducibilnost, zbog čega ovo mora biti eksplicitan prekidač, a ne tiha promjena ponašanja

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

Zato što se zadani identifikator izvodi iz trenutka generiranja. Povijesno je losLab PDF Library gradio nizove /ID iz MD5 sažetka trenutnog vremenskog žiga, pa dokument stvoren dvaput jednu sekundu razmaka nosi dva različita trajna identifikatora čak i kad je svaki drugi bajt u datoteci identičan. Posljedica niže u lancu je stvarna: build sustav koji ključa artefakte po hashu nikad ne može ponovno iskoristiti PDF korak, deduplicirajuća pohrana objekata drži po jednu kopiju po buildu umjesto po jednu kopiju po dokumentu, a recenzent koji gleda binarni diff mora dokazati da je jedina promjena šum prije nego povjeruje ostatku diffa. Deterministička generacija /ID-a postoji upravo da ukloni taj šum, u istom duhu kao rad na stabilnosti izgleda opisan u bilješkama o object streamovima i cross reference streamovima

Prelazak na reproducibilan identifikator

Deterministički način je opt-in, po dokumentu, i zadano isključen, tako da postojeći izlaz ostaje nepromijenjen dok ga izričito ne zatražite. SetDeterministicDocumentID prihvaća 0 ili 1 i vraća 1 kad je vrijednost prihvaćena, 0 za sve izvan raspona; GetDeterministicDocumentID javlja trenutno stanje. SetDocumentIDSeed dostavlja eksplicitan seed niz koji ima prednost nad svime ostalim, a prosljeđivanje praznog seeda vraća se na izvedeni seed. GetDocumentFileID čita /ID[0] nakon spremanja, tako da ga možete zabilježiti ili nad njim postaviti tvrdnju

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;

Osvježavanje se događa u trenutku spremanja, a ne kad prebacite zastavicu, tako da uključivanje determinističkog načina kasno u izgradnji dokumenta i dalje stupa na snagu. To također znači da promijenjeni seed stiže u datoteku pri sljedećem punom spremanju: postavite seed A, spremite, postavite seed B, spremite, i dvije datoteke nose različite identifikatore, dok vraćanje seeda A vraća izvornu vrijednost. Eksplicitan seed pravi je izbor kad god vaš dokument ima prirodan stabilan ključ poput broja računa, revizije zapisa ili identifikatora git commita, jer razdvaja identifikator od usputnih metapodataka

Odakle dolazi seed kad ga sami ne dostavite?

Bez eksplicitnog seeda, losLab PDF Library izvodi ga iz stanja dokumenta koje bi trebalo biti nepromjenjivo kod identičnih regeneracija: zaglavlja verzije PDF-a, broja stranica i svakog unosa u rječniku informacija o dokumentu. Vrijednosti tipa string i name uzimaju se doslovno, ostali tipovi objekata doprinose svojim serijaliziranim oblikom, a cijela cjelina se hashira u nizove /ID. Važna je posljedica da su CreationDate i ModDate dio rječnika informacija, a time i namjerno dio seeda. Dva pokretanja zarađuju isti identifikator samo kad doista 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 ključem 8 obavlja dvostruku ulogu, i to je dio koji zna iznenaditi ljude. Sam deterministički /ID ne čini datoteku bajt identičnom, jer put spremanja pečati ModDate trenutnim vremenom osim ako pozivatelj nije to izričito postavio. Postavljanje ključa 8 označava vrijednost kao onu koju je dostavio pozivatelj i potiskuje taj pečat. Ako želite reproducibilnu datoteku, a ne samo reproducibilan identifikator, tretirajte vremenske žigove metapodataka kao ulaze u build: izvedite ih iz izvornog zapisa ili fiksne epohe, nikad iz Now

Zašto prepisivanje ID-a ruši šifrirani PDF?

Zato što /ID[0] u šifriranom dokumentu nije samo metapodatak, nego materijal ključa. ISO 32000-1 §7.6.3.3 Algoritam 2 uvodi prvi element identifikatora datoteke u izračun ključa šifriranja za standardni sigurnosni rukovatelj kod revizija 2 do 4, uz popunjenu lozinku, vrijednost /O i bitove dopuštenja. Izvedeni ključ zatim proizvodi validacijski niz /U koji čitač provjerava pri otvaranju, a ključ datoteke izvodi se i predmemorira kad pozovete Encrypt ili kad se učita šifrirani dokument, a oboje se događa prije spremanja. Prepisivanje identifikatora tijekom spremanja stoga bi proizvelo strukturno valjanu datoteku čija provjera /U ne uspijeva pri ponovnom otvaranju: ne suptilno oštećenje, nego dokument koji nitko ne može otvoriti, uključujući vas. Zbog toga je deterministička obnova ograničena na dokumente koji ne nose stanje šifriranja, i zbog toga šifrirani dokument zadržava koji god /ID je već imao, deterministički način ili ne, a postavka na taj put jednostavno nema učinka. Povezano rukovanje revizijama i semantika dopuštenja obrađeni su u vodiču o reviziji PDF šifriranja i dopuštenja. Također primijetite da put obnove šifriranja osvježava samo /ID[1], identifikator promjene, točno kako to §14.4 namjerava

Zašto prirasna spremanja zadržavaju izvorni identifikator

Druga granica je način dodavanja. Prirasno ažuriranje ostavlja svaki raniji bajt datoteke netaknutim i iza njega piše novu reviziju, a trajnost /ID[0] kroz §14.4 upravo je ono što potrošaču govori da nova revizija pripada istom dokumentu kao i stara. Prepisivanje bi presjeklo tu vezu, proturječilo revizijama koje već sjede u datoteci i ometalo semantiku potpisa, jer potpis pokriva raspon bajtova određene revizije određenog dokumenta. losLab PDF Library stoga osvježava deterministički identifikator samo pri punim spremanjima, a nikad tijekom načina dodavanja, čime se čuva jamstvo opisano u članku o prirasnim PDF ažuriranjima i dodavanju u stream

Jedna prolazna točka za generiranje identifikatora

Sva generacija /ID-a u losLab PDF Library sad prolazi kroz jednu internu rutinu, NewFileIDString, i upravo to čini deterministički prekidač pouzdanim, a ne zakrpom na jednoj putanji koda. Stvaranje praznog dokumenta, lijeno stvaranje nedostajućeg niza /ID na zahtjev i put obnove otiska šifriranja svi je pozivaju, tako da postoji točno jedno mjesto gdje bi se trenutak izvršavanja mogao vratiti natrag. To također znači da su buduće varijante, poput identifikatora izvedenog iz sadržaja, promjena jedne funkcije, a ne revizija cijelog 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');

Uvežite tu usporedbu u svoj testni paket prije nego se oslonite na reproducibilan izlaz bilo gdje drugdje, jer glasno propada čim neka nova značajka ponovno uvede vremenski žig. Reproducibilnost je svojstvo koje inače tiho propada, a jedna tvrdnja preko dva spremanja u memoriji košta gotovo ništa pri svakom buildu

Ovdje prikazan API determinističkog identifikatora isporučuje se s losLab PDF Library za Delphi i C++Builder, uz potpunu referencu informacija o dokumentu, šifriranja i prirasnog spremanja