Article technique

Apparences d'annotations : aller-retour Delphi avec PDFium

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

Schéma de l'aller-retour d'annotations de PDFium Component où l'ajout de afPrint via TPdf.Annotation[] et SetAnnotationData écrit aussi des flux /R et /D vides via FPDFAnnot_SetAP, transformant un dictionnaire d'apparence PDF/A propre en un que veraPDF rejette jusqu'à ce que la v3.121.1 ne rapporte que les apparences réellement lues
Lire une annotation et la réécrire sans modification ajoutait des flux d'apparence rollover et down vides, et c'est cela qui fait échouer PDF/A, pas le drapeau Print que vous vouliez ajouter

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

Le dictionnaire d'apparence d'annotation d'ISO 32000-1 avec les entrées normal, rollover et down : PDFium renvoie 2 octets pour un flux manquant comme pour un flux vide existant, donc les deux se relisent comme sans contenu via TPdf, tandis que seul un contrôle au niveau octet comme TPdf.ValidatePdfA trouve le flux vide que le PDF/A interdit
Un /R manquant retombe sur /N ; un /R vide peint un rectangle blanc et fait quand même échouer le PDF/A, et via l'enregistrement les deux sont indiscernables

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 FPDFAnnot_GetAP rapporte un flux d'apparence manquant dans PDFium : le schéma en deux appels renvoie toujours au moins deux octets pour le terminateur UTF-16, l'ancien garde comparant à SizeOf(FPDF_WCHAR) passait à chaque appel et levait toutes les sentinelles HasAppearance, et le garde de la v3.121.1 exige plus que le terminateur plus un compte d'octets pair
Deux octets, c'est la chaîne vide encodée, pas la preuve qu'une apparence existe ; le getter corrigé traite tout ce qui est à la longueur du terminateur ou en dessous comme sans contenu et la réécriture reste silencieuse

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