Teknisk artikel

Byte-identisk PDF-output: reprodicerbare gem i Delphi

PDFium Component gør reprodicerbart PDF-output til ét enkelt kald: TPdf.SaveAsReproducible omskriver dokumentet, så gem af samme indhold to gange giver byte-identiske filer. Den fjerner /CreationDate og /ModDate fra dokument-information-dictionaryen, skriver ingen trailer /ID, holder objekt-rækkefølgen stabil og rapporterer, hvad den ændrede, i en TPdfReproducibleReport. Krypterede filer nægtes, og signerede filer behøver eksplicit samtykke

Behovet viser sig normalt i en build-pipeline. Nogen checker genererede PDF'er ind i et repository eller en golden-file-testsuite, og hver kørsel frembringer en diff, selvom intet på nogen side ændrede sig. Eller et arkiv deduplicerer efter SHA-256 og gemmer stille halvtreds kopier af samme faktura, fordi hver gem stemplede et nyt tidspunkt ind i filen. En PDF-writer, der indlejrer urskiven, er fin til en desktop-app og fjendtlig over for alt, hvad der behandler bytes som identitet

Hvorfor frembringer gem af samme PDF to gange forskellige bytes?

To gem af et uændret dokument adskiller sig på grund af præcis to slags data: tidsstempler i dokument-information-dictionaryen og fil-identifikatoren i traileren. Alt andet, en omhyggelig writer udsender, objektnummerering, objekt-rækkefølge, cross-reference-layout, kan gøres deterministisk, og PDFium Components fuld-omskrivnings-vej gør det allerede: objekter skrives i stigende objektnummer, og xref-tabellen følger samme rækkefølge

Tidsstemplerne er den indlysende synder. ISO 32000-1 §14.3.3 definerer /CreationDate og /ModDate i /Info-dictionaryen, og den native PDFium-gem skriver en frisk /ModDate hver gang, den serialiserer dokumentet. Fil-identifikatoren er den subtile. ISO 32000-1 §14.4 beskriver /ID som et to-elementers array af byte-strenge, hvor første element identificerer den oprindelige fil, og det andet ændrer sig, når filen revideres. Writers udleder typisk de strenge ved at hashe noget om filen, og hvis det noget inkluderer de tidsstemplede bytes, arver identifikatoren non-determinismen

Brug af TPdf.SaveAsReproducible i Delphi

TPdf.SaveAsReproducible har to overloads, én der skriver til en TStream og én til et filnavn, og begge returnerer Boolean med en out Report: TPdfReproducibleReport og en valgfri AllowSignedDocument-parameter, der som standard er False. Returværdien spejler Report.Succeeded; er den False, siger Report.ErrorMessage hvorfor. Fil-overloaden skriver til en midlertidig fil ved siden af målet og erstatter målet først efter en vellykket gem, så en fejlet kørsel ikke efterlader en halvfærdig PDF, hvor ens tidligere build-output plejede at være

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;

Rapportfelterne er værd at logge i CI. StrippedTimestampCount er normalt 2 for et dokument, hvis /Info bærer begge datoer, og 0 for ét uden en /Info-dictionary overhovedet. PrunedObjectCount tæller objekter, der var til stede i den native gem, men ikke kunne nås fra catalog'en eller /Info-dictionaryen; den reprodicerbare omskrivning dropper dem, så forvent at outputtet er samme størrelse eller mindre end en almindelig gem

Hvorfor det ikke var nok at strippe tidsstemplerne

Den første implementering fjernede begge tidsstempler og frembragte stadig to forskellige filer. Siderne matchede, /Info-dictionaryen matchede, og trailerne adskilte sig i andet element af /ID. Rodårsagen var rækkefølge: omskrivningen beder først PDFium om en native gem, post-proesserer så de bytes, og trailer-byggeren beregnede /ID ved at hashe det native output. Det native output indeholdt stadig den friske /ModDate, så hashen, og dermed identifikatoren, ændrede sig ved hver kørsel, selvom tidsstemplet selv aldrig nåede den endelige fil

Det er et godt eksempel på en generel regel for reprodicerbare builds: at fjerne en non-deterministisk værdi er ikke nok, hvis noget først blev udledt af den. Fixet var at holde op med at bære /ID helt. TPdf.SaveAsReproducible skriver en trailer, der kun indeholder /Size, /Root og, når kilden har én, /Info. Med intet tidsafhængigt tilbage i traileren sammenlignes to på hinanden følgende gem af samme dokument ens byte for byte, hvilket er præcis, hvordan adfærden verificeres

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;

At fjerne dictionary-nøgler uden at korruptere strenge

Tidsstemplerne fjernes med en token-bevidst scanning af /Info-objektets krop, ikke med en tekstsøgning. En naiv byte-erstatning for /ModDate ville også ramme en /Subject- eller /Keywords-streng, der tilfældigt indeholder den tekst, og at skære bytes ud af en literal streng korrupterer stille metadataet i stedet for at fejle højlydt. PDFium Component neutraliserer først literal strenge og kommentarer i en scan-kopi af kroppen, accepterer så kun et match, hvor en ægte name-token begynder, bruger PDF-objektgrammatikken til at finde, hvor den nøgles værdi slutter, og springer hele nøgle-værdi-paret over

Den praktiske konsekvens er, at hver anden post i /Info, titel, forfatter, producer, brugerdefinerede nøgler, overlever urørt. Kun de to nøgler, defineret som datoer, fjernes, og kun fra dictionaryen, som trailerens /Info-post faktisk peger på

Hvad koster udeladelse af /ID for krypterede og signerede PDF'er?

At udelade /ID er sikkert til almindeligt PDF 1.x-output, men det udelukker kryptering og ændrer, hvad der sker med signaturer. I ISO 32000-1 er trailer-/ID valgfri, medmindre en /Encrypt-post er til stede, for standard security handleren blander den første identifikatorstreng ind i krypteringsnøglen. En fil uden /ID kan ikke være en korrekt krypteret fil, så TPdf.SaveAsReproducible prøver ikke: bærer traileren /Encrypt, returnerer den False, og Report.ErrorMessage siger, at dokumentet er krypteret. Dekryptér først, hvis ens pipeline tillader det, eller hold krypterede leverancer ude af byte-identitets-tjek. Skyder man mod PDF 2.0, så bemærk, at ISO 32000-2 strammer reglen og kræver /ID i hver fil, så reprodicerbart output passer naturligst med PDF 1.x-mål; artiklen om eksakt PDF-version-conformance i Delphi dækker, hvordan komponenten håndterer versionsmål

Signerede dokumenter er et andet problem. TPdf.SaveAsReproducible er en fuld, non-inkrementel omskrivning, så byte-offsets flytter sig, og de eksisterende signaturers /ByteRange dækker ikke længere de bytes, der blev signeret. Reprodicerbart output af en signeret fil er derfor en bevidst beslutning om at give afkald på de eksisterende signaturers gyldighed til gengæld for stabile bytes, hvilket er derfor, metoden rejser EPdfError, når SignatureCount er større end nul, medmindre man giver AllowSignedDocument = True med

if Pdf.SignatureCount > 0 then
  // Eksplicit opt-in: omskrivningen flytter byte-offsets, så eksisterende
  // signaturer bør forventes at fejle verifikation bagefter
  Ok:= Pdf.SaveAsReproducible('normalized.pdf', Report, True)
else
  Ok:= Pdf.SaveAsReproducible('normalized.pdf', Report);

Er det signerede original sandhedens rekord, så behold den, som den er, og brug reprodicerbart output kun til afledte kopier, som en normaliseret rendition, man diff'er i CI. Til at tilføje til en beskyttet fil uden at forstyrre, hvad der allerede er der, se hvorfor en krypteret PDF ikke kan lappes stille i Delphi

Grænser at kende, før man stoler på byte-identitet

Byte-identitet holder for samme dokument gemt af samme komponent-build, og den dækker præcis, hvad metoden rører. TPdf.SaveAsReproducible stripper kun de to /Info-datoer; en XMP metadata-stream med sin egen xmp:CreateDate eller xmp:ModifyDate føres igennem som de bytes, dokumentet allerede holder. Det er stabilt på tværs af gem, men det betyder, at XMP- og /Info-datoerne ikke længere beskriver det samme, hvilket betyder noget for PDF/A-profiler, der kræver, at de to er enige; artiklen om PDF/A Info- og XMP-metadata-ækvivalens forklarer de regler. En anden PDFium-binær eller komponentversion kan også serialisere objekter forskelligt, så fastlås begge i enhver pipeline, der sammenligner hash på tværs af maskiner

Inden for de grænser giver metoden en det, CI-diff'er, golden-file-tests og indholds-adresserede arkiver behøver: samme input, samme bytes. TPdf.SaveAsReproducible og TPdfReproducibleReport følger med PDFium Component til Delphi og C++Builder sammen med resten af dens save-, optimerings- og compliance-API

FAQ

Ændrer TPdf.SaveAsReproducible sideindhold?

Nej. Den fjerner /CreationDate- og /ModDate-nøglerne fra /Info-dictionaryen, udelader trailer /ID og dropper objekter, der ikke kan nås fra catalog'en eller /Info; sideindholdet skrives uændret

Kan man frembringe reprodicerbart output fra en krypteret PDF?

Nej. Kryptering afhænger af trailer /ID, som reprodicerbart output udelader, så metoden returnerer False med en forklarende ErrorMessage for krypterede kilder

Hvorfor behøver en signeret PDF AllowSignedDocument?

Den reprodicerbare gem er en fuld omskrivning, der flytter byte-offsets, så eksisterende signaturer holder op med at matche deres signede byte-områder. At give AllowSignedDocument = True med bekræfter, at man accepterer det kompromis