Article technique

PDFDocEncoding : un rang décalé casse l'import FDF

Importez un FDF dans un formulaire et les valeurs de champs reviennent subtilement fausses : une puce arrive comme une obèle, une ellipse comme un tiret cadratin. PDFium Component a tracé toute cette famille de symptômes jusqu'à un seul rang de sa table PDFDocEncoding — le bloc de caractères spéciaux de l'annexe D.2 de l'ISO 32000-1 commence à $80, pas à $7F

Pourquoi un import FDF renvoie-t-il le caractère voisin ?

Parce qu'une table d'encodage décalée d'une position reste une table à l'air parfaitement valide, et chaque lookup dedans réussit. Le bloc que l'annexe D.2 assigne à $80 jusqu'à $9E avait été retranscrit un rang trop tôt, donc l'octet $80 résolvait vers l'entrée qui appartient à $81, l'octet $81 vers l'entrée de $82, et ainsi de suite sur dix-neuf positions de code consécutives. Rien ne levait, rien ne journalisait, et aucun champ n'était jamais rejeté — TPdf.ImportFDF rapportait le même nombre de champs que d'habitude

Les dégâts étaient uniformes et silencieux. Un octet portant $80, que l'annexe D.2 définit comme BULLET (U+2022), revenait comme DAGGER (U+2020). L'octet $83, HORIZONTAL ELLIPSIS (U+2026), revenait comme EM DASH (U+2014). L'octet $8D, LEFT DOUBLE QUOTATION MARK (U+201C), revenait comme RIGHT DOUBLE QUOTATION MARK (U+201D) — le cas que personne ne remarque, car un guillemet courbe faux ressemble toujours à un guillemet courbe dans un champ de formulaire. Les noms de champs subissaient le même sort que les valeurs de champs, ce qui est pire : un nom qui ne correspond plus au widget du document échoue silencieusement à s'appliquer

Schéma du décalage d'un rang de PDFDocEncoding derrière les dégâts d'import FDF dans PDFium Component : l'annexe D.2 de l'ISO 32000-1 mappe l'octet $80 à BULLET, $83 à HORIZONTAL ELLIPSIS et $8D à un guillemet double gauche, tandis que la table retranscrite un rang trop tôt renvoyait DAGGER, EM DASH et le guillemet droit pour les mêmes octets, chaque lookup réussissant quand même
Une table d'encodage décalée reste une table à l'air valide dans laquelle chaque lookup réussit, donc une puce arrive comme une obèle et un nom de champ qui ne correspond plus échoue silencieusement à s'appliquer

Les chaînes texte PDF portent deux encodages différents

L'ISO 32000-1 §7.9.2.2 définit le type chaîne texte comme l'un de deux encodages exactement, choisi en inspectant les deux premiers octets plutôt qu'à partir d'une entrée de dictionnaire. Une chaîne qui s'ouvre sur le byte order mark FE FF est en UTF-16BE ; tout le reste est en PDFDocEncoding. Cette seule branche décide lequel de deux décodeurs complètement différents s'exécute, et PDFium Component l'applique dans le parseur FDF avant de toucher un seul caractère

Schéma de la branche chaîne texte de la section 7.9.2.2 de l'ISO 32000-1 dans PDFium Component : une chaîne s'ouvrant sur le byte order mark FE FF se décode en UTF-16BE avec des paires de substituts validées, tout le reste va vers la table mono-octet FdfPdfDocEncodingChar, ce qui explique pourquoi les writers UTF-16 et les noms purement ASCII n'ont jamais exposé le bloc décalé jusqu'à l'arrivée d'un symbole typographique
Inspecter les deux premiers octets plutôt qu'une entrée de dictionnaire décide quel décodeur s'exécute, donc les outils qui écrivent en UTF-16BE par défaut n'ont jamais exercé l'unique table qui était fausse
// Chaîne texte FDF : UTF-16 quand le BOM est présent, PDFDocEncoding sinon
if (Length(Raw) >= 2) and
   (((Byte(Raw[1]) = $FE) and (Byte(Raw[2]) = $FF)) or
    ((Byte(Raw[1]) = $FF) and (Byte(Raw[2]) = $FE))) then
begin
  BigEndian := Byte(Raw[1]) = $FE;
  // ... décoder les unités de code UTF-16, en validant les paires de substituts
end
else
begin
  SetLength(Result, Length(Raw));
  for I := 1 to Length(Raw) do
    Result[I] := FdfPdfDocEncodingChar(Byte(Raw[I]));
end;

C'est pourquoi le bug s'est caché si longtemps dans les tests ordinaires. Tout FDF produit par un outil qui écrit en UTF-16BE par défaut n'atteint jamais FdfPdfDocEncodingChar, donc la table décalée reste dormante. Seules les chaînes mono-octets — ce que beaucoup de générateurs de FDF émettent pour les noms de champs latins simples, parce que c'est plus court — descendent dans la branche où la table compte. Les noms de champs purement ASCII se décodent alors correctement de toute façon, puisque sous $80 PDFDocEncoding et ASCII s'accordent. Il vous faut une chaîne mono-octet contenant un symbole typographique avant que quelque chose devienne visiblement faux

La table corrigée, et les deux ancres qui l'épingle

PDFium Component épelle maintenant la frontière dans FdfPdfDocEncodingChar lui-même, car les deux extrémités du bloc sont exactement ce qu'un implémenteur se trompe. $7F est DEL et n'a aucune signification PDFDocEncoding ; le bloc s'ouvre à $80 avec BULLET et court jusqu'à $9E avec zcaron ; $9F est non assigné ; et le signe euro est assis seul à $A0

Schéma des ancres de frontière qui épinglent la table de l'annexe D.2 dans PDFium Component : $7F est DEL sans signification PDFDocEncoding, $80 ouvre le bloc avec BULLET, $9E le ferme avec zcaron, $9F est non assigné, et le signe euro est assis seul à $A0, donc un décalage de tout le bloc ne peut pas satisfaire les extrémités et les trous à la fois
Vérifier la première rangée, la dernière rangée et les trous épingle les deux extrémités d'un coup, ce qui est précisément le test qu'un décalage de tout le bloc ne peut pas survivre
function FdfPdfDocEncodingChar(Value: Byte): WideChar;
begin
  case Value of
    // ISO 32000-1 Annexe D.2. Le bloc court de $80, pas de $7F : $7F est DEL et
    // n'a aucune signification PDFDocEncoding, et l'euro est à $A0, que la
    // branche par défaut rendrait sinon comme une espace insécable
    $7F: Result := WideChar($FFFD);
    $80: Result := WideChar($2022);   // BULLET, première rangée du bloc
    $81: Result := WideChar($2020);   // DAGGER
    // ... $82 jusqu'à $9D
    $9E: Result := WideChar($017E);   // zcaron, dernière rangée définie
    $9F: Result := WideChar($FFFD);   // non assigné dans l'annexe D.2
    $A0: Result := WideChar($20AC);   // EURO SIGN
  else
    Result := WideChar(Value);
  end;
end;

Regardez ce que le décalage a fait à ces deux extrémités, car c'est là qu'un off-by-one cesse d'être cosmétique. zcaron se tenait à $9E dans la spécification mais à $9D dans la table décalée, donc un octet de document à $9E tombait au-delà de la dernière rangée que quelqu'un avait retranscrite et chutait droit dans le repli d'identité, retournant un caractère de contrôle C1 au lieu d'une lettre. Et $A0, l'euro, n'avait jamais été écrit dans la table en premier lieu — la branche d'identité le rendait comme U+00A0 NO-BREAK SPACE, un caractère qui survit à toutes les validations suivantes, ne s'imprime comme rien, et ne correspond jamais au nom de champ que vous cherchez

Comment tester une table d'encodage retranscrite ?

Testez la première rangée, la dernière rangée, et les trous — pas un échantillon du milieu. C'est la partie du correctif qui vaut la peine d'être portée à toute autre table que vous copiez hors d'une spécification, car un contrôle ponctuel au milieu du bloc est précisément le test qu'un décalage de tout le bloc survit. Vérifier que $8D donne un guillemet double gauche ne prouve rien quand $8C donne aussi quelque chose de plausible ; vérifier que $80 donne BULLET et $9E donne zcaron épingle les deux extrémités, et un bloc décalé ne peut pas satisfaire les deux à la fois

procedure CheckPdfDocEncodingAnchors;
begin
  // Les deux extrémités du bloc spécial de l'annexe D.2 : un décalage de tout le bloc les casse
  Assert(FdfPdfDocEncodingChar($80) = WideChar($2022), 'first row: BULLET');
  Assert(FdfPdfDocEncodingChar($9E) = WideChar($017E), 'last row: zcaron');
  // Les positions de code que la spécification laisse indéfinies ne doivent pas se décoder en lettre
  Assert(FdfPdfDocEncodingChar($7F) = WideChar($FFFD), '$7F is DEL, unmapped');
  Assert(FdfPdfDocEncodingChar($9F) = WideChar($FFFD), '$9F is unassigned');
  // L'entrée isolée au-dessus du bloc, facile à omettre entièrement
  Assert(FdfPdfDocEncodingChar($A0) = WideChar($20AC), 'EURO SIGN');
end;

Les positions indéfinies méritent leurs propres assertions plutôt que d'être sautées comme sans intérêt. Un off-by-one doit bien mettre quelque chose quelque part, et l'endroit où il le met est presque toujours une position de code que la spécification a laissée vide — $7F à l'avant, $9F à l'arrière. Un test qui ne vérifie que les positions définies peut passer pendant que la table est fausse dans les deux directions ; un test qui épingle les trous attrape le décalage dès la première exécution

Ce que ça coûte dans un workflow de formulaire en aller-retour

Un import FDF qui réécrit en silence un caractère par symbole est pire qu'un qui échoue, car l'échec remonte en aval et ressemble à autre chose. Les noms de champs sont le tranchant ici. TPdf.ImportFDF compare les noms entrants aux widgets du document ouvert, et un nom qui s'est décodé avec un caractère faux ne correspond simplement pas — le champ reste vide, la valeur de retour compte quand même le champ comme parsé, et le rapport qui sort du pipeline est court d'une valeur avec rien dans le log pour expliquer pourquoi. Quiconque construit une boucle d'export-réimport du genre décrit dans l'export et l'import de données de formulaire et d'annotations XFDF avec PDFium Component devrait faire un aller-retour sur un nom de champ contenant un symbole typographique, pas seulement de l'ASCII simple, avant de faire confiance à la boucle

Ça vaut aussi le coup d'être honnête sur les limites du correctif. La table correspond maintenant à l'annexe D.2 pour les 256 valeurs d'octets, ce qui fait que les chaînes FDF mono-octets se décodent correctement, mais PDFDocEncoding couvre un répertoire latin et rien de plus — tout ce qui est dehors doit arriver en UTF-16BE avec un BOM, ce qui est exactement ce que §7.9.2.2 veut. Et une chaîne UTF-16 a encore ses propres dangers séparés côté Delphi, puisqu'un point de code au-delà de U+FFFF a besoin d'une paire de substituts plutôt que d'un seul WideChar, le problème couvert dans pourquoi les emoji et les caractères CJK cassent WideChar en Delphi. Le point plus large se généralise au-delà des tables d'encodage : un parseur PDF peut rejeter quelque chose qu'il devrait accepter, ce que vous trouvez immédiatement, ou accepter quelque chose et se tromper en silence, ce que vous trouvez en production. Les pièges de name tokens décrits dans l'analyse sécurisée des dictionnaires PDF sont dans cette seconde catégorie, et une table décalée d'un rang aussi — le chemin de code réussit, la valeur de retour a l'air raisonnable, et seul un test écrit contre la spécification plutôt que contre l'implémentation dit le contraire

ImportFDF, ExportFDF et ImportXFDF sur TPdf, avec les parseurs FDF et XFDF derrière eux, font partie du PDFium Component standard pour Delphi et C++Builder