Article technique

Sortie PDF reproductible en Delphi : octets identiques

Le PDFium Component fait de la sortie PDF reproductible un appel unique : TPdf.SaveAsReproducible réécrit le document pour qu'enregistrer deux fois le même contenu donne des fichiers identiques octet pour octet. Il retire /CreationDate et /ModDate du dictionnaire d'informations du document, n'écrit pas de /ID de trailer, garde l'ordre des objets stable, et rapporte ce qu'il a changé dans un TPdfReproducibleReport. Les fichiers chiffrés sont refusés, et les signés demandent un consentement explicite

Le besoin apparaît en général dans un pipeline de build. Quelqu'un versionne des PDF générés dans un dépôt ou une suite de tests à fichiers de référence, et chaque exécution produit un diff alors que rien sur aucune page n'a changé. Ou une archive déduplique par SHA-256 et stocke tranquillement cinquante copies de la même facture, parce que chaque enregistrement a tamponné une nouvelle heure dans le fichier. Un rédacteur PDF qui embarque l'horloge murale convient à une appli de bureau et est hostile à tout ce qui traite les octets comme identité

Pourquoi enregistrer deux fois le même PDF produit-il des octets différents ?

Deux enregistrements d'un document inchangé diffèrent à cause d'exactement deux sortes de données : les horodatages du dictionnaire d'informations du document et l'identifiant de fichier dans le trailer. Tout ce qu'un rédacteur soigné émet, numérotation des objets, ordre des objets, disposition des références croisées, peut être rendu déterministe, et le chemin de réécriture complète du PDFium Component le fait déjà : les objets sont écrits en numéro d'objets croissant et la table xref suit le même ordre

Les horodatages sont le coupable évident. ISO 32000-1 §14.3.3 définit /CreationDate et /ModDate dans le dictionnaire /Info, et l'enregistrement natif PDFium écrit un /ModDate neuf chaque fois qu'il sérialise le document. L'identifiant de fichier est le subtil. ISO 32000-1 §14.4 décrit /ID comme un tableau à deux éléments de chaînes d'octets, où le premier élément identifie le fichier original et le second change quand le fichier est révisé. Les rédacteurs dérivent en général ces chaînes en hachant quelque chose du fichier, et si ce quelque chose inclut les octets horodatés, l'identifiant hérite du non-déterminisme

Utiliser TPdf.SaveAsReproducible en Delphi

TPdf.SaveAsReproducible a deux surcharges, une écrivant dans un TStream et une dans un nom de fichier, et toutes deux renvoient un Boolean avec un out Report: TPdfReproducibleReport et un paramètre optionnel AllowSignedDocument qui vaut False par défaut. La valeur de retour reflète Report.Succeeded ; quand il est False, Report.ErrorMessage dit pourquoi. La surcharge fichier écrit dans un fichier temporaire à côté de la cible et ne remplace la cible qu'après un enregistrement réussi, si bien qu'une exécution en échec ne laisse pas un PDF à moitié écrit là où se trouvait la sortie de votre build précédent

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;

Les champs du rapport valent d'être journalisés en CI. StrippedTimestampCount vaut normalement 2 pour un document dont le /Info porte les deux dates, et 0 pour un document sans dictionnaire /Info du tout. PrunedObjectCount compte les objets qui étaient présents dans l'enregistrement natif mais pas atteignables depuis le catalogue ou le dictionnaire /Info ; la réécriture reproductible les lâche, donc attendez-vous à une sortie de même taille ou plus petite qu'un enregistrement ordinaire

Pourquoi retirer les horodatages ne suffisait pas

La première implémentation retirait les deux horodatages et produisait quand même deux fichiers différents. Les pages concordaient, le dictionnaire /Info concordait, et les trailers différaient sur le second élément de /ID. La cause racine était l'ordre : la réécriture demande d'abord à PDFium un enregistrement natif, puis post-traite ces octets, et le constructeur de trailer calculait /ID en hachant la sortie native. Cette sortie native contenait encore le /ModDate neuf, si bien que le hachage, donc l'identifiant, changeait à chaque exécution alors que l'horodatage lui-même n'arrivait jamais dans le fichier final

C'est un bon exemple d'une règle générale des builds reproductibles : retirer une valeur non déterministe ne suffit pas si quoi que ce soit en a été dérivé d'abord. Le correctif a été de ne plus porter du tout de /ID. TPdf.SaveAsReproducible écrit un trailer ne contenant que /Size, /Root et, quand la source en a un, /Info. Ne restant plus rien de dépendant du temps dans le trailer, deux enregistrements consécutifs du même document se comparent égaux octet pour octet, ce qui est exactement comme le comportement est vérifié

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;

Retirer des clés de dictionnaire sans corrompre des chaînes

Les horodatages sont retirés par un scan conscient des tokens du corps de l'objet /Info, pas par une recherche de texte. Un remplacement d'octets naïf pour /ModDate toucherait aussi une chaîne /Subject ou /Keywords qui contient par hasard ce texte, et couper des octets dans une chaîne littérale corrompt en silence les métadonnées au lieu d'échouer bruyamment. Le PDFium Component neutralise d'abord les chaînes littérales et commentaires dans une copie de scan du corps, puis n'accepte une correspondance que là où un vrai token de nom démarre, utilise la grammaire d'objets PDF pour trouver où finit la valeur de cette clé, et saute toute la paire clé-valeur

La conséquence pratique est que toute autre entrée de /Info, titre, auteur, producteur, clés personnalisées, survit intacte. Seules les deux clés définies comme dates sont retirées, et seulement du dictionnaire vers lequel pointe réellement l'entrée /Info du trailer

Que coûte l'omission de /ID pour des PDF chiffrés et signés ?

Omettre /ID est sûr pour une sortie PDF 1.x simple, mais cela exclut le chiffrement et change ce qui arrive aux signatures. Dans ISO 32000-1, le /ID du trailer est optionnel sauf si une entrée /Encrypt est présente, parce que le gestionnaire de sécurité standard mélange la première chaîne d'identifiant dans la clé de chiffrement. Un fichier sans /ID ne peut pas être un fichier correctement chiffré, donc TPdf.SaveAsReproducible n'essaie pas : quand le trailer porte /Encrypt, il renvoie False et Report.ErrorMessage dit que le document est chiffré. Déchiffrez d'abord si votre pipeline le permet, ou tenez les livrables chiffrés hors des vérifications d'identité d'octets. Si vous visez PDF 2.0, notez qu'ISO 32000-2 resserre la règle et exige /ID dans chaque fichier, si bien que la sortie reproductible colle le plus naturellement aux cibles PDF 1.x ; l'article sur la conformité exacte de version PDF en Delphi couvre comment le composant gère les cibles de version

Les documents signés sont un autre problème. TPdf.SaveAsReproducible est une réécriture complète non incrémentale, si bien que les décalages d'octets bougent et que le /ByteRange des signatures existantes ne couvre plus les octets qui ont été signés. La sortie reproductible d'un fichier signé est donc une décision délibérée d'abandonner la validité des signatures existantes en échange d'octets stables, voilà pourquoi la méthode lève EPdfError quand SignatureCount est supérieur à zéro sauf si vous passez AllowSignedDocument = True

if Pdf.SignatureCount > 0 then
  // Consentement explicite : la réécriture bouge les décalages d'octets, donc les
  // signatures existantes doivent être attendues comme échouant à la vérification ensuite
  Ok:= Pdf.SaveAsReproducible('normalized.pdf', Report, True)
else
  Ok:= Pdf.SaveAsReproducible('normalized.pdf', Report);

Si l'original signé est le registre de vérité, gardez-le tel quel et utilisez la sortie reproductible seulement pour des copies dérivées, comme une version normalisée que vous comparez en diff en CI. Pour apposer à un fichier protégé sans déranger ce qui y est déjà, voir pourquoi un PDF chiffré ne peut pas être patché en silence en Delphi

Des frontières à connaître avant de compter sur l'identité d'octets

L'identité d'octets tient pour le même document enregistré par la même build du composant, et elle couvre exactement ce que la méthode touche. TPdf.SaveAsReproducible ne retire que les deux dates /Info ; un flux de métadonnées XMP avec son propre xmp:CreateDate ou xmp:ModifyDate passe tel quel avec les octets que le document porte déjà. C'est stable entre enregistrements, mais cela veut dire que les dates XMP et /Info ne décrivent plus la même chose, ce qui compte pour les profils PDF/A qui exigent que les deux concordent ; l'article sur l'équivalence des métadonnées Info et XMP du PDF/A explique ces règles. Un binaire PDFium ou une version du composant différente peut aussi sérialiser les objets autrement, donc épinglez les deux dans tout pipeline qui compare des hachages entre machines

Dans ces limites, la méthode vous donne ce que les diffs de CI, les tests à fichiers de référence et les archives adressées par contenu demandent : même entrée, mêmes octets. TPdf.SaveAsReproducible et TPdfReproducibleReport sont livrés avec le PDFium Component pour Delphi et C++Builder, aux côtés du reste de son API d'enregistrement, d'optimisation et de conformité

FAQ

TPdf.SaveAsReproducible change-t-il le contenu des pages ?

Non. Il retire les clés /CreationDate et /ModDate du dictionnaire /Info, omet le /ID de trailer, et lâche les objets inatteignables depuis le catalogue ou /Info ; le contenu des pages est écrit inchangé

Puis-je produire une sortie reproductible depuis un PDF chiffré ?

Non. Le chiffrement dépend du /ID de trailer, que la sortie reproductible omet, si bien que la méthode renvoie False avec un ErrorMessage explicatif pour les sources chiffrées

Pourquoi un PDF signé demande-t-il AllowSignedDocument ?

L'enregistrement reproductible est une réécriture complète qui bouge les décalages d'octets, si bien que les signatures existantes cessent de correspondre à leurs plages d'octets signées. Passer AllowSignedDocument = True confirme que vous acceptez ce compromis