Technical Article

Deterministic PDF ID in Delphi for Reproducible Builds

losLab PDF Library can produce byte identical PDF output for identical input once you call SetDeterministicDocumentID(1). By default the trailer /ID array is an MD5 digest of the wall clock, so two runs of the same generator differ in at least those bytes. Deterministic mode derives /ID from a stable seed instead, which restores reproducible builds

The symptom usually shows up in CI before anyone goes looking for it. The template has not changed, the input record has not changed, the fonts have not changed, and the generated PDF still hashes differently on every pipeline run. Build caches never hit. Content addressable storage accumulates a fresh blob per nightly build. Byte level regression diffs light up on files nobody touched. Chase the diff down to the actual bytes and it is almost always the same handful of hex digits sitting in the file trailer

What the trailer ID array is for

The trailer /ID is a file identity marker, not a checksum of the content. ISO 32000-1 §14.4 defines it as an array of two byte strings: the first element is the permanent identifier assigned when the document is created and is meant to survive every later edit, and the second element is the changing identifier that a writer refreshes each time the file is modified. Together they let a system decide whether two files are revisions of one document or two unrelated documents. §7.5.5 makes the entry effectively mandatory in practice, since the trailer must carry /ID whenever it also carries /Encrypt

Nothing in the specification says how to compute the value. The recommendation is a digest of things like the current time, the file path, the file size and the document information dictionary, and the wall clock is the ingredient that makes the result unique. That is exactly the property you want for identity and exactly the property that destroys reproducibility, which is why this needs to be an explicit switch rather than a silent behaviour change

Why does the same build produce a different PDF every time?

Because the default identifier is derived from the moment of generation. Historically losLab PDF Library built the /ID strings from an MD5 of the current timestamp, so a document created twice one second apart carries two different permanent identifiers even when every other byte in the file is identical. The downstream cost is real: a build system that keys artifacts by hash can never reuse a PDF step, a deduplicating object store keeps one copy per build instead of one copy per document, and a reviewer looking at a binary diff has to prove that the only change is noise before trusting the rest of the diff. Deterministic /ID generation exists to remove that noise, in the same spirit as the layout stability work described in the notes on object streams and cross reference streams

Switching to a reproducible identifier

Deterministic mode is opt in, per document, and off by default so existing output is unchanged until you ask for it. SetDeterministicDocumentID accepts 0 or 1 and returns 1 when the value was accepted, 0 for anything out of range; GetDeterministicDocumentID reports the current state. SetDocumentIDSeed supplies an explicit seed string that wins over everything else, and passing an empty seed reverts to the derived seed. GetDocumentFileID reads back /ID[0] after the save so you can log it or assert on it

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;

The refresh happens at save time, not when you flip the flag, so enabling deterministic mode late in a document build still takes effect. That also means a changed seed reaches the file on the next full save: set seed A, save, set seed B, save, and the two files carry different identifiers, while restoring seed A restores the original value. An explicit seed is the right choice whenever your document has a natural stable key such as an invoice number, a record revision or a git commit identifier, because it decouples the identifier from incidental metadata

Where does the seed come from when you do not supply one?

Without an explicit seed, losLab PDF Library derives one from document state that should be invariant across identical regenerations: the PDF version header, the page count, and every entry in the document information dictionary. String and name values are taken verbatim, other object types contribute their serialized form, and the whole thing is hashed into the /ID strings. The important consequence is that CreationDate and ModDate are part of the information dictionary and therefore part of the seed by design. Two runs only earn the same identifier when they genuinely produce the same document metadata

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

Pinning ModDate with key 8 does double duty, and this is the part that catches people out. A deterministic /ID alone does not make the file byte identical, because the save path stamps ModDate with the current time unless the caller has set it explicitly. Setting key 8 marks the value as caller supplied and suppresses that stamp. If you want a reproducible file rather than merely a reproducible identifier, treat metadata timestamps as build inputs: derive them from the source record or from a fixed epoch, never from Now

Why does rewriting the ID break an encrypted PDF?

Because /ID[0] is not just metadata in an encrypted document, it is key material. ISO 32000-1 §7.6.3.3 Algorithm 2 feeds the first element of the file identifier into the encryption key computation for the standard security handler at revisions 2 through 4, alongside the padded password, the /O value and the permission bits. The derived key then produces the /U validation string that a reader checks on open, and the file key is derived and cached when you call Encrypt or when an encrypted document is loaded, both of which happen before the save. Rewriting the identifier during the save would therefore emit a structurally valid file whose /U check fails on reopen: not a subtle corruption but a document nobody can open, including you. That is why the deterministic refresh is restricted to documents that are not carrying encryption state, and why an encrypted document keeps whatever /ID it already had, deterministic mode or not, and the setting simply has no effect on that path. The related revision handling and permission semantics are covered in the walkthrough of PDF encryption and permission auditing. Note also that the encryption restore path refreshes only /ID[1], the change identifier, exactly as §14.4 intends

Why incremental saves keep the original identifier

The second boundary is append mode. An incremental update leaves every earlier byte of the file untouched and writes a new revision after it, and the permanence of /ID[0] across §14.4 is what tells a consumer that the new revision belongs to the same document as the old one. Rewriting it would sever that link, contradict the revisions already sitting in the file, and interfere with signature semantics, since a signature covers a byte range of a specific revision of a specific document. losLab PDF Library therefore refreshes the deterministic identifier on full saves only and never during append mode, which keeps the guarantee described in the article on PDF incremental updates and append to stream intact

One choke point for identifier generation

All /ID generation in losLab PDF Library now funnels through a single internal routine, NewFileIDString, which is what makes the deterministic switch trustworthy rather than a patch on one code path. Blank document creation, lazy creation of a missing /ID array on demand, and the encryption fingerprint restore path all call it, so there is exactly one place where the wall clock could leak back in. It also means future variants, such as a content derived identifier, are a change to one function rather than an audit of the whole 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');

Wire that comparison into your test suite before you rely on reproducible output anywhere else, because it fails loudly the moment some new feature reintroduces a timestamp. Reproducibility is a property that decays silently otherwise, and a single assertion over two in memory saves costs almost nothing to run on every build

The deterministic identifier API shown here ships with the losLab PDF Library for Delphi and C++Builder, alongside the full document information, encryption and incremental save reference