Technisch artikel

Deterministische PDF-ID in Delphi voor reproduceerbare builds

losLab PDF Library kan byte-identieke PDF-output produceren voor identieke invoer zodra je SetDeterministicDocumentID(1) aanroept. Standaard is de trailer-/ID-array een MD5-digest van de systeemklok, waardoor twee runs van dezelfde generator op zijn minst in die bytes verschillen. Deterministische modus leidt /ID in plaats daarvan af van een stabiele seed, wat reproduceerbare builds herstelt

Het symptoom duikt meestal in CI op voordat iemand er specifiek naar op zoek gaat. Het sjabloon is niet veranderd, het invoerrecord is niet veranderd, de fonts zijn niet veranderd, en toch hasht de gegenereerde PDF bij elke pipelinerun anders. Buildcaches missen altijd raak. Content-addressable storage stapelt elke nachtelijke build een verse blob op. Byte-level regressiediffs lichten op bij bestanden die niemand heeft aangeraakt. Volg de diff terug naar de daadwerkelijke bytes en het is bijna altijd hetzelfde handjevol hexadecimale cijfers in de bestandstrailer

Waar dient de trailer-ID-array voor?

De trailer-/ID is een bestandsidentiteitsmarkering, geen checksum van de inhoud. ISO 32000-1 §14.4 definieert het als een array van twee bytereeksen: het eerste element is de permanente identifier die wordt toegekend bij het aanmaken van het document en die elke latere bewerking dient te overleven, en het tweede element is de wijzigende identifier die een schrijver ververst bij elke wijziging van het bestand. Samen laten ze een systeem bepalen of twee bestanden revisies van één document zijn of twee losstaande documenten. §7.5.5 maakt het item in de praktijk feitelijk verplicht, aangezien de trailer /ID moet bevatten wanneer die ook /Encrypt bevat

Niets in de specificatie zegt hoe de waarde berekend moet worden. De aanbeveling is een digest van zaken zoals de huidige tijd, het bestandspad, de bestandsgrootte en het documentinformatiedictionary, en de systeemklok is het ingrediënt dat het resultaat uniek maakt. Dat is precies de eigenschap die je voor identiteit wilt en precies de eigenschap die reproduceerbaarheid vernietigt, en daarom moet dit een expliciete schakelaar zijn in plaats van een stilzwijgende gedragswijziging

Waarom produceert dezelfde build elke keer een andere PDF?

Omdat de standaardidentifier wordt afgeleid van het moment van generatie. Historisch bouwde losLab PDF Library de /ID-strings uit een MD5 van de huidige timestamp, dus een document dat twee keer met een seconde ertussen wordt aangemaakt draagt twee verschillende permanente identifiers, zelfs wanneer elke andere byte in het bestand identiek is. De downstream-kosten zijn reëel: een buildsysteem dat artefacten op hash sleutelt kan een PDF-stap nooit hergebruiken, een deduplicerende objectopslag bewaart één kopie per build in plaats van één kopie per document, en een reviewer die naar een binaire diff kijkt moet bewijzen dat de enige wijziging ruis is voordat hij de rest van de diff vertrouwt. Deterministische /ID-generatie bestaat om die ruis weg te nemen, in dezelfde geest als het layoutstabiliteitswerk beschreven in de notities over object streams en cross-reference streams

Overschakelen naar een reproduceerbare identifier

Deterministische modus is opt-in, per document, en standaard uit, zodat bestaande output ongewijzigd blijft totdat je erom vraagt. SetDeterministicDocumentID accepteert 0 of 1 en retourneert 1 wanneer de waarde is geaccepteerd, 0 voor alles buiten dat bereik; GetDeterministicDocumentID rapporteert de huidige staat. SetDocumentIDSeed levert een expliciete seed-string die voorrang heeft op alles, en het doorgeven van een lege seed keert terug naar de afgeleide seed. GetDocumentFileID leest /ID[0] terug na het opslaan, zodat je het kunt loggen of erop kunt assert

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;

De verversing gebeurt op het moment van opslaan, niet wanneer je de vlag omzet, dus het inschakelen van deterministische modus laat in de documentbuild nog steeds effect hebben. Dat betekent ook dat een gewijzigde seed pas doorwerkt naar het bestand bij de volgende volledige save: zet seed A, sla op, zet seed B, sla op, en de twee bestanden dragen verschillende identifiers, terwijl het herstellen van seed A de oorspronkelijke waarde herstelt. Een expliciete seed is de juiste keuze wanneer je document een natuurlijke stabiele sleutel heeft, zoals een factuurnummer, een recordrevisie of een git-commit-identifier, omdat dit de identifier ontkoppelt van incidentele metadata

Waar komt de seed vandaan als je er zelf geen opgeeft?

Zonder expliciete seed leidt losLab PDF Library er een af uit documentstaat die invariant zou moeten zijn bij identieke regeneraties: de PDF-versieheader, het paginaaantal, en elk item in het documentinformatiedictionary. String- en naamwaarden worden letterlijk overgenomen, andere objecttypen dragen hun geserialiseerde vorm bij, en het geheel wordt gehasht tot de /ID-strings. Het belangrijke gevolg is dat CreationDate en ModDate deel uitmaken van het informatiedictionary en daarmee bewust deel uitmaken van de seed. Twee runs krijgen alleen dezelfde identifier wanneer ze werkelijk dezelfde documentmetadata produceren

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');

Het vastpinnen van ModDate met sleutel 8 doet dubbel werk, en dit is het onderdeel waar mensen zich in vergissen. Een deterministische /ID alleen maakt het bestand nog niet byte-identiek, omdat het opslagpad ModDate met de huidige tijd stempelt tenzij de aanroeper die expliciet heeft ingesteld. Het instellen van sleutel 8 markeert de waarde als door de aanroeper geleverd en onderdrukt dat stempel. Wil je een reproduceerbaar bestand in plaats van slechts een reproduceerbare identifier, behandel metadata-timestamps dan als buildinvoer: leid ze af van het bronrecord of van een vaste epoch, nooit van Now

Waarom breekt het herschrijven van de ID een versleutelde PDF?

Omdat /ID[0] in een versleuteld document geen metadata is, het is sleutelmateriaal. ISO 32000-1 §7.6.3.3 Algoritme 2 voedt het eerste element van de bestandsidentifier in de berekening van de versleutelingssleutel voor de standaard security handler bij revisies 2 tot en met 4, samen met het opgevulde wachtwoord, de /O-waarde en de permissiebits. De afgeleide sleutel produceert vervolgens de /U-validatiestring die een reader controleert bij het openen, en de bestandssleutel wordt afgeleid en gecachet zodra je Encrypt aanroept of wanneer een versleuteld document wordt geladen, beide vóór het opslaan. Het herschrijven van de identifier tijdens het opslaan zou dus een structureel geldig bestand opleveren waarvan de /U-controle faalt bij het heropenen: geen subtiele corruptie, maar een document dat niemand meer kan openen, jij ook niet. Daarom is de deterministische verversing beperkt tot documenten die geen versleutelingsstaat dragen, en waarom een versleuteld document behoudt welke /ID het ook al had, deterministische modus of niet, en de instelling op dat pad simpelweg geen effect heeft. De verwante revisieafhandeling en permissiesemantiek worden behandeld in de doorloop van PDF-versleuteling en permissieaudit. Merk ook op dat het herstelpad voor versleuteling alleen /ID[1] ververst, de wijzigingsidentifier, precies zoals §14.4 bedoelt

Waarom behouden incrementele saves de oorspronkelijke identifier

De tweede grens is append-modus. Een incrementele update laat elke eerdere byte van het bestand ongemoeid en schrijft er een nieuwe revisie achteraan, en de permanentie van /ID[0] volgens §14.4 is wat een consument vertelt dat de nieuwe revisie bij hetzelfde document hoort als de oude. Het herschrijven ervan zou die koppeling doorsnijden, in tegenspraak zijn met de revisies die al in het bestand zitten, en de handtekeningsemantiek verstoren, aangezien een handtekening een bytebereik van een specifieke revisie van een specifiek document dekt. losLab PDF Library ververst de deterministische identifier daarom alleen bij volledige saves en nooit tijdens append-modus, wat de garantie beschreven in het artikel over incrementele PDF-updates en append naar stream intact houdt

Eén knooppunt voor identifiergeneratie

Alle /ID-generatie in losLab PDF Library loopt nu via één interne routine, NewFileIDString, wat de deterministische schakelaar betrouwbaar maakt in plaats van een pleister op één codepad. Het aanmaken van een leeg document, het lui aanmaken van een ontbrekende /ID-array wanneer nodig, en het herstelpad voor de versleutelingsvingerafdruk roepen het allemaal aan, dus er is precies één plek waar de systeemklok terug zou kunnen lekken. Het betekent ook dat toekomstige varianten, zoals een op inhoud gebaseerde identifier, een wijziging in één functie zijn in plaats van een audit van de hele serializer

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');

Bedraad die vergelijking in je testsuite voordat je ergens anders op reproduceerbare output vertrouwt, want ze faalt luid zodra een nieuwe feature per ongeluk weer een timestamp introduceert. Reproduceerbaarheid is een eigenschap die anders stilzwijgend vervalt, en één enkele assertion over twee in-memory saves kost bijna niets om bij elke build uit te voeren

De hier getoonde API voor deterministische identifiers wordt geleverd met de losLab PDF Library voor Delphi en C++Builder, samen met de volledige referentie voor documentinformatie, versleuteling en incrementeel opslaan