Article technique

ID PDF Deterministe en Delphi pour Builds Reproductibles

Générez la même facture deux fois depuis le même modèle et les mêmes données, puis comparez les deux PDF. Deux endroits diffèrent : les chaînes hexadécimales du tableau /ID du trailer et la chaîne ModDate du dictionnaire d'informations du document, tous deux pris de l'horloge. Dans losLab PDF Library, SetDeterministicDocumentID(1) plus un ModDate figé rendent les fichiers identiques octet pour octet, pour un document sans UUID XMP ni pièces jointes par exécution

La première tentative évidente est de figer les dates et de relancer. CreationDate et ModDate concordent désormais, et les hachages diffèrent toujours, désormais seulement dans deux chaînes hexadécimales de 32 chiffres tout à la fin du fichier, à l'intérieur de /ID [<…> <…>]. Cet identifiant est calculé séparément des métadonnées, et tant qu'il ne cesse de dépendre de l'heure de la sauvegarde, un cache de build indexé sur le hachage du fichier n'atteint jamais et un magasin adressable par contenu garde un blob par exécution au lieu d'un par document

À quoi sert le tableau ID du trailer

Le /ID du trailer identifie un fichier ; il ne fait pas la somme de contrôle de son contenu. ISO 32000-1 §14.4 le définit comme un tableau de deux chaînes d'octets. La première est l'identifiant permanent, attribué quand le document est créé et censé survivre à toute édition ultérieure. La seconde est l'identifiant changeant, qu'un rédacteur rafraîchit chaque fois qu'il modifie le fichier. Un système de gestion documentaire compare la paire pour décider si deux fichiers sont des révisions d'un même document ou deux documents sans rapport. La Table 15 du §7.5.5 rend l'entrée obligatoire quand le trailer porte /Encrypt et optionnelle sinon, et §14.4 note que s'en passer peut gêner les flux de travail qui s'appuient sur l'identité de fichier

La spécification ne dit jamais comment calculer la valeur. Elle suggère un condensé MD5 sur des éléments tels que l'heure courante, le chemin du fichier, la taille du fichier et les valeurs du dictionnaire d'informations, et l'horloge est l'ingrédient qui rend le résultat unique. L'unicité est exactement ce que vous voulez d'un marqueur d'identité et exactement ce qui détruit la reproductibilité. Par défaut losLab PDF Library suit cette suggestion sous sa forme la plus simple, un MD5 de l'horodatage courant, si bien que deux documents créés à une seconde d'intervalle reçoivent des identifiants permanents différents même quand tous les autres octets concordent

Diagramme comparant des exécutions de build Delphi identiques dont les IDs de trailer PDF fondés sur l'horloge murale par défaut diffèrent face à une sortie déterministe identique octet par octet
L'id de trailer par défaut plie l'horloge murale dans l'identité du fichier tandis que le mode déterministe le dérive d'une graine stable

Activer un identifiant reproductible

Le mode déterministe est un interrupteur par document et il est désactivé par défaut, si bien que la sortie existante ne change pas tant que vous ne le demandez pas. SetDeterministicDocumentID prend 0 ou 1 et renvoie 1 quand la valeur est acceptée, 0 pour tout le reste ; GetDeterministicDocumentID rapporte le réglage courant. SetDocumentIDSeed fournit une chaîne de graine explicite, et avec une graine en place l'identifiant est un MD5 de cette chaîne, écrit dans les deux éléments du tableau. GetDocumentFileID relit /ID[0] après la sauvegarde, ce qui est pratique pour la journalisation ou pour une assertion de test

var
  Lib: TPDFlib;
  FileID: WideString;
begin
  Lib := TPDFlib.Create;
  try
    Lib.SetDeterministicDocumentID(1);        // juste après le Create
    Lib.SetDocumentIDSeed('invoice-4471-rev3');
    Lib.SetInformation(7, 'D:20260101000000Z');   // CreationDate
    Lib.SetInformation(8, 'D:20260101000000Z');   // ModDate
    Lib.SetOrigin(1);
    Lib.DrawText(100, 700, 'Invoice 4471');
    Lib.SaveToFile('invoice.pdf');
    FileID := Lib.GetDocumentFileID;          // même valeur à chaque exécution
  finally
    Lib.Free;
  end;
end;

L'identifiant est calculé au moment de la sauvegarde. Basculer l'interrupteur tard dans la construction d'un document marche quand même, et une graine changée atteint le fichier à la prochaine sauvegarde complète : sauvegardez avec la graine A, sauvegardez encore avec la graine B, et les deux sorties portent des identifiants différents, tandis que revenir à la graine A vous rend la valeur d'origine

Activez pourtant l'interrupteur immédiatement après la création de l'objet. Polices, images, états graphiques et autres ressources de page reçoivent des noms dans le dictionnaire de ressources de la page, et tant que le mode déterministe est actif ces noms viennent d'un compteur. Tout ce qui est ajouté avant l'interrupteur garde un nom généré aléatoirement, ce qui laisse l'identifiant stable mais change quelques octets dans les flux de contenu à chaque exécution

Pourquoi l'identifiant change-t-il toujours quand aucune graine n'est réglée ?

Parce que le dictionnaire d'informations fait partie du hachage. Laissez la graine vide, ou passez une chaîne vide à SetDocumentIDSeed pour en effacer une, et losLab PDF Library dérive l'identifiant du fichier lui-même : elle fait passer le corps sérialisé par SHA-256 pendant qu'elle l'écrit et utilise les 16 premiers octets du condensat pour les deux éléments de /ID. Tout changement du contenu de page change l'identifiant, et c'est bien là tout l'intérêt. Le hic, c'est que CreationDate et ModDate vivent aussi dans ce corps, et les routines de sauvegarde estampillent ModDate de l'heure courante tant que l'appelant ne l'a pas réglée

PDF Library for Delphi : flux de décision montrant SetDocumentIDSeed prioritaire sur la graine dérivée de l'état du document utilisée pour les IDs PDF déterministes
Une graine explicite passe devant tout tandis que la voie dérivée hache les entrées du dictionnaire d'informations, y compris CreationDate et ModDate
Lib.SetDeterministicDocumentID(1);
Lib.SetDocumentIDSeed('');   // dérive le /ID des octets sauvegardés
// Le hachage du corps couvre le dictionnaire Info, si bien que les dates doivent
// être des entrées de build. La clé 8 empêche aussi la sauvegarde d'estampiller ModDate.
Lib.SetInformation(2, 'Quarterly Report');        // Titre
Lib.SetInformation(5, 'reporting-service 4.2');   // Créateur
Lib.SetInformation(7, 'D:20260101000000Z');       // CreationDate
Lib.SetInformation(8, 'D:20260101000000Z');       // ModDate
Lib.SaveToFile('report.pdf');

La clé 8 fait double emploi ici, et c'est la ligne que les gens oublient le plus souvent. Régler ModDate via SetInformation marque la date comme fournie par l'appelant, et à partir de là SaveToFile et SaveToString la laissent tranquille. Sans elle chaque sauvegarde écrit un horodatage frais, le corps haché change, et l'identifiant dérivé du contenu change avec lui. Prenez les dates depuis l'enregistrement source ou depuis une époque figée. Now n'a rien à faire dans un build reproductible

Quelques éléments sont tenus hors du hachage à dessein. Les noms de ressources générés aléatoirement et l'UUID d'une entrée XMP xmpMM:InstanceID sont normalisés avant le hachage, si bien que deux exécutions qui ne diffèrent que par ces tokens reçoivent le même /ID. Les octets du fichier diffèrent toujours à ces endroits, si bien qu'un document de style PDF/A avec un UUID d'instance frais par exécution vous donne un identifiant reproductible mais pas un fichier identique octet pour octet. Les pièces jointes intégrées sont une autre source de dérive : AddAssociatedFileFromString estampille le /ModDate propre de la pièce jointe de l'heure courante, et cette date est hachée comme tout autre octet du corps

Graine explicite ou hachage du contenu

Pour des artefacts de build nous laisserions la graine vide. Un identifiant dérivé du contenu change exactement quand les octets changent, si bien qu'un cache indexé sur lui ne peut jamais servir un fichier périmé, et vous n'avez pas de seconde clé à garder en cohérence avec les données. Une graine explicite convient à un autre travail : une archive en aval qui traite /ID[0] comme identité de document et attend le même identifiant entre les régénérations d'une même facture, même après que le modèle a changé. Cela colle mieux à l'intention du §14.4, au prix que deux fichiers visiblement différents puissent partager un identifiant

Une conséquence de la voie contenu mérite une décision délibérée. Quand vous chargez un PDF existant et le sauvegardez en entier avec le mode déterministe actif, le /ID entrant est jeté et le nouveau ne dépend que de ce qui est écrit. Deux fichiers sources qui ne diffèrent que par leurs identifiants de trailer sortent avec le même /ID. C'est ce que veut une étape de build normalisante. Si vous éditez le document d'un client et que son identité doit survivre, laissez le mode déterministe désactivé pour ce travail ou sauvegardez-le de façon incrémentale

Pourquoi réécrire l'ID casse-t-il un PDF chiffré ?

Parce que dans un document chiffré /ID[0] est du matériel de clé. ISO 32000-1 §7.6.3.3 Algorithme 2 fait entrer le premier élément de l'identifiant de fichier dans le calcul de la clé de chiffrement pour le gestionnaire de sécurité standard aux révisions 2 à 4, avec le mot de passe complété, la valeur /O et les bits de permission. Cette clé produit ensuite la chaîne /U qu'un lecteur vérifie à l'ouverture. La clé est dérivée quand vous appelez Encrypt ou chargez un fichier chiffré, avant toute sauvegarde, si bien que remplacer l'identifiant pendant la sauvegarde produirait un fichier bien formé dont la vérification de mot de passe échoue pour tout le monde, vous compris

losLab PDF Library rafraîchit donc l'identifiant déterministe seulement sur les sauvegardes complètes de documents non chiffrés. Un document chiffré garde le /ID qu'il avait déjà, et l'interrupteur n'a aucun effet sur cette voie. Rouvrir un tel fichier avec le mot de passe utilisateur marche comme d'habitude. Ne mettez pas non plus de sortie chiffrée dans une comparaison d'octets : le chiffrement AES du §7.6.2 démarre chaque chaîne et flux chiffrés avec un vecteur d'initialisation aléatoire de 16 octets, si bien que deux chiffrements du même document ne concordent jamais octet pour octet. Si vous avez besoin d'un contrôle de régression là, déchiffrez les deux et comparez ce que vous récupérez. La gestion des révisions et les bits de permission sont approfondis dans la visite guidée de l'audit du chiffrement et des permissions PDF

PDF Library for Delphi : carte des garde-fous de l'ID PDF déterministe couvrant les sauvegardes complètes, les documents chiffrés, les mises à jour incrémentielles et la voie de restauration du chiffrement
Seules les sauvegardes complètes non chiffrées peuvent rafraîchir l'id, puisque les fichiers chiffrés traitent /ID[0] comme du matériel de clé et que les révisions ajoutées doivent rester liées

Les sauvegardes incrémentielles gardent l'identifiant d'origine

Une mise à jour incrémentielle laisse chaque octet antérieur du fichier en place et ajoute une nouvelle révision après lui. La permanence de /ID[0] est ce qui dit à un consommateur que la révision ajoutée appartient au même document que celles d'avant. La réécrire contredirait les révisions déjà dans le fichier et saboterait les flux de signature, puisqu'une signature couvre une plage d'octets d'une révision précise. Le mode d'ajout ne touche jamais l'identifiant en mode déterministe, ce qui garde intact le comportement décrit dans l'article sur les mises à jour incrémentielles PDF et l'ajout au flux

Garder la sortie reproductible dans le temps

Chaque changement ultérieur du générateur est une occasion de remettre une valeur d'horloge dans le fichier : un filigrane qui imprime l'heure de génération, un chemin de code qui joint un fichier, une option de sauvegarde différente. Les options de sauvegarde comptent autant que le contenu : comprimer les objets en flux d'objets compressés produit une disposition de fichier entièrement différente de la table de références croisées classique, comme l'expliquent les notes sur les flux d'objets et les flux de références croisées, alors gardez ces réglages figés par pipeline. La défense la moins chère est un test qui construit deux fois le même document en mémoire et compare les octets

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;

// Deux builds indépendants doivent donner une seule séquence d'octets
if BuildQuote('quote-8812') = BuildQuote('quote-8812') then
  WriteLn('reproducible')
else
  WriteLn('nondeterminism leaked into the output');

Le même harnais peut comparer une exécution sérielle et une parallèle de RecompressStreams sur la même source : chargez-la dans deux instances de TPDFlib, activez les IDs déterministes et figez ModDate des deux côtés, réglez SetOptimizationWorkers(1) sur l'une et un nombre de workers plus élevé sur l'autre, et comparez les deux résultats de SaveToString. L'horloge retirée des deux fichiers, toute différence restante vient de la recompression elle-même

Les identifiants de trailer reproductibles, le contrôle des métadonnées et la sauvegarde incrémentielle font tous partie de losLab PDF Library for Delphi et C++Builder, dont la référence liste aussi les options de chiffrement mentionnées ici