Article technique

Fichiers associés PDF/A-3 et AFRelationship en Delphi

Pour attacher un fichier source à un document PDF/A-3 depuis Delphi, PDFium Component écrit une chaîne de fichiers associés PDF 2.0 : un flux de fichier embarqué avec un /Subtype MIME, une spécification de fichier portant /AFRelationship, et un tableau /AF pendu au catalogue ou à une page. InjectAssociateFiles et TPdf.SaveAsWithAssociateFiles construisent cette chaîne en une seule mise à jour incrémentale, et depuis la v3.121.2, le type MIME est sérialisé comme un unique nom PDF correctement échappé. Le reste de ce billet couvre ce qu'un validateur contrôle, le bogue d'un caractère qui a cassé text/plain, et les endroits où des versions plus anciennes faisaient tranquillement autre chose que ce que vous demandiez

Que faut-il réellement à un fichier associé PDF/A-3 ?

Une pièce jointe PDF/A-3 passe la validation seulement quand trois objets sont d'accord entre eux : le flux de fichier embarqué déclare /Type /EmbeddedFile plus un /Subtype MIME, le dictionnaire de spécification de fichier (ISO 32000-2 §7.11.3) porte /F, /UF, /EF et /AFRelationship, et quelque chose dans le document référence cette spécification de fichier via un tableau /AF (ISO 32000-2 §14.13). L'embarquement simple par l'arbre /Names /EmbeddedFiles, ce que fait TPdf.CreateAttachment, ne pose jamais les champs d'association du tout. La fixture de validation PDF/A-3b du PDFium Component rend la dépendance concrète : renommez seulement la clé /AFRelationship et le fichier échoue à exactement une règle de la clause 6.8 d'ISO 19005-3 ; retirez seulement le /Subtype MIME et une autre règle 6.8 échoue ; mettez la même pièce jointe dans un candidat PDF/A-1b et elle est rejetée net, parce que le PDF/A-1 interdit les fichiers embarqués quelle que soit la propreté des métadonnées

La chaîne à trois objets d'un fichier associé PDF/A-3 dans PDFium Component : un flux EmbeddedFile avec un Subtype MIME comme application xml, une spécification de fichier avec F, UF, EF et AFRelationship réglé à Data, et un tableau AF pour lui depuis le catalogue ou une page, les trois objets qu'un validateur contrôle avant que la clause 6.8 d'ISO 19005-3 ne passe
Flux, spécification de fichier et tableau AF doivent être d'accord ; l'embarquement simple par arbre de noms de TPdf.CreateAttachment ne pose aucun des champs d'association et ne le fera jamais

La valeur de relation est la partie que les gens ont tendance à deviner. TPdfAFRelationship dans FPdfAssocFiles mappe un membre d'énumération vers chaque jeton de nom que l'injecteur peut émettre, et seuls les cinq premiers appartiennent au sous-ensemble qu'ISO 19005-3 reconnaît :

  • afSource → /Source : l'original dont le PDF a été produit, comme un fichier de traitement de texte ou un tableur
  • afData → /Data : des données lisibles par machine dont le contenu visible dérive ou qu'il représente
  • afAlternative → /Alternative, afSupplement → /Supplement, afUnspecified → /Unspecified
  • afEncryptedPayload, afFormData, afTemplate : des ajouts PDF 2.0 qui tombent hors du sous-ensemble PDF/A-3, donc tenez-les hors des sorties d'archivage

Pourquoi /Subtype /text/plain a-t-il cassé la validation ?

Le bogue MIME était une erreur de tokenisation, pas un trou de conformité : avant la v3.121.2, l'injecteur concaténait la chaîne de l'appelant juste après une barre oblique, produisant /Subtype /text/plain. En syntaxe PDF, la seconde barre oblique démarre un nouvel objet nom (ISO 32000-1 §7.3.5), si bien que le dictionnaire du flux détenait soudain la clé /Subtype, le nom /text, et un nom /plain en surplus qui déséquilibrait les paires clé-valeur. Un validateur PDF/A indépendant rejetait le fichier pendant l'analyse du dictionnaire EmbeddedFile, avant même d'atteindre une règle PDF/A, voilà pourquoi l'échec ressemblait à une corruption de fichier plutôt qu'à une propriété de pièce jointe manquante

La correction fait passer la valeur MIME par EscapePdfName, qui émet /text#2Fplain : un seul nom dont la valeur décodée est text/plain. L'échappement est délibérément plus large que la barre oblique. Chaque octet inférieur ou égal à 32 (espace, tabulation, CR, LF), chaque octet supérieur ou égal à 127, les délimiteurs ()<>[]{}/% et le caractère d'échappement # lui-même deviennent #XX. N'échapper que la barre oblique aurait laissé un autre trou : une chaîne MIME contenant >> ou un espace blanc pouvait fermer le dictionnaire trop tôt ou injecter des clés en plus, si bien que le test de régression fournit une valeur hostile avec chaque délimiteur plus tabulation, LF et CR, et vérifie la sortie encodée exacte

Pourquoi le sous-type MIME text barre oblique plain a cassé l'analyse PDF/A-3 dans PDFium Component : concaténer la valeur après une barre oblique produisait deux objets nom, /text comme valeur plus un /plain en surplus qui déséquilibrait le dictionnaire EmbeddedFile, et la correction v3.121.2 fait passer la valeur par EscapePdfName pour que /text#2Fplain soit un nom qui se décode en text/plain
L'échec ressemblait à une corruption de fichier parce qu'il arrivait à l'analyseur, avant toute règle PDF/A ; le nom échappé garde les paires équilibrées et le validateur en train de lire
// Ce que l'injecteur écrit pour MIMEType = 'text/plain'
//   avant la v3.121.2 :  /Type /EmbeddedFile /Subtype /text/plain     (deux noms)
//   v3.121.2 :          /Type /EmbeddedFile /Subtype /text#2Fplain   (un nom)
//
// Les appelants passent toujours la valeur MIME ordinaire. Pré-échapper vous-même
// double-encode le '#', ce qui transforme 'text#2Fplain' en 'text#232Fplain'
Options.Files[0].MIMEType := 'text/plain';

Construire un fichier PDF/A-3 avec InjectAssociateFiles

Pour une sortie PDF/A-3, produisez le document de base conforme avec TPdf.SaveAsPdfAToStream puis appelez InjectAssociateFiles sur ce flux ; ce pipeline en deux étapes est exactement ce que la fixture de validation fait tourner avant de passer en PDF/A-3b. TPdf.SaveAsWithAssociateFiles est le wrapper de commodité, mais il sauvegarde par le chemin SaveAs ordinaire avec saRemoveSecurity plutôt que par le générateur PDF/A, si bien qu'il n'ajoute pas l'identification XMP et l'output intent que le PDF/A exige. Notez que les types d'enregistrement vivent dans FPdfAssocFiles et FPdfPdfa, donc les deux unités doivent figurer dans votre clause uses. Depuis la v3.121.3, FileName et Description n'ont plus besoin d'être en ASCII pur : /UF et /Desc sont écrits comme chaînes texte PDF, l'ASCII imprimable littéralement et tout le reste en UTF-16BE avec une marque d'ordre des octets, tandis que le nom /F historique est toujours de l'ASCII imprimable portable avec tout autre caractère remplacé par _, si bien que les lecteurs qui décodent /F avec leur propre page de code affichent un tiret bas au lieu de caractères bizarres. Les builds antérieurs convertissaient les trois via la page de code ANSI système sur Delphi ou écrivaient des octets UTF-8 bruts sur Free Pascal, donc gardez les noms en ASCII seulement si des builds plus anciens doivent produire la même sortie

uses
  System.SysUtils, System.Classes, System.IOUtils,
  PDFium, FPdfPdfa, FPdfAssocFiles;

procedure SaveWithSourceData(Pdf: TPdf; const XmlPath, OutPath: string);
var
  PdfAOptions: TPdfASaveOptions;
  Options: TAssocFilesOptions;
  Base: TMemoryStream;
  Output: TFileStream;
begin
  PdfAOptions := TPdfASaveOptions.Default;
  PdfAOptions.Conformance := pac3b;

  Options := TAssocFilesOptions.Default;      // TargetPage = 0 : /AF au niveau catalogue
  SetLength(Options.Files, 1);
  Options.Files[0].FileName := 'invoice-data.xml';
  Options.Files[0].Description := 'Structured invoice data';
  Options.Files[0].Content := TFile.ReadAllBytes(XmlPath);
  Options.Files[0].Relationship := afData;
  Options.Files[0].MIMEType := 'application/xml';  // écrit comme /application#2Fxml

  Base := TMemoryStream.Create;
  try
    if not Pdf.SaveAsPdfAToStream(Base, PdfAOptions) then
      raise Exception.Create('PDF/A-3 base save failed');
    Output := TFileStream.Create(OutPath, fmCreate);
    try
      InjectAssociateFiles(Base, Output, Options);  // rembobine Base ; lève EPdfAssocFilesError en cas d'échec
    finally
      Output.Free;
    end;
  finally
    Base.Free;
  end;
end;

Catalogue ou page : où atterrit le tableau /AF ?

TAssocFilesOptions.TargetPage décide du propriétaire du tableau /AF : 0 l'attache au catalogue comme association au niveau document, et 1..N l'attache au dictionnaire de cette page, en base 1. L'injecteur ajoute tout comme une unique mise à jour incrémentale dans une disposition fixe (les flux embarqués, puis les spécifications de fichiers, puis le tableau /AF, puis un objet catalogue ou page réécrit), si bien que les objets existants gardent leurs offsets et que rien n'est recompressé. Toute entrée /AF antérieure sur le dictionnaire cible est remplacée, pas fusionnée, ce qui rend une sauvegarde répétée idempotente mais signifie aussi qu'un second appel avec une liste de fichiers différente l'emporte. Deux comportements méritaient autrefois une garde dans votre propre code, et les deux ont changé. Avant la v3.122.0, un TargetPage hors plage n'échouait pas ; il retombait sur le catalogue, si bien qu'une coquille transformait une association au niveau page en association au niveau document sans aucun signal. Depuis la v3.122.0, SaveAsWithAssociateFiles et SaveAsWithAssociateFilesToStream lèvent EPdfError quand TargetPage est hors de 0..PageCount, et InjectAssociateFiles lève la nouvelle EPdfAssocFilesError pour un TargetPage négatif ou n'en nommant aucune page existante, en laissant le flux de destination intact. Avant la v3.121.4, la recherche de page scannait les octets sauvegardés pour des dictionnaires /Type /Page dans l'ordre du fichier, ce qui pouvait attacher le fichier à une autre page dès que les objets page étaient stockés dans un autre ordre que celui de leur affichage, par exemple après une réorganisation ou une insertion de pages ; depuis la v3.121.4, TargetPage nomme la page à cette position dans l'ordre des pages du document

Où atterrit le tableau AF dans PDFium Component : TargetPage zéro l'attache au catalogue, les pages 1 à N l'attachent au dictionnaire de la page, et une valeur hors plage, qui avant la v3.122.0 retombait silencieusement sur le catalogue, lève désormais une exception, tandis que l'injecteur ajoute tout comme une mise à jour incrémentale unique dans une disposition fixe qui garde les offsets existants et remplace toute entrée AF antérieure
Avant la v3.122.0, un TargetPage hors plage devenait tranquillement une association au niveau document ; les versions actuelles lèvent une exception à la place, et un second appel avec une liste de fichiers différente l'emporte toujours
procedure AttachChartSource(Pdf: TPdf; PageNumber: Integer;
  const CsvBytes: TBytes; const OutPath: string);
var
  Options: TAssocFilesOptions;
begin
  // Depuis la v3.122.0, un TargetPage hors plage lève EPdfError (les builds
  // anciens retombaient en silence sur un /AF au niveau catalogue) ; vérifier d'abord nomme la page
  if (PageNumber < 1) or (PageNumber > Pdf.PageCount) then
    raise EArgumentOutOfRangeException.CreateFmt('No page %d', [PageNumber]);

  Options := TAssocFilesOptions.Default;
  Options.TargetPage := PageNumber;
  SetLength(Options.Files, 1);
  Options.Files[0].FileName := 'chart-data.csv';
  Options.Files[0].Content := CsvBytes;
  Options.Files[0].Relationship := afSource;
  Options.Files[0].MIMEType := 'text/csv';

  if not Pdf.SaveAsWithAssociateFiles(OutPath, Options) then
    raise Exception.Create('Associated-file save failed');
end;

Comment relire AFRelationship de façon fiable ?

TPdf.AttachmentRelationship[Index] renvoie le nom /AFRelationship d'une pièce jointe via l'export natif FPDFAttachment_GetAFRelationship, mais une chaîne vide a deux significations possibles, donc appelez d'abord AttachmentRelationshipFeaturesAvailable. Le binding est chargé avec tolérance : quand la DLL PDFium manque de cet export, toute relation se lit vide, ce qui est indiscernable d'une spécification de fichier qui n'a simplement pas de /AFRelationship. La propriété partage aussi son index avec AttachmentCount, qui compte les entrées de l'arbre /Names /EmbeddedFiles. L'injecteur n'écrit que la chaîne /AF et n'ajoute pas d'entrée d'arbre de noms, si bien qu'un fichier attaché via InjectAssociateFiles est hors de cet index ; pour confirmer la chaîne injectée, inspectez les octets sauvegardés ou faites tourner un validateur PDF/A. Les internes de cet arbre de noms sont couverts dans travailler avec les pièces jointes PDF en Delphi avec PDFium Component

procedure ReportRelationships(const FileName: string);
var
  Pdf: TPdf;
  I: Integer;
  Rel: string;
begin
  if not AttachmentRelationshipFeaturesAvailable then
  begin
    Writeln('This PDFium build cannot report /AFRelationship');
    Exit;  // une réponse vide serait ambiguë, donc ne demandez pas
  end;

  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := FileName;
    Pdf.Active := True;
    for I := 0 to Pdf.AttachmentCount - 1 do
    begin
      Rel := Pdf.AttachmentRelationship[I];
      if Rel = '' then
        Rel := '(no /AFRelationship)';
      Writeln(Pdf.AttachmentName[I], ': ', Rel);
    end;
  finally
    Pdf.Free;
  end;
end;

Que ne garantit pas SaveAsWithAssociateFiles ?

TPdf.SaveAsWithAssociateFiles garantit l'enveloppe de format de fichier et que les fichiers demandés ont été injectés, pas la conformité. La partie injection est neuve : avant la v3.122.0, quand les octets sauvegardés n'avaient aucun trailer lisible ou que le dictionnaire catalogue était introuvable, InjectAssociateFiles copiait l'entrée telle quelle et la méthode renvoyait quand même True. Depuis la v3.122.0, InjectAssociateFiles lève EPdfAssocFilesError dans ces cas avant d'écrire quoi que ce soit, SaveAsWithAssociateFiles renvoie False, et parce qu'il construit désormais la sortie complète dans un magasin de sauvegarde avant d'ouvrir la cible, une sauvegarde rejetée ou échouée ne tronque plus un fichier existant. Un tableau Files vide copie toujours le document tel quel, par conception. Le contenu de la charge utile est aussi votre responsabilité : l'injecteur ne vérifie ni qu'un fichier XML est bien formé, ni que le type MIME correspond aux octets, ni que le document de base est du PDF/A. Traitez le fichier final comme non vérifié tant qu'un validateur ne l'a pas vu, la même discipline que décrit PDFium Component et conformité d'archivage PDF/A. Si vous analysez aussi vous-même les dictionnaires entrants, les mêmes règles de nom #XX s'appliquent en sens inverse, un sujet couvert dans les pièges des jetons de nom lors de l'analyse de dictionnaires PDF

Les fichiers associés, la sortie PDF/A, les métadonnées de pièces jointes et la validation arrivent tous dans le même composant, si bien que le pipeline ci-dessus tourne sans une seconde bibliothèque PDF dans le build. La référence API, le téléchargement d'essai et les options de licence sont sur la page produit PDFium Component