Dans PDFium Component avant la v3.121.1, lire une annotation via TPdf.Annotation[] et réassigner l'enregistrement pouvait ajouter des entrées /R et /D vides à son dictionnaire d'apparence /AP, même quand l'original ne portait que /N. Les validateurs PDF/A rejettent ce dictionnaire. Depuis la v3.121.1, le getter ne rapporte qu'une apparence qu'il a réellement lue, si bien qu'un aller-retour sans modification n'écrit rien de nouveau. La défaillance mérite qu'on la comprenne en détail, parce que le déclencheur habituel est une correction censée rendre un fichier plus conforme, pas moins
Qu'est-ce qui tourne mal quand on réécrit une annotation inchangée ?
La réponse courte : l'annotation gagne des flux d'apparence qu'elle n'a jamais eus, et un fichier qui passait la validation PDF/A avant votre édition l'échoue après. Le scénario typique se déroule ainsi. Une archive client arrive avec des annotations carré et texte dépourvues du drapeau Print, le PDF/A exige que chaque annotation imprime, donc vous bouclez sur les pages, ajoutez afPrint, et réassignez chaque enregistrement. Rien dans ce code ne touche aux apparences. L'enregistrement de TPdf.Annotation[] est un TPdfAnnotation, et SetAnnotationData écrit chaque champ dont la sentinelle Has* est levée, c'est exactement ainsi que les paires HasContents / ContentsText sont censées fonctionner. Le problème, c'est que le getter mettait HasAppearanceRollover et HasAppearanceDown à True avec des chaînes vides pour des modes qui n'existaient pas, et le setter écrivait docilement deux flux vides :
procedure MarkAnnotationsPrintable(const FileName: string);
var
Pdf: TPdf;
PageNo, I: Integer;
A: TPdfAnnotation;
begin
Pdf := TPdf.Create(nil);
try
Pdf.FileName := FileName;
Pdf.Active := True;
for PageNo := 1 to Pdf.PageCount do
begin
Pdf.PageNumber := PageNo;
for I := 0 to Pdf.AnnotationCount - 1 do
begin
A := Pdf.Annotation[I];
if not (afPrint in A.Flags) then
begin
A.Flags := A.Flags + [afPrint] - [afHidden, afInvisible, afNoView];
// Avant la v3.121.1, cette assignation écrivait aussi des /AP/R et
// /AP/D vides quand l'annotation source ne portait que /AP/N
Pdf.Annotation[I] := A;
end;
end;
end;
Pdf.SaveAs(ChangeFileExt(FileName, '.printable.pdf'));
finally
Pdf.Free;
end;
end;
ISO 32000-1 §12.5.5 définit le dictionnaire d'apparence avec trois entrées : /N pour l'apparence normale, /R pour le survol (rollover), et /D pour l'enfoncé (down). /R et /D sont optionnelles, et quand elles sont absentes, une visionneuse retombe sur /N. Un flux /R vide n'est toutefois pas absent. C'est un flux valide qui ne peint rien, si bien qu'une visionneuse qui honore les apparences de survol affiche un rectangle blanc dès que le pointeur passe sur l'annotation. Le PDF/A est plus strict encore : ISO 19005-1 (avec le Corrigendum 2) et ISO 19005-2 / 19005-3 ne permettent que /N dans un dictionnaire d'apparence d'annotation. veraPDF signale le fichier ayant fait l'aller-retour sous la règle 6.5.3-4 pour le PDF/A-1 et la règle 6.3.3-2 pour le PDF/A-2 et le PDF/A-3, et le TPdf.ValidatePdfA intégré le liste comme pvaiAnnotationApDictViolation. L'édition qui ajoutait le drapeau Print pour satisfaire une clause de la norme en a cassé une autre
Pourquoi FPDFAnnot_GetAP renvoie-t-il 2 pour une apparence manquante ?
PDFium ne renvoie jamais zéro depuis FPDFAnnot_GetAP, même quand le flux d'apparence demandé n'existe pas. La fonction suit le schéma habituel de PDFium en deux appels : passer un tampon nil pour obtenir la taille requise en octets, allouer, puis rappeler pour copier le texte UTF-16LE. La taille inclut toujours le terminateur UTF-16, si bien qu'un flux manquant rapporte 2 octets, une chaîne vide plus son terminateur. Le getter d'avant la v3.121.1 testait ByteLength >= SizeOf(FPDF_WCHAR), un contrôle que chaque appel passe, si bien que les trois drapeaux HasAppearance* revenaient True pour toute annotation ayant une apparence quelconque. Un aller-retour par l'enregistrement demandait alors à FPDFAnnot_SetAP de stocker une chaîne vide pour chaque mode, et PDFium créait le flux pour la porter. Aucune exception, aucun avertissement, et la page visible était identique, voilà pourquoi le défaut est apparu dans une fixture veraPDF plutôt que dans une visionneuse
Comment la v3.121.1 décide qu'une apparence existe
ReadAppearance, le helper interne à GetPageAnnotation qui remplit AppearanceNormal, AppearanceRollover et AppearanceDown, ne traite désormais un résultat comme du contenu que s'il porte au moins un caractère au-delà du terminateur. Le premier appel doit renvoyer plus de SizeOf(FPDF_WCHAR) octets et un nombre d'octets pair, puisque une longueur impaire ne peut pas être de l'UTF-16. Le second appel, qui copie réellement le texte, est validé à nouveau : une longueur renvoyée de 2 ou moins, ou une plus grande que le tampon alloué, remet HasValue à False et laisse la chaîne vide. Côté écriture, rien n'a changé. SetAnnotationData appelle toujours FPDFAnnot_SetAP uniquement pour les modes dont le drapeau HasAppearance* est True, si bien qu'un enregistrement lu depuis une annotation qui ne porte que /N ne réécrit désormais que /N. La fixture de régression couvre les deux sens : une annotation carré avec une apparence normale, lue et réécrite sans modification, passe en PDF/A-1b, PDF/A-2b et PDF/A-3b, tandis que la même annotation privée de son drapeau Print échoue sur la règle du drapeau attendu et sur rien d'autre
Flux manquants et flux vides se ressemblent, donc le getter reste conservateur
L'API native ne sait pas distinguer un flux d'apparence manquant d'un flux qui existe mais est vide, et PDFium Component ne prétend pas le contraire. Les deux cas renvoient les mêmes 2 octets depuis FPDFAnnot_GetAP, donc les deux se relisent comme HasAppearanceRollover = False avec un AppearanceRollover vide. Cela a deux conséquences autour desquelles vous devez concevoir. Un, une sentinelle False signifie « aucun contenu n'a été lu, donc une réécriture laissera ce mode tranquille », pas « la clé /R est absente du dictionnaire ». Deux, l'enregistrement ne peut pas détecter un flux vide déjà présent dans le fichier : un document abîmé par un build plus ancien ou par un autre outil se relit proprement, et réassigner l'enregistrement ne le répare ni ne l'aggrave. Pour trouver ces fichiers, il faut un contrôle au niveau octet, et c'est à cela que servent TPdf.ValidatePdfA et le flux de validation préalable PDF/A avec PDFium Component
Comment effacer une apparence exprès ?
Vous levez la sentinelle explicitement et passez une chaîne vide ; le setter l'écrit. Interdire les chaînes vides dans SetAnnotationData aurait été la correction brutale pour ce bogue, mais elle aurait aussi cassé les appelants qui effacent une apparence délibérément, le même contrat que suivent HasContents et HasAuthor pour le texte. La correction vit donc entièrement dans le getter, et le setter continue d'honorer ce que l'appellant demande :
// Remplacez l'apparence de survol, puis effacez-la à nouveau
A := Pdf.Annotation[0];
A.HasAppearanceRollover := True;
A.AppearanceRollover := 'q Q';
Pdf.Annotation[0] := A;
A := Pdf.Annotation[0];
// A.HasAppearanceRollover est True et le texte fait l'aller-retour en 'q Q'
A.HasAppearanceRollover := True; // réaffirmez l'intention explicitement
A.AppearanceRollover := ''; // écrivez un flux vide exprès
Pdf.Annotation[0] := A;
A := Pdf.Annotation[0];
// Se relit comme HasAppearanceRollover = False avec une chaîne vide :
// un flux vide et un flux manquant sont indiscernables ici
Gardez en tête qu'un /R ou /D explicitement vidé compte toujours comme une clé en trop sous les règles PDF/A citées plus haut. Si la cible est un profil d'archivage, écrire un /N non vide et laisser les deux autres modes intacts est la seule forme qui valide. Tout flux de travail qui déplace des annotations entre documents, comme l'export et l'import XFDF avec PDFium Component, devrait suivre la même règle : copiez les modes que la source portait réellement et laissez les autres sentinelles à False
Un schéma lire-modifier-écrire qui reste compatible PDF/A
Passez à la v3.121.1 ou plus récent, laissez les sentinelles d'apparence exactement comme le getter les a renvoyées, et validez le fichier sauvegardé avant de l'expédier. Parce qu'un flux vide résiduel se relit comme absent, l'étape de vérification doit regarder le document sérialisé plutôt que l'enregistrement, et elle est assez bon marché pour tourner après chaque lot :
uses
PDFium, FPdfPdfa; // FPdfPdfa déclare TPdfAValidationIssue
function AnnotationAppearancesAreClean(Pdf: TPdf): Boolean;
var
Report: TPdfAValidationResult;
begin
// Valide le document actuellement chargé dans Pdf, y compris les éditions
// faites via Pdf.Annotation[] depuis son ouverture
Report := Pdf.ValidatePdfA;
Result := not (pvaiAnnotationApDictViolation in Report.Issues);
end;
La même discipline vaut pour tout panneau qui recolore ou annote des pages pour revue, un flux de travail couvert dans construire un flux de revue d'annotations Delphi avec PDFium Component : l'enregistrement est un instantané de ce que le moteur a pu lire, et une sentinelle que vous n'avez pas levée vous-même doit voyager en retour inchangée. L'API d'annotation complète, le précontrôle PDF/A et le moteur PDFium natif voyagent ensemble dans PDFium Component pour Delphi, C++Builder et Lazarus