Genereer dezelfde factuur twee keer uit hetzelfde sjabloon met dezelfde data, en diff de twee PDF's. Twee plekken verschillen: de hex-strings in de trailer-/ID-array en de ModDate-string in de documentinformatiedictionary, allebei van de klok. In losLab PDF Library maakt SetDeterministicDocumentID(1) plus een vaste ModDate de bestanden byte-identiek, voor een document zonder per-run XMP-UUID's of bijlagen
De voor de hand liggende eerste poging is de datums vastpinnen en opnieuw draaien. CreationDate en ModDate matchen nu, en de hashes verschillen nog steeds, nu alleen in twee hex-strings van 32 cijfers vlak voor het einde van het bestand, binnen /ID [<…> <…>]. Die identifier wordt apart van de metadata berekend, en totdat hij niet meer van het opslagtijdstip afhangt raakt een buildcache die op de bestandshash sleutelt nooit raak en houdt een content-addressable store één blob per run in plaats van één per document
Waar de trailer-ID-array voor dient
De trailer-/ID identificeert een bestand; hij is geen checksum van zijn inhoud. ISO 32000-1 §14.4 definieert hem als een array van twee bytestrings. De eerste is de permanente identifier, toegekend bij de creatie van het document en bedoeld om elke latere bewerking te overleven. De tweede is de veranderende identifier, die een writer verversst telkens als hij het bestand wijzigt. Een documentmanagementsysteem vergelijkt het paar om te beslissen of twee bestanden revisies van één document zijn of twee ongerelateerde documenten. Tabel 15 in §7.5.5 maakt de invoer verplicht wanneer de trailer /Encrypt draagt en optioneel verder, en §14.4 merkt op dat hem weglaten workflows in de weg kan zitten die op bestandsidentiteit leunen
De specificatie zegt nooit hoe de waarde moet worden berekend. Ze suggereert een MD5-digest over dingen als de huidige tijd, het bestandspad, de bestandsgrootte en de waarden in de informatiedictionary, en de klok is het ingrediënt dat het resultaat uniek maakt. Uniciteit is precies wat je van een identiteitsmarker wilt en precies wat reproduceerbaarheid sloopt. Standaard volgt losLab PDF Library die suggestie in zijn eenvoudigste vorm, een MD5 van de huidige timestamp, dus twee documenten die een seconde uit elkaar worden gemaakt krijgen verschillende permanente identifiers, ook als alle andere bytes matchen
Een reproduceerbare identifier aanzetten
Deterministische modus is een schakelaar per document en staat standaard uit, dus bestaande output verandert niet totdat je erom vraagt. SetDeterministicDocumentID neemt 0 of 1 en geeft 1 terug wanneer de waarde is geaccepteerd, 0 voor al het andere; GetDeterministicDocumentID meldt de huidige instelling. SetDocumentIDSeed levert een expliciete seedstring aan, en met een seed in place is de identifier een MD5 van die string, weggeschreven in beide elementen van de array. GetDocumentFileID leest /ID[0] na de save terug, handig voor logging of voor een testassertie
var
Lib: TPDFlib;
FileID: WideString;
begin
Lib := TPDFlib.Create;
try
Lib.SetDeterministicDocumentID(1); // direct na Create
Lib.SetDocumentIDSeed('invoice-4471-rev3');
Lib.SetInformation(7, 'D:20260101000000Z'); // CreationDate
Lib.SetInformation(8, 'D:20260101000000Z'); // ModDate
Lib.SetOrigin(1);
Lib.DrawText(100, 700, 'Invoice 4471');
Lib.SaveToFile('invoice.pdf');
FileID := Lib.GetDocumentFileID; // dezelfde waarde bij elke run
finally
Lib.Free;
end;
end;
De identifier wordt berekend op opslagtijd. De schakelaar laat in een documentbuild omslaan werkt nog steeds, en een gewijzigde seed komt bij de volgende volledige save in het bestand: opslaan met seed A, nog eens opslaan met seed B, en de twee uitvoeren dragen verschillende identifiers, terwijl terug naar seed A je de originele waarde weer geeft
Zet de schakelaar wel direct na het aanmaken van het object aan. Fonts, afbeeldingen, graphics states en andere paginaresources krijgen namen in de resource dictionary van de pagina, en zolang deterministische modus aan staat komen die namen uit een teller. Alles wat vóór de schakelaar werd toegevoegd houdt een willekeurig gegenereerde naam, wat de identifier stabiel laat maar elke run een paar bytes in de contentstreams verandert
Waarom verandert de identifier nog steeds als er geen seed is gezet?
Omdat de informatiedictionary deel uitmaakt van de hash. Laat de seed leeg, of geef een lege string aan SetDocumentIDSeed om één te wissen, en losLab PDF Library leidt de identifier af uit het bestand zelf: hij jaagt de geserialiseerde body door SHA-256 tijdens het schrijven en gebruikt de eerste 16 bytes van de digest voor beide elementen van /ID. Elke wijziging in pagina-inhoud verandert de identifier, en dat is het hele punt. De vangst is dat CreationDate en ModDate ook in die body leven, en de saveroutines stempelen ModDate met de huidige tijd tenzij de aanroeper hem heeft gezet
Lib.SetDeterministicDocumentID(1);
Lib.SetDocumentIDSeed(''); // leid /ID af uit de opgeslagen bytes
// De body-hash dekt de Info dictionary, dus de datums moeten
// build-invoeren zijn. Sleutel 8 verhindert ook dat de save ModDate stempelt.
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');
Sleutel 8 doet hier dubbel werk, en het is de regel die mensen het vaakst weglaten. ModDate via SetInformation zetten markeert de datum als door de aanroeper geleverd, en vanaf dan laten SaveToFile en SaveToString hem met rust. Zonder schrijft elke save een verse timestamp, verandert de gehashte body, en verandert de inhoudsafgeleide identifier mee. Neem de datums uit het bronrecord of uit een vast epoch. Now hoort niet thuis in een reproduceerbare build
Een paar dingen wordt met opzet buiten de hash gehouden. Willekeurig gegenereerde resourcenamen en de UUID in een XMP-xmpMM:InstanceID-invoer worden vóór het hashen genormaliseerd, dus twee runs die alleen in die tokens verschillen krijgen dezelfde /ID. De bestandsbytes verschillen daar wel, dus een PDF/A-achtig document met een verse instance-UUID per run geeft je een reproduceerbare identifier maar geen byte-identiek bestand. Ingesloten bijlagen zijn een andere bron van drift: AddAssociatedFileFromString stempelt de eigen /ModDate van de bijlage met de huidige tijd, en die datum wordt gehasht als elke andere bodybyte
Expliciete seed of inhoudshash
Voor build-artefacten zouden wij de seed leeg laten. Een inhoudsafgeleide identifier verandert precies wanneer de bytes veranderen, dus een cache die op hem sleutelt kan nooit een verouderd bestand serveren, en je krijgt geen tweede sleutel die synchroon moet blijven met de data. Een expliciete seed past bij een andere taak: een downstream archief dat /ID[0] als documentidentiteit behandelt en dezelfde identifier verwacht over regeneraties van dezelfde factuur heen, ook nadat het sjabloon is veranderd. Dat past beter bij de bedoeling van §14.4, tegen de prijs dat twee zichtbaar verschillende bestanden een identifier kunnen delen
Eén gevolg van de inhoudsroute verdient een bewuste beslissing. Laad je een bestaande PDF en sla je hem volledig op met deterministische modus aan, dan wordt de inkomende /ID weggegooid en hangt de nieuwe alleen af van wat er wordt geschreven. Twee bronbestanden die alleen in hun trailer-identifiers verschillen komen met dezelfde /ID uit de bus. Dat is wat een normaliserende buildstap wil. Bewerk je het document van een klant en moet zijn identiteit overleven, dan laat je deterministische modus voor die taak uit of slaat je hem incrementeel op
Waarom breekt het herschrijven van de ID een versleutelde PDF?
Omdat /ID[0] in een versleuteld document sleutelmateriaal is. ISO 32000-1 §7.6.3.3 algoritme 2 voedt het eerste element van de bestandsidentifier in de encryptiesleutelberekening voor de standaard security handler bij revisies 2 tot en met 4, samen met het aangevulde wachtwoord, de /O-waarde en de permissiebits. Die sleutel levert vervolgens de /U-string op die een reader bij het openen checkt. De sleutel wordt afgeleid op het moment dat je Encrypt aanroept of een versleuteld bestand laadt, vóór elke save, dus de identifier tijdens de save vervangen zou een keurig gevormd bestand opleveren waarvan de wachtwoordcontrole voor iedereen faalt, jijzelf inbegrepen
losLab PDF Library verversst de deterministische identifier daarom alleen bij onversleutelde volledige saves. Een versleuteld document houdt de /ID die het al had, en de schakelaar heeft geen effect op dat pad. Zo'n bestand heropenen met het gebruikerswachtwoord werkt zoals gewoonlijk. Stop ook geen versleutelde output in een bytevergelijking: AES-versleuteling onder §7.6.2 begint elke versleutelde string en stream met een willekeurige initialisatievector van 16 bytes, dus twee versleutelingen van hetzelfde document matchen nooit byte voor byte. Heb je daar een regressiecontrole nodig, decrypt allebei en vergelijk wat je terugkrijgt. Revisieafhandeling en permissiebits staan dieper uitgewerkt in de rondwandeling van PDF-versleuteling en permissieaudits
Incrementele saves houden de originele identifier
Een incrementele update laat elke eerdere byte van het bestand op zijn plek en plakt een nieuwe revisie erachter aan. De permanentie van /ID[0] vertelt een consument dat de toegevoegde revisie bij hetzelfde document hoort als de voorgaande. Hem herschrijven zou de revisies die al in het bestand staan tegenspreken en zou handtekeningworkflows ondermijnen, want een handtekening dekt een bytebereik van één specifieke revisie. Append-modus raakt de identifier in deterministische modus nooit aan, wat het gedrag dat in het artikel over PDF-incrementele updates en append to stream staat beschreven intact houdt
De output over tijd reproduceerbaar houden
Elke latere wijziging aan de generator is een kans om een klokwaarde terug in het bestand te stoppen: een watermerk dat de generatietijd printt, een codepad dat een bestand toevoegt, een andere save-optie. Save-opties tellen net zo zwaar als inhoud: objecten in compressed object streams stoppen levert een compleet andere bestandsindeling op dan de klassieke cross reference tabel, zoals uitgelegd in de notities over object streams en cross reference streams, dus houd die instellingen per pijplijn vast. De goedkoopste verdediging is een test die hetzelfde document twee keer in het geheugen bouwt en de bytes vergelijkt
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;
// Twee onafhankelijke builds moeten één bytereeks opleveren
if BuildQuote('quote-8812') = BuildQuote('quote-8812') then
WriteLn('reproducible')
else
WriteLn('nondeterminism leaked into the output');
Dezelfde harness kan een seriele en een parallelle RecompressStreams-run op dezelfde bron vergelijken: laad hem in twee TPDFlib-instanties, zet deterministische IDs aan en pin ModDate op allebei, zet SetOptimizationWorkers(1) op de één en een hoger aantal workers op de ander, en diff de twee SaveToString-resultaten. Met de klok uit beide bestanden komt elk resterend verschil uit de recompressie zelf
Reproduceerbare trailer-identifiers, metadatacontrole en incrementeel opslaan maken allemaal deel uit van losLab PDF Library voor Delphi en C++Builder, waarvan de referentie ook de hier genoemde versleutelingsopties opsomt