PDF Library for Delphi v3.539.18 et v3.539.20 corrigent deux façons dont une sauvegarde PDF qui ne change rien pouvait quand même corrompre les métadonnées du document : quand /CreationDate et /ModDate référençaient le même objet chaîne, la mise à jour automatique de ModDate réécrivait les deux, et quand l'objet XMP était créé avant que le flux /Metadata d'origine ne soit lu, un paquet par défaut remplaçait l'original. Les correctifs remplacent des références de dictionnaire au lieu de muter des objets partagés, et capturent le paquet existant avant l'initialisation paresseuse de XMP
L'opération est la moins intéressante qu'une bibliothèque PDF puisse faire : charger un fichier, l'enregistrer sous un nouveau nom, ne rien toucher entre les deux. Les pages se rendaient à l'identique avant et après. Les hachages des flux de contenu concordaient. Le fichier passait tous les contrôles dont nous disposions, et il était quand même faux à deux endroits qu'aucun moteur de rendu ne vous montrerait jamais. Les deux défauts se trouvaient dans le chemin lecture-modification-écriture par lequel passe toute édition réelle, donc n'importe quelle sauvegarde suffisait à les déclencher, et les deux n'ont été trouvés que quand un second analyseur indépendant a comparé les sémantiques non visuelles des deux fichiers
Pourquoi une sauvegarde PDF change-t-elle sa CreationDate ?
Parce que le dictionnaire d'informations du document a le droit de référencer un même objet chaîne indirect depuis deux clés, et que la bibliothèque mettait à jour l'objet plutôt que la clé. ISO 32000-1 §7.3.10 autorise n'importe quelle valeur de dictionnaire à être une référence indirecte, et rien dans la Table 317 de §14.3.3 n'exige que la valeur sous /CreationDate soit un objet différent de la valeur sous /ModDate. Un producteur qui a écrit deux fois le même horodatage à la création peut, en toute légalité, pointer les deux clés sur un seul 2728 0 R, ce que faisait exactement un document de conception CJK de notre corpus local
Le déclencheur est la date de modification automatique. Sauf si UserModDate est défini, SaveToFile appelle SetInfo('ModDate', ...) avec l'heure courante avant d'écrire, ce qui atteint SetRawInfo. L'ancien SetRawInfo cherchait l'objet sous la clé et, s'il trouvait un TPDFString, appelait SetTo dessus. C'est une écriture en place dans l'objet que la clé résout à cet instant, et quand cet objet est partagé, /CreationDate rapporte désormais aussi l'heure de sauvegarde. Le document s'ouvre toujours, s'imprime et se rend pixel pour pixel comme avant, donc une suite de régression visuelle passe sans broncher
var
Lib: TPDFlib;
Before, After: WideString;
begin
Lib := TPDFlib.Create;
try
Lib.LoadFromFile('design.pdf', '');
Before := Lib.GetInformation(7); // 7 = CreationDate, 8 = ModDate
Lib.SaveToFile('design-resaved.pdf');
Lib.LoadFromFile('design-resaved.pdf', '');
After := Lib.GetInformation(7);
if Before <> After then
Log('a save that changed nothing rewrote CreationDate');
finally
Lib.Free;
end;
end;
Le correctif dans TPDFDocument.SetRawInfo est petit et le principe derrière est général : mettre à jour une entrée de dictionnaire remplace la référence de cette entrée, jamais l'objet qu'elle résolvait par hasard. Le nouveau code lit le TPDFStringMode existant pour qu'une chaîne hexadécimale reste hexadécimale et qu'une chaîne littérale reste littérale, puis ajoute une chaîne neuve issue de FStructure.NewString(Value, StringMode) sous la clé. Deux autres détails comptent autant que le changement principal. L'ancienne branche pour une entrée de type flux vidait le flux avec SetTo('') avant de le remplacer, ce qui aurait vidé la valeur pour toutes les autres clés pointant encore sur ce flux, donc ce vidage a disparu. Et l'objet remplacé n'est pas supprimé, parce que la structure le possède et que d'autres références peuvent encore en avoir besoin
// Avant : muter l'objet que la clé résout à cet instant
if Obj is TPDFString then
TPDFString(Obj).SetTo(Value);
// Après : garder la représentation, ne remplacer que la référence de cette clé
StringMode := smLiteral;
if Obj is TPDFString then
StringMode := TPDFString(Obj).StringMode;
ID.Add(Key, FStructure.NewString(Value, StringMode));
La régression dans Tests\SharedInfoSemantics.inc construit l'aliasing délibérément plutôt que de compter sur un fichier du corpus : une chaîne hexadécimale référencée par les deux clés de date, une chaîne directe partagée par /Title et /Subject, un flux partagé par /Author et /Keywords. Après mise à jour d'une clé de chaque paire, l'autre doit toujours lire sa valeur d'origine et la chaîne mise à jour doit toujours être hexadécimale. La référence publique de SetInformation énonce désormais la garantie en une phrase : mettre à jour un champ Info ne remplace que ce champ, même quand d'autres champs référencent le même objet
Pourquoi un paquet XMP existant est-il remplacé par les valeurs par défaut ?
À cause de l'ordre de deux lignes. TPDFDocument.GetMetadata a un chemin rapide : quand le champ XMP est déjà affecté, il renvoie XMP.SaveToString au lieu de décoder le flux /Metadata du catalogue. Plusieurs sites d'appel initialisaient paresseusement avec XMP := TPDFlibXMP.Create; XMP.LoadFromString(GetMetadata);, ce qui se lit naturellement et qui est faux : au moment où GetMetadata s'exécute, XMP est affecté, donc la « source » chargée est le paquet par défaut sérialisé d'un objet créé une ligne plus tôt. Le paquet d'origine, avec son dc:creator, ses espaces de noms personnalisés et son éventuelle identification de norme, n'atteint jamais l'objet et est écrasé à la sauvegarde. La même date de modification automatique suffit à le déclencher, parce que SetInfo initialise XMP avant de toucher au dictionnaire Info pour que xmp:ModifyDate reste en phase avec /ModDate. Notez ce derrière quoi ce défaut se cache : la comparaison du dictionnaire Info du premier bug passe, puisque /Author et /Title dans /Info ne sont pas touchés. Seul l'arbre XMP a changé, et seul un contrôle qui analyse et compare cet arbre le remarque
// Faux : GetMetadata sérialise maintenant l'objet créé à la ligne précédente
XMP := TPDFlibXMP.Create;
XMP.LoadFromString(GetMetadata);
// Juste : capturer le flux /Metadata d'abord, puis créer et charger
Source := GetMetadata;
XMP := TPDFlibXMP.Create;
XMP.LoadFromString(Source);
Le correctif fait deux choses. TPDFDocument.EnsureXMP capture désormais Source := GetMetadata avant TPDFlibXMP.Create, et chaque initialisation paresseuse du document a été remplacée par un appel à cette méthode : SetInfo, SetXMPInformation, GetXMPInformation, les setters de mode PDF/A, PDF/X, PDF/E, PDF/VT, PDF/VCR et PDF/UA, et le chemin de réparation des métadonnées. Les points d'entrée publics comme SetXMPProperty passaient déjà par EnsureXMP, et GetXMPProperty lit via GetDocumentMetadata, donc toute la surface partage un seul ordre d'initialisation. Une seule copie correcte d'une séquence de trois lignes vaut mieux que dix copies qui se trouvent d'accord aujourd'hui
Deux pièges plus petits trouvés sur le même chemin
Le sérialiseur XMP sous Windows utilise l'écrivain XML de la plateforme, qui émet une déclaration XML que le paquet ne doit pas porter. L'ancien code la supprimait en effaçant des caractères jusqu'à atteindre <?xpacket. ISO 16684-1 §7.3.2 rend le wrapper xpacket facultatif, et un producteur qui écrit un simple élément <x:xmpmeta> reste dans la norme, donc sur un tel paquet la boucle supprimait le document entier, qui était valide. Le sérialiseur localise maintenant le ?> fermant de la déclaration et ne retire que cela. Tests\XMPRetentionSemantics.inc exécute son contrôle de rétention deux fois, une avec le wrapper et une avec le wrapper retiré, et vérifie qu'un marqueur d'espace de noms personnalisé et l'auteur d'origine survivent à SetInfo, GetMetadata, SaveToString et à un rechargement. Le second piège était un symbole de préprocesseur : la synchronisation Info vers XMP dans SetInfo était gardée par NOVCL, qui est défini pour les compilations Free Pascal, alors que le backend XMP est conditionné par le système d'exploitation, pas par le framework, puisque PDFlibXMP.pas ne définit NO_XMP que lorsque OS_WINDOWS est absent. Une compilation Lazarus Windows avait donc un objet XMP fonctionnel et un SetInfo qui sautait silencieusement sa mise à jour. Le garde est désormais NO_XMP, si bien qu'une application Free Pascal Windows obtient la même synchronisation que Delphi
Comment garder la ModDate d'origine sur une sauvegarde en transit ?
Définissez KeepModDate dans TPDFlibSaveOptions et enregistrez via SaveToFileOptions. L'option définit UserModDate pour la durée de l'appel, et SaveToFile saute alors l'horodatage automatique, qui est aussi l'étape qui initialise paresseusement l'objet XMP. Un document dont vous n'avez jamais touché les métadonnées, et pour lequel aucun mode de conformité n'a été activé, garde son dictionnaire Info et son flux /Metadata tels que chargés. Appeler SetInformation(8, ...) a le même effet de façon permanente, puisque définir vous-même la date de modification la marque comme contrôlée par l'utilisateur
var
Options: TPDFlibSaveOptions;
begin
FillChar(Options, SizeOf(Options), 0);
Options.OptimizeContentStreams := True;
Options.PackObjectStreams := True;
Options.KeepModDate := True; // pas de /ModDate automatique, pas d'init XMP paresseuse
if Lib.SaveToFileOptions('design-resaved.pdf', Options) <> 1 then
Log(Format('save failed, LastErrorCode=%d', [Lib.LastErrorCode]));
end;
Soyez honnête sur ce que cela vous achète. KeepModDate est le bon choix pour une étape de transit dont la sortie doit décrire la même révision que son entrée, et c'est le mauvais choix pour tout ce qui modifie réellement du contenu, parce que §14.3.3 attend de /ModDate qu'elle reflète la modification la plus récente. Cela ne répare pas non plus rétroactivement une bibliothèque qui mute des objets partagés ; cela évite seulement la seule écriture qui exposait le défaut. Les deux correctifs ci-dessus sont ce qui rend une sauvegarde ordinaire sûre, et l'option est ce qui rend un no-op délibéré honnête
Comment vérifier qu'une sauvegarde ne change rien d'autre que la ModDate ?
Pas avec les pixels et pas avec les hachages de flux, parce que les deux défauts laissent chaque page et chaque flux de contenu identiques octet pour octet. Le contrôle qui les a attrapés est un instantané sémantique non visuel pris par un analyseur indépendant, qui ne partage aucun code avec la bibliothèque testée, depuis le fichier source et depuis le fichier enregistré, suivi d'une comparaison structurelle. L'instantané couvre le dictionnaire Info sans /ModDate, l'arbre de signets avec chaque signet résolu en numéro de page plutôt qu'en numéro d'objet, les destinations nommées et les cibles de liens résolues de la même façon, les valeurs des champs de formulaire, les octets des pièces jointes sous forme de hachages, et le paquet XMP analysé comme un arbre plutôt que comparé comme du texte. Les numéros d'objet n'en font délibérément pas partie, puisqu'une réécriture complète renumérote tout et qu'une comparaison basée sur eux ne rapporterait que du bruit
Les exclusions comptent autant que les inclusions. /ModDate, xmp:ModifyDate et xmp:MetadataDate sont censées changer et sont écartées avant la comparaison ; un fichier dont la source ne portait aucun XMP n'est pas pénalisé pour avoir gagné un paquet. Ce que le contrôle ne prétend pas est tout aussi explicite : conserver un paquet existant ne dit rien sur la validité de ce paquet au regard du schéma, ni sur le fait que le document respecte PDF/UA ou une partie PDF/A. Ce sont des questions séparées avec des outils séparés, et confondre « les métadonnées ont survécu » avec « les métadonnées sont conformes » est exactement ce qui a permis au premier bug de se cacher aussi longtemps. Côté bibliothèque, les deux régressions tournent désormais à chaque passe ciblée sur Delphi Win32 et Win64 et sur Free Pascal Win32 et Win64, et la comparaison sémantique est une condition de réussite du benchmark sur le corpus de documents réels
Si vous travaillez au niveau en dessous de ces correctifs, la mécanique de la réécriture des objets lors d'une sauvegarde est traitée dans les mises à jour incrémentales et la sauvegarde en ajout seul, qui est le seul mode de sauvegarde où un objet partagé reste simplement là où il était, et dans les niveaux de modification et le diff de révision, qui est l'autre endroit où une date périmée ou réécrite trompe un lecteur. La vue côté réparation de ce même couple Info et XMP, où les deux moitiés sont amenées à s'accorder plutôt que simplement préservées, se trouve dans la conversion en PDF/A et la réparation des métadonnées
PDF Library for Delphi est une bibliothèque PDF Pascal native pour Delphi, C++Builder et Lazarus, et le chemin lecture-modification-écriture décrit ici est celui par lequel passe chaque édition dans votre propre processus, donc les garanties ci-dessus s'appliquent que vous enregistriez une fois ou mille fois par jour — voyez la page produit PDF Library for Delphi pour les compilateurs et plateformes pris en charge