Para anexar um ficheiro de origem a um documento PDF/A-3 a partir de Delphi, o PDFium Component escreve uma cadeia de ficheiros associados do PDF 2.0: um stream de ficheiro incorporado com um /Subtype MIME, uma especificação de ficheiro a transportar /AFRelationship, e uma matriz /AF pendurada no catálogo ou numa página. O InjectAssociateFiles e o TPdf.SaveAsWithAssociateFiles constroem essa cadeia numa única atualização incremental, e desde a v3.121.2 que o tipo MIME é serializado como um único nome PDF corretamente escapado. O resto deste post cobre o que um validador confere, o bug de um caráter que partiu o text/plain, e os sítios em que versões antigas faziam em silêncio outra coisa qualquer além do que pediu
O que precisa realmente um ficheiro associado PDF/A-3?
Um anexo PDF/A-3 só passa a validação quando três objetos concordam entre si: o stream de ficheiro incorporado declara /Type /EmbeddedFile mais um /Subtype MIME, o dicionário de especificação de ficheiro (ISO 32000-2 §7.11.3) transporta /F, /UF, /EF e /AFRelationship, e algo no documento referencia essa especificação de ficheiro através de uma matriz /AF (ISO 32000-2 §14.13). A incorporação simples pela árvore /Names /EmbeddedFiles, que é o que o TPdf.CreateAttachment faz, nunca define os campos de associação. O fixture de validação PDF/A-3b do próprio PDFium Component torna a dependência concreta: mude só a chave /AFRelationship e o ficheiro falha exatamente uma regra na cláusula 6.8 da ISO 19005-3; tire só o /Subtype MIME e falha uma regra 6.8 diferente; ponha o mesmo anexo num candidato PDF/A-1b e é rejeitado à partida, porque o PDF/A-1 proíbe ficheiros incorporados não importa quão arrumada esteja a metadata
O valor da relação é a parte em que as pessoas tendem a adivinhar. O TPdfAFRelationship no FPdfAssocFiles mapeia um membro do enum para cada token de nome que o injetor pode emitir, e só os primeiros cinco pertencem ao subconjunto que a ISO 19005-3 reconhece:
afSource→/Source: o original a partir do qual o PDF foi produzido, como um ficheiro de processamento de texto ou uma folha de cálculoafData→/Data: dados legíveis por máquina a partir 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 ficam fora do subconjunto PDF/A-3, por isso mantenha-as fora da saída de arquivo
Porque é que /Subtype /text/plain partiu a validação?
O bug MIME era um erro de tokenização, não uma lacuna de conformidade: antes da v3.121.2 o injetor concatenava a string de quem chama logo a seguir a uma barra, produzindo /Subtype /text/plain. Na sintaxe PDF a segunda barra começa um novo objeto name (ISO 32000-1 §7.3.5), por isso o dicionário do stream passou a conter de repente a chave /Subtype, o nome /text, e um nome extra pendurado /plain que desequilibrou os pares chave-valor. Um validador PDF/A independente rejeitava o ficheiro enquanto analisava o dicionário EmbeddedFile, antes de chegar quer que fosse a uma regra PDF/A, razão pela qual a falha parecia corrupção de ficheiro e não uma propriedade de anexo em falta
A correção encaminha o valor MIME pelo EscapePdfName, que emite /text#2Fplain: um nome cujo valor decodificado é text/plain. O escape é deliberadamente mais largo do que a barra. Todos os bytes de 32 ou abaixo (espaço, tab, CR, LF), todos os bytes de 127 ou acima, os delimitadores ()<>[]{}/% e o próprio caráter de escape # tornam-se #XX. Escapar só a barra teria deixado um buraco diferente: uma string MIME contendo >> ou espaço em branco podia fechar o dicionário mais cedo ou injetar chaves extra, por isso o teste de regressão alimenta um valor hostil com todos os delimitadores mais tab, LF e CR e confere a saída codificada exata
// O que o injetor escreve 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)
//
// Quem chama passa sempre o valor MIME ordinário. Pré-escapar você próprio
// codifica o '#' duas vezes, o que torna 'text#2Fplain' em 'text#232Fplain'
Options.Files[0].MIMEType := 'text/plain';
Construir um ficheiro 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 corre antes de passar em PDF/A-3b. O TPdf.SaveAsWithAssociateFiles é o invólucro de conveniência, mas grava pelo caminho ordinário do SaveAs com saRemoveSecurity em vez de pelo escritor PDF/A, por isso não acrescenta a identificação XMP nem o output intent que o PDF/A exige. Note que os tipos de registo vivem em FPdfAssocFiles e FPdfPdfa, por isso ambas as units pertencem à sua cláusula uses. Desde a v3.121.3, o FileName e o Description já não têm de ser ASCII puro: o /UF e o /Desc são escritos como strings de texto PDF, ASCII imprimível literalmente e todo o resto como UTF-16BE com uma marca de ordem de bytes, enquanto o nome legado /F é sempre ASCII imprimível portável com qualquer outro caráter substituído por _, por isso leitores que decodifiquem o /F com a sua própria página de código mostram um sublinhado em vez de mojibake. Versões anteriores convertiam os três pela página de código ANSI do sistema em Delphi ou escreviam bytes UTF-8 brutos em Free Pascal, por isso mantenha os nomes ASCII só se versões antigas tiverem de 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 ao nível do 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 o Base; dispara EPdfAssocFilesError em caso de falha
finally
Output.Free;
end;
finally
Base.Free;
end;
end;
Catálogo ou página: onde aterra a matriz /AF?
O TAssocFilesOptions.TargetPage decide o dono da matriz /AF: 0 apega-a ao catálogo como associação ao nível do documento, e 1..N apega-a ao dicionário dessa página, de base um. O injetor acrescenta tudo como uma única atualização incremental num layout fixo (os streams incorporados, depois as especificações de ficheiro, depois a matriz /AF, depois um catálogo ou objeto de página reescrito), por isso os objetos existentes conservam os seus offsets e nada é recomprimido. Qualquer entrada /AF anterior no dicionário alvo é substituída, não fundida, o que torna uma gravação repetida idempotente mas também significa que uma segunda chamada com uma lista de ficheiros diferente ganha. Dois comportamentos costumavam merecer uma guarda no seu próprio código, e ambos mudaram. Antes da v3.122.0 um TargetPage fora do intervalo não falhava; recuava para o catálogo, por isso um erro de dactilografia transformava uma associação ao nível da página numa ao nível do documento sem sinal nenhum. Desde a v3.122.0, o SaveAsWithAssociateFiles e o SaveAsWithAssociateFilesToStream disparam EPdfError quando o TargetPage está fora de 0..PageCount, e o InjectAssociateFiles dispara o novo EPdfAssocFilesError para um TargetPage negativo ou um que não nomeie página existente, deixando o stream de destino inalterado. Antes da v3.121.4 a procura de páginas varria os bytes gravados à procura de dicionários /Type /Page pela ordem do ficheiro, o que podia apegar o ficheiro a uma página diferente quando os objetos de página estavam guardados noutra ordem do que a que são mostrados, por exemplo depois de páginas reordenadas ou inseridas; desde a v3.121.4 o TargetPage nomeia a página nessa 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 do intervalo dispara EPdfError (versões
// antigas recuavam em silêncio para um /AF de catálogo); verificar primeiro 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 fiabilidade?
O TPdf.AttachmentRelationship[Index] devolve o nome /AFRelationship de um anexo através da exportação nativa FPDFAttachment_GetAFRelationship, mas uma string vazia tem dois significados possíveis, por isso chame primeiro o AttachmentRelationshipFeaturesAvailable. O binding é carregado com tolerância: quando a DLL do PDFium não tem essa exportação, todas as relações leem como vazias, o que é indistinguível de uma especificação de ficheiro que simplesmente não tem /AFRelationship. A propriedade também partilha o índice com o AttachmentCount, que conta entradas na árvore /Names /EmbeddedFiles. O injetor escreve só a cadeia /AF e não acrescenta entrada na árvore de nomes, por isso um ficheiro anexado através do InjectAssociateFiles fica fora desse índice; para confirmar a cadeia injetada, inspecione os bytes gravados ou corra um validador PDF/A. As entranhas dessa árvore de nomes estão cobertas 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, por isso 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 invólucro do formato de ficheiro e que os ficheiros pedidos foram injetados, não a conformidade. A parte da injeção é nova: antes da v3.122.0, quando os bytes gravados não tinham trailer legível ou o dicionário do catálogo não podia ser localizado, o InjectAssociateFiles copiava a entrada sem alterações e o método continuava a devolver True. Desde a v3.122.0 o InjectAssociateFiles dispara EPdfAssocFilesError nesses casos antes de escrever coisa alguma, o SaveAsWithAssociateFiles devolve False, e como agora constrói a saída completa num save store antes de abrir o alvo, uma gravação recusada ou falhada já não trunca um ficheiro existente. Uma matriz Files vazia continua a copiar o documento sem alterações, por desenho. O conteúdo da carga também é responsabilidade sua: o injetor não verifica que um ficheiro XML está bem formado, que o tipo MIME bate certo com os bytes, ou que o documento base é sequer PDF/A. Trate o ficheiro final como não verificado até um validador o ter visto, a mesma disciplina descrita em PDFium Component e conformidade de arquivo PDF/A. Se também analisa dicionários de entrada você próprio, as mesmas regras de nome #XX aplicam-se ao contrário, um tópico coberto em armadilhas de tokens de nome ao analisar dicionários PDF
Ficheiros associados, saída PDF/A, metadata de anexos e validação viajam todos no mesmo componente, por isso o pipeline acima corre sem uma segunda biblioteca PDF na build. A referência da API, o download de avaliação e as opções de licenciamento estão na página do produto PDFium Component