Technisch artikel

Byte-identieke PDF's: reproduceerbaar opslaan in Delphi

De PDFium Component maakt reproduceerbare PDF-uitvoer één aanroep: TPdf.SaveAsReproducible herschrijft het document zodat het tweemaal opslaan van dezelfde inhoud byte-identieke bestanden oplevert. Hij verwijdert /CreationDate en /ModDate uit het documentinformatiewoordenboek, schrijft geen trailer-/ID, houdt de objectvolgorde stabiel en meldt wat hij veranderde in een TPdfReproducibleReport. Versleutelde bestanden worden geweigerd, en ondertekende bestanden vragen expliciete toestemming

De behoefte toont zich meestal in een buildpijplijn. Iemand zet gegenereerde PDF's in een repository of een golden-file-testsuite, en elke run produceert een diff terwijl er op geen enkele pagina iets veranderde. Of een archief dedupliceert op SHA-256 en bewaart stilletjes vijftig kopieën van dezelfde factuur, omdat elke opslag een nieuwe tijd in het bestand stempelde. Een PDF-schrijver die de muurklok inbedt, is prima voor een desktopapplicatie en vijandig tegen alles dat bytes als identiteit behandelt

Waarom levert het tweemaal opslaan van dezelfde PDF verschillende bytes op?

Twee opslagen van een onveranderd document verschillen door precies twee soorten data: tijdstempels in het documentinformatiewoordenboek en de bestandsidentificatie in de trailer. Al het andere dat een zorgvuldige schrijver uitzendt — objectnummering, objectvolgorde, cross-reference-layout — is deterministisch te maken, en het full-rewrite-pad van de PDFium Component doet dat al: objecten worden in oplopend objectnummer geschreven en de xref-tabel volgt dezelfde volgorde

De tijdstempels zijn de voor de hand liggende dader. ISO 32000-1 §14.3.3 definieert /CreationDate en /ModDate in het /Info-woordenboek, en de native PDFium-opslag schrijft elke keer dat hij het document serialiseert een verse /ModDate. De bestandsidentificatie is het subtiele deel. ISO 32000-1 §14.4 beschrijft /ID als een array van twee bytestrings, waarin het eerste element het originele bestand identificeert en het tweede verandert naarmate het bestand wordt herzien. Schrijvers leiden die strings doorgaans af door iets over het bestand te hashen, en bevat dat iets de gestempelde bytes, dan erft de identificatie het niet-determinisme

TPdf.SaveAsReproducible gebruiken in Delphi

TPdf.SaveAsReproducible heeft twee overloads, één die naar een TStream schrijft en één naar een bestandsnaam, en beide geven Boolean terug met een out Report: TPdfReproducibleReport en een optionele parameter AllowSignedDocument die op False staat. De returnwaarde spiegelt Report.Succeeded; is die False, dan zegt Report.ErrorMessage waarom. De bestandsoverload schrijft naar een tijdelijk bestand naast het doel en vervangt het doel pas na een geslaagde opslag, dus een mislukte run laat geen half geschreven PDF achter waar eerst uw vorige build-uitvoer stond

var
  Pdf: TPdf;
  Report: TPdfReproducibleReport;
begin
  Pdf:= TPdf.Create(nil);
  try
    Pdf.FileName:= 'invoice-2026-10.pdf';
    Pdf.Active:= True;
    if Pdf.SaveAsReproducible('build\invoice-2026-10.pdf', Report) then
      Writeln(Format('%d -> %d bytes, %d objects kept, %d pruned, ' +
        '%d timestamps stripped', [Report.SourceByteCount,
        Report.OutputByteCount, Report.KeptObjectCount,
        Report.PrunedObjectCount, Report.StrippedTimestampCount]))
    else
      Writeln('Reproducible save failed: ', Report.ErrorMessage);
  finally
    Pdf.Free;
  end;
end;

De rapportvelden zijn het loggen in CI waard. StrippedTimestampCount is doorgaans 2 voor een document waarvan /Info beide data voert, en 0 voor een document zonder /Info-woordenboek. PrunedObjectCount telt objecten die in de native opslag aanwezig waren maar niet bereikbaar waren vanuit de catalogus of het /Info-woordenboek; de reproduceerbare herschrijving laat ze vallen, dus verwacht dat de uitvoer even groot of kleiner is dan een gewone opslag

Waarom de tijdstempels strippen niet genoeg was

De eerste implementatie verwijderde beide tijdstempels en produceerde toch twee verschillende bestanden. De pagina's matchten, het /Info-woordenboek matchte, en de trailers verschilden in het tweede element van /ID. De oorzaak zat in de volgorde: de herschrijving vraagt PDFium eerst om een native opslag en verwerkt die bytes daarna, en de trailerbouwer berekende /ID door de native uitvoer te hashen. Die native uitvoer bevatte nog de verse /ModDate, dus de hash — en daarmee de identificatie — veranderde bij elke run, ook al bereikte het tijdstempel zelf nooit het definitieve bestand

Het is een goed voorbeeld van een algemene regel voor reproduceerbare builds: een niet-deterministische waarde verwijderen is niet genoeg als er eerst iets uit is afgeleid. De fix was /ID helemaal niet meer mee te voeren. TPdf.SaveAsReproducible schrijft een trailer die alleen /Size, /Root en, wanneer de bron er een heeft, /Info bevat. Zonder iets tijdsafhankelijks in de trailer vergelijken twee opeenvolgende opslagen van hetzelfde document byte voor byte gelijk, en precies zo wordt het gedrag geverifieerd

function SavesAreIdentical(Pdf: TPdf): Boolean;
var
  A, B: TMemoryStream;
  RA, RB: TPdfReproducibleReport;
begin
  A:= TMemoryStream.Create;
  B:= TMemoryStream.Create;
  try
    Result:= Pdf.SaveAsReproducible(A, RA) and
      Pdf.SaveAsReproducible(B, RB) and
      (A.Size = B.Size) and CompareMem(A.Memory, B.Memory, A.Size);
  finally
    B.Free;
    A.Free;
  end;
end;

Woordenboeksleutels verwijderen zonder strings te beschadigen

De tijdstempels worden verwijderd met een tokengewijze scan van de /Info-objectbody, niet met een tekstzoekactie. Een naïeve bytevervanging voor /ModDate zou ook een /Subject- of /Keywords-string raken die toevallig die tekst bevat, en bytes uit een letterlijke string knippen beschadigt stilletjes de metadata in plaats van luid te falen. De PDFium Component maakt eerst letterlijke strings en commentaar onschadelijk in een scankopie van de body, accepteert daarna alleen een match waar een echte naamtoken begint, gebruikt de PDF-objectgrammatica om te vinden waar de waarde van die sleutel eindigt en slaat het hele sleutel-waarde-paar over

De praktische consequentie is dat elke andere entry in /Info — titel, auteur, producent, custom keys — onaangeroerd overleeft. Alleen de twee sleutels die als datum gedefinieerd zijn worden verwijderd, en alleen uit het woordenboek waarnaar de /Info-entry van de trailer werkelijk wijst

Wat kost het weglaten van /ID voor versleutelde en ondertekende PDF's?

/ID weglaten is veilig voor gewone PDF 1.x-uitvoer, maar het sluit versleuteling uit en verandert wat er met handtekeningen gebeurt. In ISO 32000-1 is de trailer-/ID optioneel tenzij een /Encrypt-entry aanwezig is, want de standaard security handler mengt de eerste identificatiestring in de versleutelingssleutel. Een bestand zonder /ID kan geen correct versleuteld bestand zijn, dus TPdf.SaveAsReproducible probeert het niet: voert de trailer /Encrypt, dan geeft hij False terug en zegt Report.ErrorMessage dat het document versleuteld is. Ontsleutel eerst als uw pijplijn dat toelaat, of houd versleutelde opleveringen buiten byte-identiteitscontroles. Richt u zich op PDF 2.0, bedenk dan dat ISO 32000-2 de regel aanscherpt en /ID in elk bestand vereist, dus reproduceerbare uitvoer past het natuurlijkst bij PDF 1.x-doelen; het artikel over exacte PDF-versieconformantie in Delphi behandelt hoe de component met versiedoelen omgaat

Ondertekende documenten zijn een ander probleem. TPdf.SaveAsReproducible is een volledige, niet-incrementele herschrijving, dus byte-offsets verschuiven en de /ByteRange van bestaande handtekeningen dekt de bytes die waren ondertekend niet meer. Reproduceerbare uitvoer van een ondertekend bestand is daarom een bewuste beslissing om de geldigheid van de bestaande handtekeningen op te geven in ruil voor stabiele bytes, en daarom geeft de methode EPdfError wanneer SignatureCount groter is dan nul, tenzij u AllowSignedDocument = True doorgeeft

if Pdf.SignatureCount > 0 then
  // Expliciete opt-in: de herschrijving verschuift byte-offsets, dus bestaande
  // handtekeningen zullen naar verwachting niet meer verifiëren
  Ok:= Pdf.SaveAsReproducible('normalized.pdf', Report, True)
else
  Ok:= Pdf.SaveAsReproducible('normalized.pdf', Report);

Is het ondertekende origineel het bewijs van de waarheid, houd het dan zoals het is en gebruik reproduceerbare uitvoer alleen voor afgeleide kopieën, zoals een genormaliseerde uitvoering die u in CI als diff controleert. Voor het toevoegen aan een beveiligd bestand zonder te storen aan wat er al staat, zie waarom een versleutelde PDF niet stilletjes kan worden gepatcht in Delphi

Grenzen om te kennen voordat u op byte-identiteit vertrouwt

Byte-identiteit geldt voor hetzelfde document opgeslagen door dezelfde componentbuild, en hij dekt exact wat de methode aanraakt. TPdf.SaveAsReproducible stript alleen de twee /Info-data; een XMP-metadatostream met zijn eigen xmp:CreateDate of xmp:ModifyDate gaat door zoals de bytes van het document die al bevatten. Dat is stabiel over opslagen heen, maar het betekent dat de XMP- en /Info-data niet langer hetzelfde beschrijven, wat telt voor PDF/A-profielen die eisen dat de twee overeenkomen; het artikel over PDF/A Info- en XMP-metadata-equivalentie legt die regels uit. Een andere PDFium-binary of componentversie kan objecten bovendien anders serialiseren, dus pin beide vast in elke pijplijn die hashes tussen machines vergelijkt

Binnen die grenzen geeft de methode u wat CI-diffs, golden-file-tests en content-addressed archieven nodig hebben: dezelfde invoer, dezelfde bytes. TPdf.SaveAsReproducible en TPdfReproducibleReport worden geleverd met de PDFium Component for Delphi and C++Builder, naast de rest van zijn save-, optimalisatie- en compliance-API

FAQ

Verandert TPdf.SaveAsReproducible pagina-inhoud?

Nee. Hij verwijdert de sleutels /CreationDate en /ModDate uit het /Info-woordenboek, laat de trailer-/ID weg en gooit objecten weg die niet bereikbaar zijn vanuit de catalogus of /Info; pagina-inhoud wordt ongewijzigd geschreven

Kan ik reproduceerbare uitvoer produceren uit een versleutelde PDF?

Nee. Versleuteling hangt af van de trailer-/ID, die reproduceerbare uitvoer weglaat, dus de methode geeft False terug met een verklarende ErrorMessage voor versleutelde bronnen

Waarom heeft een ondertekende PDF AllowSignedDocument nodig?

De reproduceerbare opslag is een volledige herschrijving die byte-offsets verschuift, dus bestaande handtekeningen matchen hun ondertekende bytebereiken niet meer. Het doorgeven van AllowSignedDocument = True bevestigt dat u die afweging accepteert