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
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
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
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