Teknisk artikel

Byte-identisk PDF-utdata: reproducerbara sparningar i Delphi

PDFium Component gör reproducerbar PDF-utdata till ett enda anrop: TPdf.SaveAsReproducible skriver om dokumentet så att sparning av samma innehåll två gånger ger byte-identiska filer. Den tar bort /CreationDate och /ModDate ur dokumentinformationsordlistan, skriver ingen trailer-/ID, håller objektordningen stabil och rapporterar vad den ändrade i en TPdfReproducibleReport. Krypterade filer vägras, och signerade filer kräver uttryckligt samtycke

Behovet visar sig oftast i en byggpipeline. Någon checkar in genererade PDF:er i ett arkiv eller en golden-file-testsvit, och varje körning producerar en diff fast ingenting på någon sida ändrats. Eller ett arkiv deduplicerar med SHA-256 och lagrar tyst femtio kopior av samma faktura, eftersom varje sparning stämplade en ny tid in i filen. En PDF-skrivare som bäddar in väggklockan är fin för en skrivbordsapplikation och fientlig mot allt som behandlar bytes som identitet

Varför ger två sparningar av samma PDF olika bytes?

Två sparningar av ett oförändrat dokument skiljer sig på grund av exakt två sorters data: tidsstämplarna i dokumentinformationsordlistan och filidentifieraren i trailern. Allt annat en omsorgsfull skrivare emitterar, objektnumrering, objektordning, xref-layout, kan göras deterministiskt, och PDFium Components fullständiga omskrivningsväg gör det redan: objekt skrivs i stigande objektnummer och xref-tabellen följer samma ordning

Tidsstämplarna är den uppenbara boven. ISO 32000-1 §14.3.3 definierar /CreationDate och /ModDate i /Info-ordlistan, och PDFium:s inbyggda sparning skriver en färsk /ModDate varje gång den serialiserar dokumentet. Filidentifieraren är den subtila. ISO 32000-1 §14.4 beskriver /ID som en tvåelement-array av bytesträngar, där första elementet identifierar originalfilen och det andra ändras när filen revideras. Skrivare härleder typ de strängarna genom att hasha något om filen, och om det något inkluderar de tidsstämplade bytena ärver identifieraren nondeterminismen

Använda TPdf.SaveAsReproducible i Delphi

TPdf.SaveAsReproducible har två överladdningar, en som skriver till en TStream och en till ett filnamn, och båda returnerar Boolean med en out Report: TPdfReproducibleReport och en valfri AllowSignedDocument-parameter som är False som standard. Returvärdet speglar Report.Succeeded; när det är False säger Report.ErrorMessage varför. Filöverladdningen skriver till en temporärfil bredvid målet och ersätter målet först efter en lyckad sparning, så en fallerad körning inte lämnar en halvskriven PDF där ditt tidigare byggutdata brukade ligga

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;

Rapportfälten är värda att logga i CI. StrippedTimestampCount är normalt 2 för ett dokument vars /Info bär båda datumen, och 0 för ett utan /Info-ordlista alls. PrunedObjectCount räknar objekt som fanns i den inbyggda sparningen men inte var nåbara från katalogen eller /Info-ordlistan; den reproducerbara omskrivningen slänger dem, så räkna med att utdatan är lika stor eller mindre än en vanlig sparning

Varför det inte räckte att ta bort tidsstämplarna

Den första implementationen tog bort båda tidsstämplarna och producerade ändå två olika filer. Sidorna matchade, /Info-ordlistan matchade, och trailer:na skilde sig i det andra elementet av /ID. Rotorsaken var ordningen: omskrivningen ber först PDFium om en inbyggd sparning och efterbehandlar sedan de bytena, och trailerbyggaren beräknade /ID genom att hasha den inbyggda utdatan. Den inbyggda utdatan innehöll fortfarande den färska /ModDate, så hashen, och därmed identifieraren, ändrades vid varje körning fast tidsstämpeln själv aldrig nådde den slutliga filen

Det är ett gott exempel på en allmän regel för reproducerbara byggen: att ta bort ett icke-deterministiskt värde räcker inte om något härletts ur det först. Fixen var att sluta bära /ID över huvud taget. TPdf.SaveAsReproducible skriver en trailer som innehåller bara /Size, /Root och, när källan har en, /Info. Med ingenting tidsberoende kvar i trailern jämförs två på varandra följande sparningar av samma dokument lika byte för byte, vilket är exakt hur beteendet verifieras

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;

Ta bort ordlistenycklar utan att förstöra strängar

Tidsstämplarna tas bort med en tokenmedveten skanning av /Info-objektets kropp, inte av en textsökning. En naiv bytesersättning för /ModDate skulle också träffa en /Subject- eller /Keywords-sträng som råkar innehålla den texten, och att klippa bytes ur en literal sträng förstör tyst metadata i stället för att fallera högt. PDFium Component neutraliserar först literalsträngar och kommentarer i en skanningskopia av kroppen, accepterar sedan bara en träff där en riktig namntoken börjar, använder PDF-objektgrammatiken för att hitta var den nyckelns värde slutar och hoppar över hela nyckel-värde-paret

Den praktiska konsekvensen är att varje annan post i /Info, titel, författare, producent, egna nycklar, överlever orörd. Bara de två nycklar som definierats som datum tas bort, och bara ur ordlistan som trailerposten /Info faktiskt pekar på

Vad kostar att utelämna /ID för krypterade och signerade PDF:er?

Att utelämna /ID är säkert för vanlig PDF 1.x-utdata, men det utesluter kryptering och ändrar vad som händer med signaturer. I ISO 32000-1 är trailer-/ID valfri om inte en /Encrypt-post finns, eftersom standardens säkerhetshanterare blandar den första identifierarsträngen in i krypteringsnyckeln. En fil utan /ID kan inte vara en korrekt krypterad fil, så TPdf.SaveAsReproducible försöker inte: bär trailern /Encrypt returnerar den False och Report.ErrorMessage säger att dokumentet är krypterat. Dekryptera först om din pipeline tillåter det, eller håll krypterade leveranser utanför byteidentitetskontrollerna. Söker du PDF 2.0, notera att ISO 32000-2 hårdnar regeln och kräver /ID i varje fil, så reproducerbar utdata passar naturligast med PDF 1.x-mål; artikeln om exakt PDF-versionskonformitet i Delphi täcker hur komponenten hanterar versionsmål

Signerade dokument är ett annat problem. TPdf.SaveAsReproducible är en fullständig, icke-inkrementell omskrivning, så byteoffset flyttas och de befintliga signaturernas /ByteRange täcker inte längre de bytes som signerades. Reproducerbar utdata av en signerad fil är därför ett medvetet beslut att ge upp de befintliga signaturernas giltighet i utbyte mot stabila bytes, vilket är varför metoden kastar EPdfError när SignatureCount är större än noll om du inte skickar AllowSignedDocument = True

if Pdf.SignatureCount > 0 then
  // Explicit opt-in: omskrivningen flyttar byteoffset, så befintliga
  // signaturer kan förväntas fallera verifieringen efteråt
  Ok:= Pdf.SaveAsReproducible('normalized.pdf', Report, True)
else
  Ok:= Pdf.SaveAsReproducible('normalized.pdf', Report);

Är det signerade originalet sanningsrekordet, behåll det som det är och använd reproducerbar utdata bara för härledda kopior, som en normaliserad version du diffar i CI. För att bifoga till en skyddad fil utan att störa vad som redan finns där, se varför en krypterad PDF inte kan patchas tyst i Delphi

Gränser att känna till innan du litar på byteidentitet

Byteidentitet gäller för samma dokument sparat av samma komponentbygge, och den täcker exakt vad metoden vidrör. TPdf.SaveAsReproducible tar bara bort de två /Info-datumen; en XMP-metadatström med sitt eget xmp:CreateDate eller xmp:ModifyDate förs vidare som de bytes dokumentet redan håller. Det är stabilt över sparningar, men det betyder att XMP- och /Info-datumen inte längre beskriver samma sak, vilket spelar roll för PDF/A-profiler som kräver att de två överensstämmer; artikeln om PDF/A Info- och XMP-metadataekvivalens förklarar de reglerna. En annan PDFium-binär eller komponentversion kan också serialisera objekt olika, så lås båda i vilken pipeline som helst som jämför hashar mellan maskiner

Inom de gränserna ger metoden dig det som CI-diffar, golden-file-test och innehållsadresserade arkiv behöver: samma indata, samma bytes. TPdf.SaveAsReproducible och TPdfReproducibleReport medföljer PDFium Component for Delphi and C++Builder, bredvid resten av dess sparnings-, optimerings- och efterlevnads-API

FAQ

Ändrar TPdf.SaveAsReproducible sidinnehåll?

Nej. Den tar bort nycklarna /CreationDate och /ModDate ur /Info-ordlistan, utelämnar trailer-/ID och slänger objekt onåbara från katalogen eller /Info; sidinnehållet skrivs oförändrat

Kan jag producera reproducerbar utdata från en krypterad PDF?

Nej. Kryptering beror på trailer-/ID, som reproducerbar utdata utelämnar, så metoden returnerar False med ett förklarande ErrorMessage för krypterade källor

Varför behöver en signerad PDF AllowSignedDocument?

Den reproducerbara sparningen är en fullständig omskrivning som flyttar byteoffset, så befintliga signaturer slutar matcha sina signerade byteintervall. Att skicka AllowSignedDocument = True bekräftar att du accepterar den avvägningen