Para anexar um arquivo de origem a um documento PDF/A-3 a partir do Delphi, o PDFium Component escreve uma cadeia de associated-files do PDF 2.0: um stream de arquivo embutido com um /Subtype MIME, uma file specification carregando /AFRelationship, e um array /AF pendurado no catálogo ou numa página. O InjectAssociateFiles e o TPdf.SaveAsWithAssociateFiles constroem essa cadeia num único update incremental, e desde a v3.121.2 o tipo MIME é serializado como um único nome PDF corretamente escapado. O resto deste post cobre o que um validador checa, o bug de um caractere que quebrou o text/plain, e os lugares em que releases antigas silenciosamente faziam outra coisa que não o que você pediu
O que um arquivo associado PDF/A-3 realmente precisa?
Um anexo PDF/A-3 só passa na validação quando três objetos concordam entre si: o stream de arquivo embutido declara /Type /EmbeddedFile mais um /Subtype MIME, o dicionário de file specification (ISO 32000-2 §7.11.3) carrega /F, /UF, /EF e /AFRelationship, e algo no documento referencia essa file specification através de um array /AF (ISO 32000-2 §14.13). Embutir simplesmente pela árvore /Names /EmbeddedFiles, que é o que o TPdf.CreateAttachment faz, nunca seta os campos de associação. O próprio fixture de validação PDF/A-3b do PDFium Component torna a dependência concreta: renomeie só a chave /AFRelationship e o arquivo falha em exatamente uma regra da cláusula 6.8 da ISO 19005-3; derrube só o /Subtype MIME e uma regra 6.8 diferente falha; ponha o mesmo anexo num candidato a PDF/A-1b e ele é rejeitado de cara, porque o PDF/A-1 proíbe arquivos embutidos não importa quão arrumada a metadata esteja
O valor de relacionamento é a parte em que as pessoas costumam chutar. O TPdfAFRelationship no FPdfAssocFiles mapeia um membro do enum para cada name token que o injector pode emitir, e só os cinco primeiros pertencem ao subconjunto que a ISO 19005-3 reconhece:
afSource→/Source: o original a partir do qual o PDF foi produzido, como um arquivo de processamento de texto ou uma planilhaafData→/Data: dados legíveis por máquina dos quais o conteúdo visível foi derivado ou que ele representaafAlternative→/Alternative,afSupplement→/Supplement,afUnspecified→/UnspecifiedafEncryptedPayload,afFormData,afTemplate: adições do PDF 2.0 que caem fora do subconjunto PDF/A-3, então mantenha-os fora da saída de arquivamento
Por que /Subtype /text/plain quebrou a validação?
O bug do MIME era um erro de tokenização, não uma lacuna de conformidade: antes da v3.121.2 o injector concatenava a string do chamador direto depois de uma barra, produzindo /Subtype /text/plain. Na sintaxe PDF a segunda barra inicia um novo objeto name (ISO 32000-1 §7.3.5), então o dicionário do stream de repente guardava a chave /Subtype, o nome /text, e um nome extra solto /plain que desequilibrava os pares chave-valor. Um validador PDF/A independente rejeitava o arquivo ao parsear o dicionário EmbeddedFile, antes de chegar a uma regra PDF/A sequer, razão pela qual a falha parecia corrupção de arquivo em vez de uma propriedade de anexo ausente
O fix roteia o valor MIME pelo EscapePdfName, que emite /text#2Fplain: um nome cujo valor decodificado é text/plain. O escaping é deliberadamente mais largo que a barra. Todo byte de 32 para baixo (espaço, tab, CR, LF), todo byte de 127 para cima, os delimitadores ()<>[]{}/% e o próprio caractere de escape # viram #XX. Escapar só a barra teria deixado um buraco diferente: uma string MIME contendo >> ou whitespace poderia fechar o dicionário cedo ou injetar chaves extras, então o teste de regressão alimenta um valor hostil com todo delimitador mais tab, LF e CR e confere a saída codificada exata
// O que o injector grava para MIMEType = 'text/plain'
// antes da v3.121.2: /Type /EmbeddedFile /Subtype /text/plain (dois nomes)
// v3.121.2: /Type /EmbeddedFile /Subtype /text#2Fplain (um nome)
//
// Chamadores sempre passam o valor MIME comum. Pre-escapar você mesmo
// dupla-codifica o '#', o que transforma 'text#2Fplain' em 'text#232Fplain'
Options.Files[0].MIMEType := 'text/plain';
Construindo um arquivo PDF/A-3 com InjectAssociateFiles
Para saída PDF/A-3, produza o documento base conforme com o TPdf.SaveAsPdfAToStream e depois chame o InjectAssociateFiles sobre esse stream; esse pipeline de dois passos é exatamente o que o fixture de validação roda antes de passar em PDF/A-3b. O TPdf.SaveAsWithAssociateFiles é o wrapper de conveniência, mas salva pelo caminho comum do SaveAs com saRemoveSecurity em vez de pelo writer de PDF/A, então ele não adiciona a identificação XMP nem o output intent que o PDF/A exige. Note que os tipos de registro vivem no FPdfAssocFiles e no FPdfPdfa, então ambas as units pertencem à sua cláusula uses. Desde a v3.121.3, FileName e Description não precisam mais ser ASCII puro: /UF e /Desc são gravadas como PDF text strings, ASCII imprimível literal e qualquer outra coisa como UTF-16BE com byte order mark, enquanto o nome legado /F é sempre ASCII imprimível portátil com todo outro caractere substituído por _, então readers que decodificam o /F com a própria code page deles mostram um underscore em vez de mojibake. Builds anteriores convertiam os três pela code page ANSI do sistema no Delphi ou gravavam bytes UTF-8 crus no Free Pascal, então mantenha nomes ASCII só se builds antigos precisam produzir a mesma saída
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 nível 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'; // gravado 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; levanta EPdfAssocFilesError em falha
finally
Output.Free;
end;
finally
Base.Free;
end;
end;
Catálogo ou página: onde o array /AF aterrissa?
O TAssocFilesOptions.TargetPage decide o dono do array /AF: 0 o anexa ao catálogo como associação de nível de documento, e 1..N o anexa ao dicionário daquela página, base 1. O injector anexa tudo como um único update incremental num layout fixo (os streams embutidos, depois as file specifications, depois o array /AF, depois um catálogo ou objeto de página reescrito), então objetos existentes mantêm os offsets deles e nada é recomprimido. Qualquer entrada /AF anterior no dicionário alvo é substituída, não mesclada, o que torna um save repetido idempotente mas também significa que uma segunda chamada com lista de arquivos diferente ganha. Dois comportamentos costumavam merecer um guard no seu próprio código, e ambos mudaram. Antes da v3.122.0 um TargetPage fora da faixa não falhava; ele caía para o catálogo, então um typo transformava uma associação de nível de página numa de nível de documento sem sinal algum. Desde a v3.122.0, SaveAsWithAssociateFiles e SaveAsWithAssociateFilesToStream levantam EPdfError quando o TargetPage está fora de 0..PageCount, e o InjectAssociateFiles levanta o novo EPdfAssocFilesError para um TargetPage negativo ou que não nomeia página existente, deixando o stream de destino intocado. Antes da v3.121.4 a busca de página varria os bytes salvos atrás de dicionários /Type /Page em ordem de arquivo, o que podia anexar o arquivo a uma página diferente quando os objetos de página eram gravados noutra ordem que não a exibida, por exemplo depois de páginas reordenadas ou inseridas; desde a v3.121.4 o TargetPage nomeia a página naquela posição na ordem de páginas do documento
procedure AttachChartSource(Pdf: TPdf; PageNumber: Integer;
const CsvBytes: TBytes; const OutPath: string);
var
Options: TAssocFilesOptions;
begin
// Desde a v3.122.0 um TargetPage fora da faixa levanta EPdfError (builds antigos
// caíam silenciosamente num /AF a nível de catálogo); checar antes nomeia a 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;
Como ler o AFRelationship de volta com confiabilidade?
O TPdf.AttachmentRelationship[Index] devolve o nome /AFRelationship de um anexo pelo export nativo FPDFAttachment_GetAFRelationship, mas uma string vazia tem dois sentidos possíveis, então chame o AttachmentRelationshipFeaturesAvailable primeiro. O binding é carregado de forma tolerante: quando a DLL do PDFium não tem esse export, todo relacionamento lê como vazio, o que é indistinguível de uma file specification que simplesmente não tem /AFRelationship. A propriedade também divide o índice com o AttachmentCount, que conta entradas na árvore /Names /EmbeddedFiles. O injector grava só a cadeia /AF e não adiciona entrada na name tree, então um arquivo anexado pelo InjectAssociateFiles está fora desse índice; para confirmar a cadeia injetada, inspecione os bytes salvos ou rode um validador PDF/A. Os detalhes internos dessa name tree estão cobertos em trabalhar com anexos PDF em Delphi usando o 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; // uma resposta vazia seria ambígua, então não pergunte
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;
O que o SaveAsWithAssociateFiles não garante?
O TPdf.SaveAsWithAssociateFiles garante o envelope de formato de arquivo e que os arquivos pedidos foram injetados, não conformidade. A parte da injeção é nova: antes da v3.122.0, quando os bytes salvos não tinham trailer legível ou o dicionário de catálogo não podia ser localizado, o InjectAssociateFiles copiava a entrada inalterada e o método ainda devolvia True. Desde a v3.122.0 o InjectAssociateFiles levanta EPdfAssocFilesError nesses casos antes de escrever qualquer coisa, o SaveAsWithAssociateFiles devolve False, e como agora ele constrói a saída completa num save store antes de abrir o alvo, um save rejeitado ou falho não trunca mais um arquivo existente. Um array Files vazio ainda copia o documento inalterado de propósito. O conteúdo do payload também é responsabilidade sua: o injector não checa se um arquivo XML está bem formado, se o tipo MIME bate com os bytes, ou se o documento base é PDF/A ao menos. Trate o arquivo final como não verificado até um validador vê-lo, a mesma disciplina descrita em PDFium Component e conformidade de arquivamento PDF/A. Se você também parseia dicionários de entrada por conta própria, as mesmas regras de nome #XX valem ao contrário, um tópico coberto em armadilhas de name token ao parsear dicionários PDF
Associated files, saída PDF/A, metadata de anexos e validação todos vão no mesmo componente, então o pipeline acima roda sem uma segunda biblioteca PDF no build. A referência da API, o download trial e as opções de licenciamento estão na página do produto PDFium Component