Artigo Técnico

Ficheiros associados PDF/A-3 e AFRelationship em Delphi

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

A cadeia de três objetos de um ficheiro associado PDF/A-3 no PDFium Component: um stream EmbeddedFile com um Subtype MIME como application xml, uma especificação de ficheiro com F, UF, EF e AFRelationship posto a Data, e uma matriz AF para ele a partir do catálogo ou de uma página, os três objetos que um validador confere antes da cláusula 6.8 da ISO 19005-3 passar
Stream, especificação de ficheiro e matriz AF têm de concordar; a incorporação simples pela árvore de nomes do TPdf.CreateAttachment não define campo de associação nenhum e nunca definirá

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álculo
  • afData → /Data: dados legíveis por máquina a partir 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 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

Porque é que o subtipo MIME text barra plain partiu a análise PDF/A-3 no PDFium Component: concatenar o valor depois de uma barra produziu dois objetos name, /text como valor mais um /plain pendurado que desequilibrou o dicionário EmbeddedFile, e a correção da v3.121.2 encaminha o valor pelo EscapePdfName para /text#2Fplain ser um nome que decodifica para text/plain
A falha parecia corrupção de ficheiro porque acontecia no parser, antes de qualquer regra PDF/A; o nome escapado mantém os pares equilibrados e o validador a ler
// 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

Onde aterra a matriz AF no PDFium Component: TargetPage zero apega-a ao catálogo, páginas 1 a N apegam-na ao dicionário da página, e um valor fora do intervalo, que antes da v3.122.0 recuava em silêncio para o catálogo, agora dispara uma exceção, enquanto o injetor acrescenta tudo como uma atualização incremental num layout fixo que conserva os offsets existentes e substitui qualquer entrada AF anterior
Antes da v3.122.0 um TargetPage fora do intervalo tornava-se em silêncio numa associação ao nível do documento; as versões atuais disparam em vez disso, e uma segunda chamada com uma lista de ficheiros diferente continua a ganhar
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