Artículo técnico

Archivos asociados PDF/A-3 y AFRelationship en Delphi

Para adjuntar un archivo origen a un documento PDF/A-3 desde Delphi, PDFium Component escribe una cadena de associated files de PDF 2.0: un stream de archivo incrustado con un /Subtype MIME, una file specification que lleva /AFRelationship, y un array /AF colgado del catálogo o de una página. InjectAssociateFiles y TPdf.SaveAsWithAssociateFiles construyen esa cadena en una única actualización incremental, y desde v3.121.2 el tipo MIME se serializa como un único nombre PDF correctamente escapado. El resto de esta entrada cubre qué comprueba un validador, el bug de un carácter que rompió text/plain, y los sitios donde las releases anteriores hacían en silencio otra cosa distinta de lo que pediste

¿Qué necesita realmente un archivo asociado PDF/A-3?

Un adjunto PDF/A-3 pasa la validación solo cuando tres objetos están de acuerdo entre sí: el stream de archivo incrustado declara /Type /EmbeddedFile más un /Subtype MIME, el diccionario de file specification (ISO 32000-2 §7.11.3) lleva /F, /UF, /EF y /AFRelationship, y algo en el documento referencia esa file specification mediante un array /AF (ISO 32000-2 §14.13). La incrustación simple por el árbol /Names /EmbeddedFiles, que es lo que hace TPdf.CreateAttachment, no fija ningún campo de asociación. El fixture propio de validación PDF/A-3b de PDFium Component hace concreta la dependencia: renombra solo la clave /AFRelationship y el archivo suspende exactamente una regla de la cláusula 6.8 de ISO 19005-3; quita solo el /Subtype MIME y suspende otra regla 6.8 distinta; mete el mismo adjunto en un candidato PDF/A-1b y lo rechazan sin más, porque PDF/A-1 prohíbe archivos incrustados por muy ordenados que estén los metadatos

La cadena de tres objetos de un archivo asociado PDF A-3 en PDFium Component: un stream EmbeddedFile con un Subtype MIME como application xml, una file specification con F, UF, EF y AFRelationship fijado a Data, y un array AF para ella desde el catálogo o una página, los tres objetos que comprueba un validador antes de que pase la cláusula 6.8 de ISO 19005-3
Stream, file specification y array AF deben estar de acuerdo; la incrustación simple por name-tree de TPdf.CreateAttachment no fija ninguno de los campos de asociación ni lo hará

El valor de relación es la parte que la gente suele inventar. TPdfAFRelationship en FPdfAssocFiles mapea un miembro del enum a cada name token que el inyector puede emitir, y solo los cinco primeros pertenecen al subconjunto que ISO 19005-3 reconoce:

  • afSource → /Source: el original a partir del cual se produjo el PDF, como un archivo de procesador de textos o una hoja de cálculo
  • afData → /Data: datos legibles por máquina de los que deriva o que representa el contenido visible
  • afAlternative → /Alternative, afSupplement → /Supplement, afUnspecified → /Unspecified
  • afEncryptedPayload, afFormData, afTemplate: adiciones de PDF 2.0 que quedan fuera del subconjunto PDF/A-3, así que mantenlos fuera de la salida de archivo

¿Por qué /Subtype /text/plain rompía la validación?

El bug del MIME era un error de tokenización, no un hueco de conformidad: antes de v3.121.2 el inyector concatenaba la cadena del invocador directamente tras una barra, produciendo /Subtype /text/plain. En sintaxis PDF la segunda barra abre un nuevo objeto name (ISO 32000-1 §7.3.5), así que el diccionario del stream de pronto contenía la clave /Subtype, el name /text, y un name extra colgante /plain que descompensaba las parejas clave-valor. Un validador PDF/A independiente rechazaba el archivo mientras parseaba el diccionario EmbeddedFile, antes de llegar siquiera a una regla PDF/A, razón por la que el fallo parecía corrupción de archivo y no una propiedad de adjunto ausente

El fix enruta el valor MIME por EscapePdfName, que emite /text#2Fplain: un name cuyo valor decodificado es text/plain. El escape es deliberadamente más amplio que la barra. Todo byte en 32 o por debajo (espacio, tabulador, CR, LF), todo byte en 127 o por encima, los delimitadores ()<>[]{}/% y el propio carácter de escape # se convierten en #XX. Escapar solo la barra habría dejado otro agujero: una cadena MIME que contuviera >> o espacio en blanco podría cerrar el diccionario antes de tiempo o inyectar claves extra, así que el test de regresión le da un valor hostil con cada delimitador más tabulador, LF y CR y comprueba la salida codificada exacta

Por qué el subtype MIME text barra plain rompía el parseo PDF A-3 en PDFium Component: concatenar el valor tras una barra producía dos objetos name, /text como valor más un /plain colgante que descompensaba el diccionario EmbeddedFile, y el fix de v3.121.2 enruta el valor por EscapePdfName para que /text#2Fplain sea un name que decodifica a text/plain
El fallo parecía corrupción de archivo porque ocurría en el parser, antes de cualquier regla PDF/A; el nombre escapado mantiene las parejas compensadas y al validador leyendo
// Lo que escribe el inyector para MIMEType = 'text/plain'
//   antes de v3.121.2:  /Type /EmbeddedFile /Subtype /text/plain     (dos names)
//   v3.121.2:         /Type /EmbeddedFile /Subtype /text#2Fplain   (un name)
//
// Los invocadores pasan siempre el valor MIME ordinario. Pre-escaparlo tú
// mismo duplica la codificación del '#', que convierte 'text#2Fplain' en 'text#232Fplain'
Options.Files[0].MIMEType := 'text/plain';

Construir un archivo PDF/A-3 con InjectAssociateFiles

Para salida PDF/A-3, produce el documento base conforme con TPdf.SaveAsPdfAToStream y luego llama a InjectAssociateFiles sobre ese stream; ese pipeline de dos pasos es exactamente lo que ejecuta el fixture de validación antes de pasar PDF/A-3b. TPdf.SaveAsWithAssociateFiles es el envoltorio de conveniencia, pero guarda por el camino ordinario de SaveAs con saRemoveSecurity en vez de por el escritor PDF/A, así que no añade la identificación XMP ni el output intent que PDF/A exige. Ten en cuenta que los tipos record viven en FPdfAssocFiles y FPdfPdfa, así que ambas unidades pertenecen a tu cláusula uses. Desde v3.121.3, FileName y Description ya no tienen por qué ser ASCII puro: /UF y /Desc se escriben como cadenas de texto PDF, ASCII imprimible literalmente y cualquier otra cosa como UTF-16BE con marca de orden de bytes, mientras que el /F legado es siempre ASCII imprimible portable con cada carácter distinto sustituido por _, así que los lectores que decodifican /F con su propia página de códigos muestran un subrayado en lugar de mojibake. Los builds anteriores convertían los tres por la página de códigos ANSI del sistema en Delphi o escribían bytes UTF-8 crudos en Free Pascal, así que mantén los nombres solo en ASCII si builds más antiguos deben producir la misma salida

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 a nivel de catálogo
  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';  // escrito como /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);  // rebobina Base; lanza EPdfAssocFilesError en caso de fallo
    finally
      Output.Free;
    end;
  finally
    Base.Free;
  end;
end;

¿Catálogo o página: dónde aterriza el array /AF?

TAssocFilesOptions.TargetPage decide el dueño del array /AF: 0 lo adjunta al catálogo como asociación de documento, y 1..N lo adjunta al diccionario de esa página, base 1. El inyector añade todo como una única actualización incremental en un layout fijo (los streams incrustados, luego las file specifications, luego el array /AF, luego un catálogo u objeto de página reescrito), así que los objetos existentes conservan sus offsets y nada se recomprime. Cualquier entrada /AF anterior en el diccionario destino se sustituye, no se fusiona, lo que hace idempotente un guardado repetido pero también significa que una segunda llamada con otra lista de archivos gana. Dos comportamientos solían merecer un guardia en tu propio código, y ambos han cambiado. Antes de v3.122.0 un TargetPage fuera de rango no fallaba; caía al catálogo, así que una errata convertía una asociación de página en una de documento sin señal alguna. Desde v3.122.0, SaveAsWithAssociateFiles y SaveAsWithAssociateFilesToStream lanzan EPdfError cuando TargetPage está fuera de 0..PageCount, y InjectAssociateFiles lanza el nuevo EPdfAssocFilesError para un TargetPage negativo o uno que no nombre página existente, dejando el stream destino sin modificar. Antes de v3.121.4 la búsqueda de página escaneaba los bytes guardados buscando diccionarios /Type /Page en orden de archivo, lo que podía adjuntar el archivo a una página distinta una vez que los objetos de página se almacenaban en otro orden distinto del que se muestran, por ejemplo tras reordenar o insertar páginas; desde v3.121.4 TargetPage nombra la página en esa posición del orden de páginas del documento

Dónde aterriza el array AF en PDFium Component: TargetPage cero lo adjunta al catálogo, las páginas 1 a N lo adjuntan al diccionario de página, y un valor fuera de rango, que antes de v3.122.0 caía en silencio al catálogo, ahora lanza una excepción, mientras el inyector añade todo como una única actualización incremental en un layout fijo que conserva los offsets existentes y sustituye cualquier entrada AF anterior
Antes de v3.122.0 un TargetPage fuera de rango se convertía en silencio en una asociación de documento; las releases actuales lanzan en su lugar, y una segunda llamada con otra lista de archivos sigue ganando
procedure AttachChartSource(Pdf: TPdf; PageNumber: Integer;
  const CsvBytes: TBytes; const OutPath: string);
var
  Options: TAssocFilesOptions;
begin
  // Desde v3.122.0 un TargetPage fuera de rango lanza EPdfError (los builds
  // antiguos caían en silencio a un /AF de catálogo); comprobar antes nombra la página
  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;

¿Cómo lees AFRelationship de vuelta con fiabilidad?

TPdf.AttachmentRelationship[Index] devuelve el name /AFRelationship de un adjunto a través de la exportación nativa FPDFAttachment_GetAFRelationship, pero una cadena vacía tiene dos significados posibles, así que llama antes a AttachmentRelationshipFeaturesAvailable. El binding se carga con tolerancia: cuando la DLL de PDFium carece de esa exportación, toda relación se lee vacía, lo que es indistinguible de una file specification que simplemente no tiene /AFRelationship. La propiedad comparte además su índice con AttachmentCount, que cuenta entradas del árbol /Names /EmbeddedFiles. El inyector escribe solo la cadena /AF y no añade entrada al name-tree, así que un archivo adjuntado vía InjectAssociateFiles queda fuera de ese índice; para confirmar la cadena inyectada, inspecciona los bytes guardados o pasa un validador PDF/A. Las interioridades de ese name tree están cubiertas en trabajar con adjuntos PDF en Delphi usando 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;  // una respuesta vacía sería ambigua, así que no preguntes
  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;

¿Qué no garantiza SaveAsWithAssociateFiles?

TPdf.SaveAsWithAssociateFiles garantiza el sobre del formato de archivo y que los archivos pedidos fueron inyectados, no la conformidad. La parte de inyección es nueva: antes de v3.122.0, cuando los bytes guardados no tenían trailer legible o no se podía localizar el diccionario del catálogo, InjectAssociateFiles copiaba la entrada sin cambios y el método seguía devolviendo True. Desde v3.122.0 InjectAssociateFiles lanza EPdfAssocFilesError en esos casos antes de escribir nada, SaveAsWithAssociateFiles devuelve False, y como ahora construye la salida completa en un save store antes de abrir el destino, un guardado rechazado o fallido ya no trunca un archivo existente. Un array Files vacío sigue copiando el documento sin cambios a propósito. El contenido del payload también es cosa tuya: el inyector no comprueba que un XML esté bien formado, que el tipo MIME corresponda a los bytes, o que el documento base sea PDF/A siquiera. Trata el archivo final como no verificado hasta que lo haya visto un validador, la misma disciplina que describe PDFium Component y la conformidad de archivo PDF/A. Si además parseas tú los diccionarios entrantes, las mismas reglas de name #XX aplican a la inversa, un tema que cubre las trampas de name tokens al parsear diccionarios PDF

Archivos asociados, salida PDF/A, metadatos de adjuntos y validación se envían todos en el mismo componente, así que el pipeline de arriba corre sin una segunda biblioteca PDF en la build. La referencia de la API, la descarga de prueba y las opciones de licencia están en la página de producto de PDFium Component