Article technique

Pièges de codepage FPC dans les métadonnées XMP PDF/A

Le composant PDFium pour Delphi assemble le paquet XMP destiné à la sortie PDF/A en concaténant des fragments UTF-8 dans un AnsiString, et sous Free Pascal 3.2.2 ce paquet a cessé silencieusement d’être un UTF-8 valide dès qu’un titre de document contenait un caractère non ASCII. L’ISO 19005-1 6.7.2 exige que le flux de métadonnées soit en UTF-8 valide ; le fichier échouait donc à la validation. La version 3.103.1 corrige directement l’encodeur, dans StringToUtf8. La partie intéressante n’est pas le patch. C’est qu’une même ligne source inchangée produisait des octets corrects sous Delphi, des octets corrects dans une application Lazarus LCL et des octets corrompus dans un programme console Free Pascal compilé à partir de la même unité. Trois comportements distincts des chaînes Free Pascal doivent s’aligner pour que cela devienne compréhensible, et chacun est défendable pris isolément

Pourquoi le même code de métadonnées produit-il des octets différents sous Delphi et FPC ?

Parce que string n’est pas le même type dans les deux compilateurs. FPC 3.2.2 en {$MODE Delphi} compile string en AnsiString marqué avec DefaultSystemCodePage, tandis que Delphi le compile en UnicodeString. Chaque champ de métadonnées de TPdfASaveOptions est déclaré string ; Title, Author, Subject, Keywords, Creator et Producer transportent donc des unités de code UTF-16 dans un compilateur et des caractères sur un octet accompagnés d’une étiquette de codepage dans l’autre. Même enregistrement, même champ, charge différente. Les valeurs elles-mêmes arrivent du document en UTF-16. TPdf.GetTitle et ses frères renvoient WString, qui vaut WideString sur FPC et string sur Delphi, et SaveAsPdfAToStream remplit tout champ d’option vide depuis le dictionnaire Info avant d’injecter les marqueurs. Cette affectation est une conversion réductrice sous Free Pascal, et la RTL l’effectue via la codepage de la chaîne cible. Dans un programme LCL, LazUTF8 a déjà défini DefaultSystemCodePage à CP_UTF8, si bien que la réduction produit de l’UTF-8 et que tout le chemin suivant est correct par hasard. Dans un programme console ordinaire, la même réduction tombe sur la codepage ANSI, et StringToUtf8 recopie ensuite ces octets tels quels parce qu’elle suppose qu’ils sont déjà en UTF-8. Six ponts de sauvegarde ont cette forme : SaveAsPdfAToStream, SaveAsPdfUaToStream, SaveAsPdfEToStream, SaveAsPdfXToStream, SaveAsPdfRToStream et SaveAsPdfVTToStream, chacun avec son propre enregistrement d’options

// PDFium.pas : les accesseurs du document sont toujours en UTF-16
//   WString = WideString sur FPC, = string (UnicodeString) sur Delphi
function TPdf.GetTitle: WString;

// FPdfPdfa.pas : l’enregistrement d’options de sauvegarde porte les métadonnées en `string`
TPdfASaveOptions = record
  Conformance: TPdfAConformance;
  IccProfileData: TBytes;
  Title: string;      // UnicodeString sur Delphi
                      // AnsiString + DefaultSystemCodePage sur FPC
  Author: string;
  Subject: string;
  Keywords: string;
  Creator: string;
  Producer: string;
  CreationDate: string;
  ModDate: string;
  DocumentId: TBytes;
  InstanceId: TBytes;
  class function Default: TPdfASaveOptions; static;
end;

// SaveAsPdfAToStream complète les champs vides depuis le dictionnaire Info
// La conversion réductrice est désormais explicitée plutôt que laissée implicite :
if Eff.Title = '' then
  Eff.Title := WStringToStr(GetTitle);

Faire passer les six ponts par un seul helper WStringToStr ne change pas le comportement de la RTL, mais place la conversion là où un lecteur peut la voir et a supprimé 92 avertissements de conversion implicite qui masquaient précisément cette classe de problème. C’est l’image miroir de la corruption côté Delphi décrite dans nos notes sur les pièges des compilateurs Delphi et FPC dans les builds PDFium, où une concaténation sous Delphi détruit un octet de poids fort que Free Pascal conserve

Trois comportements Free Pascal qui déjouent le correctif évident

Le correctif évident consiste à appeler UTF8Encode et à passer à autre chose. Sous FPC 3.2.2 en mode Delphi, il échoue trois fois, et chaque échec est silencieux

var
  W: UnicodeString;
  S: string;
  U: UTF8String;
  R: RawByteString;
  Xmp: AnsiString;
begin
  // Piège 1 : en mode Delphi, une *variable* UTF8String est un simple AnsiString,
  // si bien que l’affectation retranscode directement les octets vers la codepage hôte
  U := UTF8Encode(W);

  // Piège 2 : S est déjà un AnsiString, UTF8Encode ne fait donc absolument rien
  R := UTF8Encode(S);                 // aucun décodage, aucun encodage, aucune erreur
  R := UTF8Encode(UnicodeString(S));  // celui-ci encode réellement

  // Piège 3 : la concaténation unifie chaque opérande vers la codepage de destination,
  // et une destination RawByteString ne fait pas exception
  Xmp := Xmp + R;
end;

Le premier piège signifie que le résultat encodé doit rester dans l’AnsiString ou le RawByteString qui l’a produit. Faites-le passer par un temporaire UTF8String en sortie et vous aurez annulé le travail. Le deuxième est celui qui reste caché le plus longtemps, car UTF8Encode(S) se compile, s’exécute, renvoie une valeur de la bonne longueur et n’effectue absolument aucune conversion lorsque son argument est déjà un AnsiString ; seul l’élargissement préalable vers UnicodeString fait réellement décoder l’appel. Le troisième explique comment un encodeur correct peut malgré tout produire un document cassé : BuildXmpBytes accumule le paquet dans une variable locale Xmp: AnsiString, et Free Pascal convertit chaque opérande d’une concaténation vers la codepage de la variable de destination, rabattant au passage les séquences multioctets en octets ANSI uniques

Que garantit réellement SetCodePage avec False ?

SetCodePage(RawByteString(Result), DefaultSystemCodePage, False) réétiquette la chaîne sans toucher à un seul octet. Le troisième paramètre est Convert ; passer False signifie « supposer que la charge est déjà dans la codepage cible et modifier uniquement l’étiquette ». C’est un mensonge sur le contenu, raconté délibérément : les octets sont réellement de l’UTF-8, mais les marquer comme appartenant à la codepage hôte empêche la concaténation du piège trois de les convertir. Ils rejoignent le tampon XMP comme octets bruts et ressortent inchangés

function StringToUtf8(const S: string): AnsiString;
begin
{$IFDEF UNICODE}
  // Delphi : UTF8Encode produit déjà des octets marqués CP_UTF8 et la
  // concaténation dans un AnsiString les conserve
  Result := AnsiString(UTF8Encode(S));
{$ELSE}
  // FPC : élargir d’abord, sinon UTF8Encode ne fait rien sur un argument AnsiString
  Result := UTF8Encode(UnicodeString(S));
  // Réétiqueter sans transcodage afin que les octets survivent à la concaténation
  // dans les tampons marqués ANSI qui assemblent les paquets XMP et les objets chaîne PDF
  if Length(Result) > 0 then
    SetCodePage(RawByteString(Result), DefaultSystemCodePage, False);
{$ENDIF}
end;

Il faut bien cerner la limite. Le réétiquetage est propre à FPC et ne constitue pas une autorisation générale de mélanger des chaînes marquées et non marquées. Il fonctionne ici parce qu’il n’existe qu’un seul schéma de consommation en aval : ajouter à un AnsiString, puis écrire le tampon comme des octets. Tout ce qui tenterait d’interpréter la valeur réétiquetée comme du texte dans la codepage hôte lirait du mojibake, à juste titre. Le chemin inverse fonctionne autrement et est identique sur les deux compilateurs : marquer le tampon entrant CP_UTF8 avec SetCodePage(..., False), puis appeler UTF8ToString

Pourquoi les tests de régression portaient-ils le même piège ?

Parce qu’un test qui construit ses octets attendus à partir d’un littéral source teste le compilateur, pas la bibliothèque. Une constante telle que #$C3#$A9 écrite dans un fichier source Pascal porte la codepage de compilation de ce fichier et, lorsqu’elle est passée à un paramètre AnsiString, la RTL la réencode, ce qui est précisément la conversion testée. L’attendu doit être assemblé à l’exécution, octet par octet, puis comparé octet par octet, car = sur deux valeurs AnsiString portant des étiquettes différentes réconcilie les codepages avant la comparaison et renvoie un faux négatif rassurant

function BytesPattern(const Values: array of Byte): AnsiString;
var
  I: Integer;
begin
  SetLength(Result, Length(Values));
  for I := 0 to High(Values) do
    Result[I + 1] := AnsiChar(Values[I]);
end;

procedure TPdfATests.StringToUtf8_AnsiCodePageString_EncodesUtf8;
var
  Saved: Word;
  Wide: WideString;
  Narrowed: string;
  Encoded, ExpectedUtf8: AnsiString;
begin
  Saved := DefaultSystemCodePage;
  try
    SetMultiByteConversionCodePage(1252);
    Wide := WideChar($0043) + WideChar($0061) + WideChar($0066) + WideChar($00E9);
    Narrowed := Wide;                    // la réduction testée
    Encoded := StringToUtf8(Narrowed);
  finally
    SetMultiByteConversionCodePage(Saved);
  end;
  // « Caf » + U+00E9 en UTF-8, construit à l’exécution pour qu’aucun littéral ne soit réencodé
  ExpectedUtf8 := BytesPattern([$43, $61, $66, $C3, $A9]);
  AssertTrue('StringToUtf8 must emit UTF-8 for a string carrying a non-UTF-8 codepage',
    SameOctets(Encoded, ExpectedUtf8));
end;

Le harness lui-même est un programme LCL, donc DefaultSystemCodePage vaut CP_UTF8 et le bug est invisible jusqu’à ce que le test le modifie. SetMultiByteConversionCodePage(1252) dans un bloc try..finally reproduit l’environnement console ordinaire pendant la durée d’un test. Le contrôle de bout en bout va plus loin et vérifie les deux directions : le paquet XMP produit par l’injection de marqueurs doit contenir $43 $61 $66 $C3 $A9 et ne doit pas contenir $43 $61 $66 $E9, de sorte qu’une future régression vers une sortie brute sur un octet échoue bruyamment au lieu de produire un fichier simplement plausible dans un dump hexadécimal. Si vous travaillez avec des métadonnées non latines, la même discipline d’élargissement gouverne les cas de texte emoji et CJK qui casse la gestion WideChar dans Delphi

Où la conversion réductrice intervient-elle encore ?

XMP est la victime visible, mais tout pont TBytes vers string de la même base de code était exposé de la même manière. Deux autres ont été corrigés dans la version 3.103.1 : Utf8BytesToString et StringToUtf8Bytes dans FPdfProduction, qui font un aller-retour du paquet de jeux de données XFA par une chaîne afin que MergePdfXfaDatasets puisse substituer les valeurs liées, et BytesToUtf8 dans FPdfTrustedList, qui décode le XML des listes de confiance européennes après suppression de la marque d’ordre des octets. Tous mettent désormais le tampon en scène dans un RawByteString, le marquent CP_UTF8 sans conversion et le décodent avec UTF8ToString. Un module était déjà immunisé, et la raison mérite d’être reprise. L’écrivain XFDF déclare son propre type de texte sous le nom XFDFString, résolu en WideString sous FPC et en UnicodeString sous Delphi ; son encodeur ne voit donc jamais un AnsiString marqué par une codepage. C’est le correctif structurel : conserver le texte dans un type UTF-16 jusqu’au point exact de sérialisation et laisser une seule fonction étroite posséder la conversion en octets. Chaque bug de cette famille venait d’un champ string placé au milieu d’un pipeline qui était sinon en UTF-16 à une extrémité et en octets à l’autre

Ce qu’il faut vérifier dans votre propre code PDF à deux compilateurs

Si vous livrez de l’Object Pascal qui s’exécute sur les deux compilateurs et écrit des métadonnées dans un PDF conforme aux normes, quatre contrôles trouvent la plupart de cette classe de problèmes avant le validateur

  • Recherchez UTF8Encode avec un argument string. Sous FPC, cet appel est un no-op et c’est la ligne offrant le meilleur rendement d’audit
  • Traitez toute variable UTF8String comme suspecte en mode Delphi. Elle y est un simple AnsiString et l’affectation d’octets encodés la retranscode
  • Exécutez au moins une régression sous SetMultiByteConversionCodePage avec une codepage sur un octet. Un harness LCL fonctionne sous CP_UTF8 et ne reproduira jamais un programme console ordinaire
  • Construisez les vecteurs d’octets attendus à l’exécution et comparez-les octet par octet. Les littéraux source et = passent tous deux par la réconciliation des codepages et masqueront le défaut que vous cherchez

Rien de tout cela ne relève d’une curiosité exotique de Free Pascal. C’est le coût ordinaire d’un langage qui a conservé un type de chaîne orienté octets à côté d’un type UTF-16, les deux compilateurs ayant fait des choix raisonnables mais différents sur ce que string devait signifier. La conséquence pratique pour le PDF est étroite et nette : des métadonnées qui semblent correctes dans votre IDE peuvent atteindre le paquet XMP sous forme d’UTF-8 invalide, et l’ISO 19005-1 6.7.2 ne se soucie pas du compilateur qui les y a placées. Si vous construisez un pipeline d’archivage, la couche d’encodage mérite autant d’attention que le reste du workflow de conformité PDF/A qui l’entoure. Le composant PDFium pour Delphi fournit ces conversions dans la bibliothèque, si bien que SaveAsPdfA et ses cinq variantes normatives émettent des métadonnées UTF-8 conformes sous Delphi, Lazarus et les builds Free Pascal ordinaires, sans configuration de codepage de la part de l’appelant. La documentation API complète et la version actuelle se trouvent sur la page produit du composant PDFium pour Delphi