HotPDF Delphi Component stocke chaque nombre PDF dans THPDFNumericObject.Value, déclaré comme Single, et un Single ne peut pas détenir tout entier 32 bits. Les drapeaux de champs AcroForm (/Ff) sont un masque 32 bits, si bien que tout masque avec un bit haut posé était arrondi au chargement : $8000FFFF revenait en $80010000. Depuis la v2.989.5, les lectures de drapeaux de champs analysent le token entier dorigine exactement et copient ses bits dans lAPI Integer existante
Le bug mérite un examen attentif même si vous ne touchez jamais aux formulaires PDF. Rien ne plantait, aucune exception nétait levée, et la plupart des fichiers de test passaient, parce que la plupart des masques réels sont petits. La défaillance napparaissait qu quand un bit haut et les drapeaux communs bas étaient présents ensemble, et elle ne perdait alors pas un bit, elle en réécrivait plusieurs à la fois. Stocker un masque de bits dans un type à virgule flottante est un piège Delphi classique, et voici à quoi il ressemble en code de production
Pourquoi un Single perd-il des bits de drapeaux de champs AcroForm ?
Un Single Delphi a une mantisse de 24 bits, si bien quil représente exactement tout entier jusquà 2^24 (16777216) et rien au-delà sans trous. Entre 2^31 et 2^32, les valeurs Single consécutives sont espacées de 256. Un masque de drapeaux de champ avec le bit 32 posé vit exactement dans cette plage, si bien que ses huit bits bas, et souvent davantage, sont arrondis au multiple de 256 le plus proche
ISO 32000-1 §7.3.3 définit les objets entiers et réels comme des sortes de nombres séparées, et lAnnexe C liste des limites dimplémentation différentes pour chacun, si bien quun entier PDF na aucune obligation de tenir dans un float 32 bits. Lentrée des drapeaux de champ est définie dans la Table 221 comme un entier, et elle se lit bit à bit, pas comme une quantité. Arrondir une quantité dune infime fraction est sans danger ; arrondir un masque se propage vers les bits voisins. Vous pouvez reproduire tout leffet en quelques lignes de Delphi simple :
var
S: Single;
Mask: Int64;
begin
S := $8000FFFF; // 2147549183, non représentable en Single
Mask := Round(S); // 2147549184
Writeln(IntToHex(Mask, 8)); // 80010000 : bits 1-16 effacés, bit 17 posé
S := $80003FFF;
Mask := Round(S);
Writeln(IntToHex(Mask, 8)); // 80004000 : bits 1-14 effacés, bit 15 posé
end;
La leçon se généralise au-delà du PDF. Un Single, un Double, un Variant contenant un float, ou un nombre JSON décodé en float est le mauvais conteneur pour un champ de bits, même quand les valeurs que vous testez par hasard sont petites. Double pousse la plage exacte jusquà 2^53, ce qui cache le problème pour les masques 32 bits mais laisse quand même le type exprimer la mauvaise intention. Employez Cardinal, Integer ou Int64 et convertissez depuis le texte source, pas depuis un float déjà arrondi
Quels drapeaux de champs basculent : Radio, Pushbutton, Password et Multiline
Les deux masques ci-dessus ne sont pas arbitraires ; ils montrent comment une seule étape darrondi change le type de champ quun lecteur voit. Le PDF numérote les bits de drapeaux à partir de 1, si bien que le bit n vaut 2^(n-1). ISO 32000-1 §12.7.3.1 Table 226 définit les bits 1 à 3 pour chaque champ, la Table 228 définit les drapeaux de boutons, et la Table 230 définit les drapeaux de champs texte
$8000FFFFvers$80010000: le bit 16 (Radio,$8000, Table 228) est effacé et le bit 17 (Pushbutton,$10000) est posé, si bien quun bouton radio devient un bouton poussoir- Le même arrondi efface les bits 1 à 3, si bien que ReadOnly, Required et NoExport de la Table 226 disparaissent avec Radio
$80003FFFvers$80004000: le bit 13 (Multiline,$1000) et le bit 14 (Password,$2000) de la Table 230 sont tous deux effacés, si bien quune boîte de mot de passe masquée devient un champ texte simple à ligne unique- Sous le bit 32, les dégâts commencent à 2^24 :
16777219(Comb plus ReadOnly et Required) sarrondit en16777220, qui perd ReadOnly et Required et ajoute NoExport
Le dernier cas comptait pour HotPDF au-delà de lédition de formulaires. ExportLoadedPagesStructured construit son modèle de formulaires JSON et XML depuis le masque effectif et emploie NoExport dans sa décision de suppression de valeur, si bien quun masque arrondi pouvait cacher une valeur ordinaire ou rapporter les mauvais membres readOnly et required. Le chemin dexport structuré a reçu la même lecture exacte en v2.989.8
Comment HotPDF lit le token entier 32 bits exact
HotPDF Delphi Component corrige le côté lecture sans changer le modèle dobjets numériques. Lanalyseur se souvient déjà du texte du token dorigine à côté de la valeur Single, si bien que le lecteur de drapeaux de champs analyse ce token comme un entier, la cumule dans un Int64 borné, et copie les 32 bits résultants dans lInteger que lAPI publique renvoie. Les API numériques génériques et lancien chemin de repli non entier restent tels quels, et seuls les consommateurs de drapeaux de champs prennent le chemin exact
Lanalyseur doit accepter les deux façons dont les fichiers réels écrivent un masque à bit haut. Certains producteurs émettent la valeur non signée 2147549183, qui dépasse la limite dentier de 2^31 - 1 de lAnnexe C mais est courante en pratique ; dautres émettent la valeur signée -2147418113. Les deux décrivent les mêmes bits. Une version de lanalyseur borné ressemble à ceci :
function TryParseFlagMask(const Token: AnsiString; out Mask: Cardinal): Boolean;
var
P, Digit: Integer;
Negative: Boolean;
Number, Limit: Int64;
begin
Result := False;
Mask := 0;
if Token = '' then Exit;
P := 1;
Negative := Token[1] = '-';
if Token[1] in ['+', '-'] then Inc(P);
if P > Length(Token) then Exit;
// tokens signés jusquà -2^31, tokens non signés jusquà 2^32 - 1
if Negative then Limit := 2147483648 else Limit := 4294967295;
Number := 0;
while P <= Length(Token) do
begin
if not (Token[P] in ['0'..'9']) then Exit; // les réels prennent le repli ancien
Digit := Ord(Token[P]) - Ord('0');
if Number > (Limit - Digit) div 10 then Exit; // borné, pas de débordement
Number := Number * 10 + Digit;
Inc(P);
end;
if Negative and (Number <> 0) then Number := 4294967296 - Number;
Mask := Cardinal(Number);
Result := True;
end;
Deux détails sont délibérés. La borne est vérifiée avant chaque multiplication, si bien quun token de 400 chiffres échoue proprement au lieu de déborder Int64. Et un token tel que 3.0 est rejeté, ce qui renvoie lappelant à Round(Value) ; le chemin exact améliore les masques entiers sans inventer de nouvelles sémantiques pour les mal formés
Depuis le code applicatif, rien ne change dans les signatures. THotPDF.GetLoadedFormFieldFlags(FieldIndex: Integer): Integer renvoie le masque effectif, en remontant la chaîne /Parent de la même façon que décrit comment HotPDF résout les valeurs de champs AcroForm héritées et les resets, et THotPDF.SetLoadedFormFieldFlags(FieldIndex, AFlags: Integer) écrit un masque local complet. Un masque avec le bit 32 posé est simplement un Integer négatif, donc déplacez les bits plutôt que de convertir la valeur :
const
FF_RADIO = $00008000; // bit 16, ISO 32000-1 Table 228
FF_PUSHBUTTON = $00010000; // bit 17, Table 228
var
Pdf: THotPDF;
I, Native: Integer;
Bits: Cardinal;
begin
Pdf := THotPDF.Create(nil);
try
if Pdf.LoadFromFile('survey.pdf') <= 0 then Exit;
for I := 0 to Pdf.GetFormFieldCount - 1 do
begin
Native := Pdf.GetLoadedFormFieldFlags(I);
Move(Native, Bits, SizeOf(Bits)); // mêmes 32 bits, aucune conversion
if (Bits and FF_RADIO) <> 0 then
Writeln(Pdf.GetFormFieldName(I), ': radio, mask $', IntToHex(Bits, 8));
end;
Bits := $80000002; // bit haut plus Required
Move(Bits, Native, SizeOf(Native)); // Native = -2147483646
Pdf.SetLoadedFormFieldFlags(0, Native);
Pdf.SaveLoadedDocument('survey-out.pdf');
finally
Pdf.Free;
end;
end;
Lire un masque hérité laisse le dictionnaire enfant intact ; écrire remplace le masque entier par une entrée locale sur le champ sélectionné. Les raccourcis SetLoadedFormFieldReadOnly et SetLoadedFormFieldRequired partent du masque hérité effectif, basculent un bit, et gardent chaque autre bit exact. Pour remplir des valeurs plutôt que des drapeaux, voyez définir des valeurs de champs de formulaire dans un PDF chargé avec HotPDF
Pourquoi lordre de Value et RememberSourceToken importe-t-il ?
Lordre compte parce que THPDFNumericObject ne fait confiance à son token mémorisé que tant que la valeur Single est inchangée. RememberSourceToken stocke le token avec une photo de la Value courante, et le getter SourceToken renvoie une chaîne vide dès que Value diffère de cette photo. Cette règle dinvalidation est ce qui empêche un nombre édité dêtre sauvegardé avec son texte dorigine périmé
La règle devient un piège côté écriture. Mémorisez le token dabord et affectez la valeur ensuite, et la photo contient lancienne valeur, le getter ne rapporte aucun token, et le sérialiseur se rabat sur le formatage du Single arrondi. Chaque écrivain de drapeaux dans la bibliothèque, y compris les raccourcis et les constructeurs de champs natifs, suit la même séquence :
// Correct : la photo correspond à Value, donc le token exact survit à la sauvegarde
Item := THPDFNumericObject.Create(nil);
Item.Value := AFlags;
Item.RememberSourceToken(AnsiString(IntToStr(AFlags)));
// Faux : la photo est prise avant que Value change, donc SourceToken
// est invalidé et le Single arrondi est écrit à la place
Item := THPDFNumericObject.Create(nil);
Item.RememberSourceToken(AnsiString(IntToStr(AFlags)));
Item.Value := AFlags;
Comme SaveNumericObject écrit le token mémorisé chaque fois quil est valide, obtenir le bon ordre est ce qui fait quun fichier sauvegardé puis rouvert porte lentier dorigine, pas une approximation arrondie de celui-ci
Le token null qui a fait partager un même nom à deux champs
La même version a corrigé un second défaut de lanalyseur trouvé en testant ces masques : une valeur null dans un dictionnaire lu depuis le flux nétait jamais consommée. HotPDF jette un œil au token suivant pour le classer, et ce contrôle laisse la position du flux au début du token. La branche otNull du lecteur de dictionnaires en flux créait lobjet null mais navançait pas au-delà des quatre octets de null
La conséquence nétait pas un blocage mais une lecture fausse silencieuse. La recherche de clé suivante balayait en avant depuis le mauvais endroit, dépassait la fin du dictionnaire courant, et lisait des clés depuis lobjets indirect suivant, qui écrasait alors le champ courant. Dans la liste des champs, le symptôme était deux champs différents rapportant le même nom et la même valeur. Les analyseurs de tableaux et de spans consommaient déjà null correctement, si bien que la correction avance le flux de quatre octets dans cette seule branche au lieu de réécrire lanalyseur général. Un curseur qui ne bouge pas est de la même famille de bug que les arrêts de curseur de lanalyseur PDF qui bloquaient HotPDF sur une entrée mal formée, avec un symptôme différent
La correction a aussi clarifié lhéritage. Un /Ff null local, ou une référence qui se résout en null, est traité comme absent et la recherche continue vers le parent, tandis quun 0 explicite est un vrai masque qui supplante le parent
Ce quune directive {$R-} prouve et ne prouve pas
La première hypothèse pour le bug de drapeaux était une erreur de contrôle de plage quand un grand masque était affecté à un Integer. Elle était fausse, et la raison est un détail Delphi bon à connaître : une directive locale {$R-} placée avant ParseSimpleDictionary dans HPDFDoc.pas navait aucune restauration correspondante, si bien que chaque fonction après elle dans lunité compilait avec les contrôles de plage coupés. Delphi ne réinitialise pas une directive à bascule à la fin dune fonction, et un fichier inclus hérite de létat des directives au point où il est inclus
Ainsi un -$R+ ou -Cr en ligne de commande ne prouve que le réglage initial de lunité. Il ne dit rien dune fonction particulière plus bas. Pour savoir si le contrôle de plage est actif pour une routine donnée, trouvez la directive la plus proche au-dessus delle, puis confirmez le comportement avec une vraie valeur de bord. Les deux anciens consommateurs locaux de drapeaux nont jamais levé lexception de plage que lhypothèse prédisait, si bien que cette exception absente ne pouvait être citée comme preuve dune correction. Le vrai défaut était larrondi Single à travers des bits de drapeaux connus
La lecture au token exact est honnête sur son périmètre aussi. Elle ne change pas létat des directives au niveau de lunité, ne donne aucun sens aux bits réservés, et ne prétend pas que toute combinaison de bits est un champ valide. Elle garantit que les 32 bits du fichier sont les 32 bits que votre code voit
FAQ
Pourquoi ne pas changer THPDFNumericObject.Value en Double ou Int64 ?
Changer le type dun champ public casserait chaque appelant qui le lit ou laffecte, et les nombres PDF sont réels aussi souvent quils sont entiers. Garder Single et lire les drapeaux de champs depuis le token dorigine corrige le bug pour les masques de bits sans changer aucune déclaration ni convention dappel existante
SetLoadedFormFieldFlags accepte-t-il des masques avec le bit 32 posé ?
Oui. Passez le masque comme un Integer signé ; $80000002 et -2147483646 sont les mêmes 32 bits. GetLoadedFormFieldFlags renvoie la même valeur après sauvegarde et rechargement, Required inclus
Quelle version dHotPDF lit les drapeaux de champs AcroForm exacts ?
Les lectures exactes de drapeaux de champs et la correction du token null sont livrées dans HotPDF v2.989.5, et lexport structuré JSON et XML a adopté le masque exact en v2.989.8. Les versions antérieures arrondissent tout masque au-dessus de 2^24 au travers dun Single
Drapeaux de champs AcroForm exacts, recherche de champs hérités et édition de formulaires chargés font partie du HotPDF Component for Delphi and C++Builder, une bibliothèque PDF native pour créer, charger et éditer des documents PDF