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
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álculoafData→/Data: datos legibles por máquina de los que deriva o que representa el contenido visibleafAlternative→/Alternative,afSupplement→/Supplement,afUnspecified→/UnspecifiedafEncryptedPayload,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
// 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
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