HotPDF 2.743.0 aplatit les annotations PDF qui ne possèdent aucun flux d’apparence /AP au lieu de les ignorer silencieusement. FlattenLoadedAnnotations fait désormais passer un widget sans apparence par EnsureLoadedFieldAppearanceStream et construit un Form XObject pour les annotations sans apparence à partir de leurs propres propriétés, de sorte que les valeurs saisies dans un formulaire /NeedAppearances survivent dans le contenu de la page au lieu de disparaître lors de l’aplatissement. L’échec qui a imposé ce changement ressemble à un non-op. Un client envoie un formulaire rempli, imprimé en PDF depuis un navigateur. Vous le chargez dans HotPDF, appelez FlattenLoadedAnnotations, obtenez 0, sauvegardez et livrez un document avec des cases vides là où le demandeur avait saisi un nom et un montant. Rien n’a été levé, rien n’a été journalisé. Les valeurs étaient dans le fichier depuis le début, dans l’entrée /V de chaque champ, et la passe d’aplatissement les parcourait sans s’y arrêter parce qu’aucun de ces widgets ne portait de flux d’apparence
Pourquoi l’aplatissement d’un formulaire imprimé par un navigateur fait-il perdre les valeurs saisies ?
Parce qu’un formulaire /NeedAppearances stocke la valeur sans stocker son image. L’ISO 32000-1 12.7.2 permet à un formulaire interactif de définir /NeedAppearances true dans le dictionnaire AcroForm, ce qui indique au viewer de construire à l’ouverture la surface visuelle de chaque champ à partir de /V, /DA et /Q. Les producteurs qui génèrent des formulaires à bas coût — chemins d’impression des navigateurs, remplisseurs côté serveur, certains frontends de numérisation — profitent de cette possibilité et n’écrivent aucun /AP. L’aplatissement, défini par l’algorithme d’apparence de l’ISO 32000-1 12.5.5, est un travail de transcription : prendre le flux d’apparence normale de l’annotation, projeter son /BBox sur son /Rect, l’invoquer depuis le flux de contenu de la page avec un opérateur Do, puis supprimer l’annotation. Sans flux source, il n’y a rien à transcrire. L’implémentation HotPDF d’origine, depuis la version 2.386.0, traitait ce cas comme un « skip », choix défendable isolément mais désastreux en pratique : les documents qui ont le plus besoin d’être aplatis sont ceux qui ont le moins de chances de porter des apparences. La même lacune engloutissait le markup — un Highlight issu d’un outil de revue, un Square provenant d’une passe de redline, une signature Ink — lorsque le producteur comptait sur le viewer pour le dessiner
Où HotPDF branche-t-il la synthèse dans FlattenLoadedAnnotations ?
Le point d’accroche est volontairement tardif : après l’échec de la recherche d’apparence, pas avant. FlattenLoadedAnnotations demande toujours d’abord l’apparence normale à GetLoadedAnnotationAppearanceStream, et une annotation qui en possède déjà une est aplatie exactement comme dans la version 2.386.0. Seul un résultat nil, sur une annotation dont le /Rect n’est pas dégénéré et qui ne porte pas le flag hidden, entre dans le chemin de synthèse. Cet ordre compte : l’auteur d’un document qui a pris la peine d’écrire un /AP récupère ses propres octets, pas une reconstruction HotPDF de ceux-ci
NStrm:= GetLoadedAnnotationAppearanceStream(Indices[PgI], AnI, aakNormal);
if (NStrm= nil) and (RR> RL) and (RT> RB) and ((FlagsValue and 2)= 0) then
begin
if Subtype= 'Widget' then
begin
FieldIdx:= GetLoadedFormFieldIndexForAnnotation(Indices[PgI], AnI, WidgetIdx);
if FieldIdx>= 0 then
EnsureLoadedFieldAppearanceStream(FieldIdx);
// redemander : le générateur a attaché /AP /N au widget
NStrm:= GetLoadedAnnotationAppearanceStream(Indices[PgI], AnI, aakNormal);
end
else
NStrm:= SynthesizeMarkupAppearance(AnnotDict, Subtype, RL, RB, RR, RT);
end;
À partir de là, les deux familles d’annotations se séparent. Un widget est résolu vers son champ propriétaire par GetLoadedFormFieldIndexForAnnotation, puis confié à EnsureLoadedFieldAppearanceStream, le générateur d’apparence de champ présent dans cette bibliothèque PDF Delphi depuis la version 2.328.0. Le réutiliser plutôt que d’écrire un deuxième renderer de champs est tout l’intérêt : il couvre déjà les polices Type0, le retour à la ligne, le quadding, les états /AS des cases à cocher et des boutons radio, ainsi que la rotation /MK, avec le même mécanisme que celui décrit dans l’ajout de champs AcroForm à un PDF déjà chargé. Tout le reste est envoyé au synthétiseur de markup. Pour l’appelant, rien ne change : le même appel d’aplatissement sur une ligne renvoie désormais un compte non nul pour les documents qui renvoyaient autrefois zéro
Doc:= THotPDF.Create(nil);
try
Doc.LoadFromFile('needappearances-form.pdf');
// v2.743.0 : les widgets et annotations sans AP sont synthétisés puis aplatis
Flattened:= Doc.FlattenLoadedAnnotations; // toutes les pages, tous les sous-types
// Flattened:= Doc.FlattenLoadedAnnotations('1-3', 'Highlight');
if Flattened= 0 then
raise Exception.Create('nothing was flattened');
Doc.SaveLoadedDocument('flattened.pdf');
finally
Doc.Free;
end;
Pourquoi QuadPoints et InkList se retrouvent-ils au mauvais endroit ?
Parce que ces coordonnées sont dans l’espace utilisateur de la page, alors que le flux d’apparence synthétisé dessine dans son propre espace /BBox, et que les deux origines ne sont pas le même point. La table 176 de l’ISO 32000-1 définit /QuadPoints pour les annotations de markup textuel dans l’espace utilisateur par défaut, et la table 174 fait de même pour les extrémités /L d’une annotation de ligne ; /InkList suit la même convention. HotPDF donne au formulaire synthétisé un /BBox de [0 0 W H] dont l’origine se trouve dans le coin inférieur gauche du /Rect. Chaque point extrait de /QuadPoints, /L ou /InkList doit donc être translaté par l’opposé du coin inférieur gauche du /Rect avant d’être écrit dans le flux de contenu. Si vous vous trompez, un surlignage situé à 700 points plus haut sur la page est dessiné 700 points au-dessus de sa propre boîte, ce qui signifie en pratique qu’il ne se dessine nulle part. La correction est une soustraction par coordonnée et elle se compose avec le cm émis ensuite par l’aplatissement : cette matrice remappe le /BBox sur le /Rect, si bien que les deux étapes s’annulent pour retrouver la géométrie absolue correcte
// Les extrémités /L sont dans l’espace utilisateur de la page (ISO 32000-1 Table 174) ; l’origine
// du BBox du formulaire est le coin inférieur gauche du /Rect : décaler de -(RL, RB)
X1:= ArrNum(LA, 0, 0)- RL;
Y1:= ArrNum(LA, 1, 0)- RB;
X2:= ArrNum(LA, 2, 0)- RL;
Y2:= ArrNum(LA, 3, 0)- RB;
StrokeOp:= ColorOp(DArr('C'), true);
if StrokeOp= '' then
StrokeOp:= '0 G';
Result:= _FloatToStrR(BW)+ ' w '#10+ StrokeOp+ #10+
_FloatToStrR(X1)+ ' '+ _FloatToStrR(Y1)+ ' m '+
_FloatToStrR(X2)+ ' '+ _FloatToStrR(Y2)+ ' l S'#10;
Que dessine réellement l’apparence de markup synthétisée ?
Le synthétiseur de markup lit uniquement le dictionnaire de l’annotation, ce qui rend la sortie prévisible et honnête sur ce qu’il ne peut pas savoir. FreeText et Stamp dessinent /Contents avec la police et la couleur extraites de /DA, alignés selon /Q, avec un remplissage de 2 points. Square et Circle dessinent un contour re ou un contour de Bézier à quatre arcs, tracé avec /C et rempli avec /IC lorsque celui-ci est présent, avec la largeur de /BS /W. Line et Ink tracent leurs sommets. Highlight remplit chaque quadrilatère, tandis que Underline, StrikeOut et Squiggly tracent un trait au bas du quadrilatère, à son milieu ou en zigzag d’un point. Un /CA inférieur à 1 devient un ExtGState doté d’une entrée ca, référencée par /GSA gs en tête du flux
L’encodage du texte est décidé à partir de l’entrée /DR /Font de l’AcroForm nommée par /DA. Si le /Subtype de cette police est Type0, HotPDF écrit la chaîne comme un littéral hexadécimal UTF-16BE avec la marque d’ordre des octets FEFF ; sinon, il écrit une chaîne littérale échappée, en échappant les parenthèses et les barres obliques inverses et en écrivant en octal les octets supérieurs à 126. L’opérateur Tf de /DA est émis avant BT, ce qui est légal puisque l’état du texte persiste entre les limites de l’objet texte et évite de déconstruire la chaîne /DA. Deux limites doivent être dites clairement. La largeur de ligne pour le retour et le quadding est estimée avec une heuristique de demi-cadratin / cadratin complet plutôt qu’avec les métriques réelles de la police ; l’alignement dans une police proportionnelle est donc proche, mais pas exact. Et un sous-type sans rien de synthétisable — Popup, Link ou un Stamp dont le seul contenu est un nom d’icône — renvoie nil et reste inchangé, exactement comme avant
Le remplacement temporaire de /Annots qui punit un nettoyage bien intentionné
FlattenOneWidget, le chemin par widget utilisé par FlattenLoadedFormFields, est un piège d’aliasing que toute modification de la boucle d’aplatissement partagée doit respecter. Il remplace temporairement la valeur /Annots de la page par un tableau à un élément afin que la passe générique d’aplatissement opère sur un seul widget, puis restaure le pointeur PHPDFDictionaryItem d’origine dans un bloc finally. La restauration réécrit dans un emplacement du dictionnaire capturé avant l’appel
DictItem:= PHPDFDictionaryItem(PageObj.Items.Items[AnnotsIndex]);
Item:= DictItem^.Value;
TemporaryAnnots:= THPDFArrayObject.Create(nil);
TemporaryAnnots.AddObject(Target);
DictItem^.Value:= TemporaryAnnots;
try
Result:= FlattenLoadedAnnotations(IntToStr(PageIndex+ 1), 'Widget')= 1;
finally
DictItem^.Value:= Item; // suspendu si la boucle interne a libéré cet élément
TemporaryAnnots.Free;
end;
Ajoutez un nettoyage d’apparence raisonnable dans la boucle interne partagée — un DeleteValue('Annots') dès que le tableau est vide, afin que la page sauvegardée ne conserve aucun tableau vide vestigial — et cet appel libère précisément l’élément de dictionnaire vers lequel pointe DictItem. Le finally écrit alors via un pointeur suspendu et le processus meurt avec « Invalid pointer operation ». Deux tests existants l’ont détecté immédiatement, seule raison pour laquelle ceci est une note et non un ticket de support. La règle est générale : avant d’ajouter un nettoyage dans une boucle partagée, vérifiez les contrats d’alias ou d’échange chez les appelants. Un tableau /Annots vide résiduel est un défaut cosmétique et ne vaut pas l’abandon d’une garantie de durée de vie du pointeur
Ce qui reste non aplati et le coût de l’aplatissement
Les annotations masquées sont exclues volontairement. Une annotation dont l’entier /F a le bit en position 2 activé est masquée selon l’ISO 32000-1 12.5.3, et lorsqu’elle n’a pas non plus de /AP, la tentation est réelle de lui en synthétiser un puis de l’aplatir comme les autres. Ce serait un bug aux conséquences de sécurité : intégrer une note invisible au contenu de la page la rend visible pour toute personne qui ouvre le fichier. HotPDF laisse ces annotations exactement où elles sont et ne les compte pas dans la valeur de retour. Soyez tout aussi clair avec vos utilisateurs sur le prix de celles qui sont aplaties. L’aplatissement est irréversible — l’annotation est supprimée du tableau /Annots de la page et son visuel devient du contenu de page ; il n’est donc plus possible de modifier la valeur du champ, de conserver un fil de commentaires, de changer l’état /AS ou de récupérer les données structurées autrement qu’à partir du fichier original. Aplatissez une copie, gardez l’original et ne revenez à celui-ci que lorsque le document cesse d’être un formulaire pour devenir un dossier. Si votre problème repose sur XFA plutôt que sur l’absence d’apparence, le chemin de conversion et d’aplatissement XFA vers AcroForm dans HotPDF est le bon point de départ ; et si vous construisez encore le formulaire, les notes sur le câblage des actions et de la validation des champs AcroForm couvrent le chemin d’écriture
Une réserve de vérification, car elle vous coûtera sinon un après-midi. ExtractLoadedPageGlyphs ne descend pas dans les Form XObjects, et une apparence aplatie vit dans l’un d’eux — le flux de contenu de la page ne contient qu’une séquence q ... cm /FlatAn<n> Do Q. L’extraction des glyphes sur une page aplatie ne renvoie donc rien, ce qui est le comportement correct plutôt qu’un aplatissement perdu. Vérifiez soit au niveau des octets, en recherchant le nom de ressource /FlatAn, l’invocation Do et /Subtype /Form, soit via le pipeline de rendu, qui développe bien les XObjects
L’aplatissement des annotations ressemble à trois lignes de transcription jusqu’à ce que l’on rencontre les documents réellement produits. Si vous travaillez avec des formulaires remplis, du markup de revue ou des sorties d’archivage dans Delphi ou C++Builder, il vaut la peine de lire comment le composant PDF HotPDF pour Delphi gère le côté document chargé des AcroForms et des annotations avant de construire votre propre générateur d’apparence par-dessus