Para adjuntar un archivo fuente a un documento PDF/A-3 desde Delphi, PDFium Component escribe una cadena de associated files estilo PDF 2.0: un stream de archivo incrustado con un /Subtype MIME, una file specification que carga /AFRelationship, y un array /AF colgado del catálogo o de una página. InjectAssociateFiles y TPdf.SaveAsWithAssociateFiles arman esa cadena en una sola actualización incremental, y desde la v3.121.2 el tipo MIME se serializa como un único nombre PDF correctamente escapado. El resto de este post cubre lo que chequea un validador, el bug de un carácter que rompió text/plain, y los lugares donde las releases viejas calladamente hacían otra cosa distinta a lo que usted pedía
¿Qué necesita realmente un archivo asociado de PDF/A-3?
Un adjunto PDF/A-3 pasa la validación solo cuando tres objetos concuerdan 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) carga /F, /UF, /EF y /AFRelationship, y algo en el documento referencia esa file specification vía un array /AF (ISO 32000-2 §14.13). La incrustación simple por el árbol /Names /EmbeddedFiles, que es lo que hace TPdf.CreateAttachment, nunca fija los campos de asociación. El fixture propio de validación PDF/A-3b de PDFium Component hace concreta la dependencia: renombre solo la clave /AFRelationship y el archivo falla exactamente una regla de la cláusula 6.8 de ISO 19005-3; quite solo el /Subtype MIME y falla otra regla 6.8 distinta; meta el mismo adjunto en un candidato PDF/A-1b y se rechaza de plano, porque PDF/A-1 prohíbe archivos incrustados sin importar cuán prolijo esté el metadata
El valor de relationship es la parte que la gente suele adivinar. TPdfAFRelationship en FPdfAssocFiles mapea un miembro del enum a cada token de nombre que el inyector puede emitir, y solo los primeros cinco pertenecen al subconjunto que ISO 19005-3 reconoce:
afSource→/Source: el original del que se produjo el PDF, como un archivo de procesador de textos o una planillaafData→/Data: datos legibles por máquina de los que se derivó o que representa el contenido visibleafAlternative→/Alternative,afSupplement→/Supplement,afUnspecified→/UnspecifiedafEncryptedPayload,afFormData,afTemplate: adiciones de PDF 2.0 que caen fuera del subconjunto PDF/A-3, así que téngalos lejos 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 la v3.121.2 el inyector concatenaba el string del llamador directo después de una barra, produciendo /Subtype /text/plain. En la sintaxis PDF la segunda barra arranca un nuevo objeto name (ISO 32000-1 §7.3.5), así que el diccionario del stream de repente contenía la clave /Subtype, el nombre /text, y un nombre extra colgante /plain que desbalanceaba los pares clave-valor. Un validador PDF/A independiente rechazaba el archivo mientras parseaba el diccionario EmbeddedFile, antes de llegar jamás a una regla PDF/A, que es por qué la falla parecía corrupción de archivo y no una propiedad de adjunto faltante
El fix hace pasar el valor MIME por EscapePdfName, que emite /text#2Fplain: un solo name cuyo valor decodificado es text/plain. El escape es deliberadamente más amplio que la barra. Todo byte en 32 o menos (espacio, tab, CR, LF), todo byte en 127 o más, los delimitadores ()<>[]{}/% y el propio carácter de escape # se vuelven #XX. Escapar solo la barra habría dejado otro hueco: un string MIME que contenga >> o whitespace podría cerrar el diccionario antes de tiempo o inyectar claves extra, así que el test de regresión alimenta un valor hostil con todos los delimitadores más tab, LF y CR y chequea la salida codificada exacta
// Lo que el inyector escribe 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 llamadores siempre pasan el valor MIME ordinario. Pre-escapearlo usted
// mismo duplica el encode del '#', lo que vuelve 'text#2Fplain' en 'text#232Fplain'
Options.Files[0].MIMEType := 'text/plain';
Construir un archivo PDF/A-3 con InjectAssociateFiles
Para salida PDF/A-3, produzca el documento base conforme con TPdf.SaveAsPdfAToStream y después llame InjectAssociateFiles sobre ese stream; ese pipeline de dos pasos es exactamente el que corre el fixture de validación antes de pasar PDF/A-3b. TPdf.SaveAsWithAssociateFiles es el wrapper de conveniencia, pero guarda por el camino ordinario de SaveAs con saRemoveSecurity y no por el escritor PDF/A, así que no agrega la identificación XMP ni el output intent que PDF/A exige. Note que los record types viven en FPdfAssocFiles y FPdfPdfa, así que ambas unidades pertenecen a su cláusula uses. Desde la v3.121.3, FileName y Description ya no tienen que ser ASCII puro: /UF y /Desc se escriben como PDF text strings, el ASCII imprimible literal y cualquier otra cosa como UTF-16BE con byte order mark, mientras que el nombre legado /F siempre es ASCII imprimible portable con cualquier otro carácter reemplazado por _, así que los lectores que decodifican /F con su propia code page muestran un guion bajo en lugar de mojibake. Los builds anteriores convertían los tres por la code page ANSI del sistema en Delphi o escribían bytes UTF-8 crudos en Free Pascal, así que mantenga los nombres en ASCII solo si builds viejos 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'; // se escribe 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 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 a nivel de documento, y 1..N lo adjunta a ese diccionario de página, base 1. El inyector agrega todo como una única actualización incremental en un layout fijo (los streams incrustados, después las file specifications, después el array /AF, después 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 objetivo se reemplaza, no se mezcla, 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 su propio código, y ambos cambiaron. Antes de la v3.122.0 un TargetPage fuera de rango no fallaba; caía al catálogo, así que un typo convertía una asociación de página en una de documento sin señal alguna. Desde la 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 la 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 quedaron almacenados en otro orden al que se muestran, por ejemplo después de reordenar o insertar páginas; desde la 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 (builds viejos
// caían en silencio a un /AF a nivel de catálogo); chequear 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 se lee AFRelationship de vuelta con confianza?
TPdf.AttachmentRelationship[Index] devuelve el nombre /AFRelationship de un adjunto vía el export nativo FPDFAttachment_GetAFRelationship, pero un string vacío tiene dos significados posibles, así que llame primero a AttachmentRelationshipFeaturesAvailable. El binding se carga con tolerancia: cuando la DLL de PDFium carece de ese export, toda relationship se lee vacía, lo que es indistinguible de una file specification que simplemente no tiene /AFRelationship. La propiedad además comparte su índice con AttachmentCount, que cuenta entradas del árbol /Names /EmbeddedFiles. El inyector escribe solo la cadena /AF y no agrega entrada al name-tree, así que un archivo adjuntado vía InjectAssociateFiles queda fuera de ese índice; para confirmar la cadena inyectada, inspeccione los bytes guardados o corra 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 pregunte
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 de formato de archivo y que los archivos pedidos fueron inyectados, no la conformidad. La parte de la inyección es nueva: antes de la v3.122.0, cuando los bytes guardados no tenían trailer legible o el diccionario de catálogo no se podía localizar, InjectAssociateFiles copiaba la entrada sin cambios y el método igual devolvía True. Desde la 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 por diseño. El contenido del payload también es responsabilidad suya: el inyector no chequea que un archivo XML esté bien formado, que el tipo MIME coincida con los bytes, ni que el documento base sea PDF/A siquiera. Trate el archivo final como no verificado hasta que un validador lo haya visto, la misma disciplina que describe PDFium Component y la conformidad de archivo PDF/A. Si además parsea usted mismo los diccionarios entrantes, las mismas reglas de nombre #XX aplican al revés, un tema que cubre las trampas de tokens de nombre al parsear diccionarios PDF
Associated files, salida PDF/A, metadata de adjuntos y validación vienen todos en el mismo componente, así que el pipeline de arriba corre sin una segunda librería PDF en el build. La referencia del API, la descarga de prueba y las opciones de licencia están en la página de producto de PDFium Component