Artigo Técnico

Arquivos associados PDF/A-3 e AFRelationship em Delphi

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

A cadeia de três objetos de um arquivo associado PDF A-3 no PDFium Component: um stream EmbeddedFile com um Subtype MIME como application xml, uma file specification com F, UF, EF e AFRelationship setado para Data, e um array AF para ela vindo do catálogo ou de uma página, os três objetos que um validador checa antes da cláusula 6.8 da ISO 19005-3 passar
Stream, file specification e array AF precisam concordar; o embedding simples pela name tree do TPdf.CreateAttachment não seta campo de associação algum e nunca vai

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 planilha
  • afData → /Data: dados legíveis por máquina dos quais o conteúdo visível foi derivado ou que ele representa
  • afAlternative → /Alternative, afSupplement → /Supplement, afUnspecified → /Unspecified
  • afEncryptedPayload, 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

Por que o subtipo MIME text slash plain quebrou o parse de PDF A-3 no PDFium Component: concatenar o valor depois de uma barra produziu dois objetos name, /text como valor mais um /plain solto que desequilibrou o dicionário EmbeddedFile, e o fix da v3.121.2 roteia o valor pelo EscapePdfName para que /text#2Fplain seja um nome que decodifica para text/plain
A falha parecia corrupção de arquivo porque aconteceu no parser, antes de qualquer regra PDF/A; o nome escapado mantém os pares equilibrados e o validador lendo
// 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

Onde o array AF aterrissa no PDFium Component: TargetPage zero o anexa ao catálogo, páginas 1 a N o anexam ao dicionário da página, e um valor fora da faixa, que antes da v3.122.0 caía silenciosamente para o catálogo, agora levanta exceção, enquanto o injector anexa tudo como um único update incremental num layout fixo que mantém os offsets existentes e substitui qualquer entrada AF anterior
Antes da v3.122.0 um TargetPage fora da faixa virava silenciosamente uma associação de nível de documento; releases atuais levantam exceção em vez, e uma segunda chamada com lista de arquivos diferente ainda ganha
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