Articolo tecnico

File associati PDF/A-3 e AFRelationship in Delphi

Per allegare un file sorgente a un documento PDF/A-3 da Delphi, PDFium Component scrive una catena di associated file PDF 2.0: uno stream di file incorporato con un /Subtype MIME, una file specification che porta /AFRelationship, e un array /AF appeso al catalogo o a una pagina. InjectAssociateFiles e TPdf.SaveAsWithAssociateFiles costruiscono quella catena in un unico aggiornamento incrementale, e dalla v3.121.2 il tipo MIME viene serializzato come un unico nome PDF correttamente escapato. Il resto di questo post copre che cosa controlla un validator, il bug di un carattere che ha rotto text/plain, e i punti in cui le release più vecchie facevano in silenzio qualcosa di diverso da quello che chiedevate

Che cosa serve davvero a un file associato PDF/A-3?

Un allegato PDF/A-3 passa la validazione solo quando tre oggetti sono d'accordo tra loro: lo stream del file incorporato dichiara /Type /EmbeddedFile più un /Subtype MIME, il dizionario di file specification (ISO 32000-2 §7.11.3) porta /F, /UF, /EF e /AFRelationship, e qualcosa nel documento referenzia quella file specification tramite un array /AF (ISO 32000-2 §14.13). L'incorporamento semplice attraverso l'albero /Names /EmbeddedFiles, che è ciò che fa TPdf.CreateAttachment, non imposta mai i campi di associazione. La fixture di validazione PDF/A-3b dello stesso PDFium Component rende concreta la dipendenza: rinominate solo la chiave /AFRelationship e il file fallisce esattamente una regola della clausola 6.8 di ISO 19005-3; togliete solo il /Subtype MIME e fallisce una diversa regola 6.8; mettete lo stesso allegato in un candidato PDF/A-1b e viene rifiutato seccamente, perché PDF/A-1 vieta i file incorporati non importa quanto siano ordinati i metadati

La catena a tre oggetti di un file associato PDF A-3 in PDFium Component: uno stream EmbeddedFile con un Subtype MIME come application xml, una file specification con F, UF, EF e AFRelationship impostato a Data, e un array AF per esso dal catalogo o da una pagina, i tre oggetti che un validator controlla prima che la clausola 6.8 di ISO 19005-3 passi
Stream, file specification e array AF devono essere d'accordo; l'incorporamento semplice nell'albero dei nomi di TPdf.CreateAttachment non imposta nessuno dei campi di associazione e non lo farà mai

Il valore della relazione è la parte su cui la gente tende a indovinare. TPdfAFRelationship in FPdfAssocFiles mappa un membro dell'enum a ogni name token che l'iniettore può emettere, e solo i primi cinque appartengono al sottoinsieme che ISO 19005-3 riconosce:

  • afSource → /Source: l'originale da cui il PDF è stato prodotto, come un file di videoscrittura o un foglio di calcolo
  • afData → /Data: dati leggibili dalla macchina da cui il contenuto visibile deriva o che rappresenta
  • afAlternative → /Alternative, afSupplement → /Supplement, afUnspecified → /Unspecified
  • afEncryptedPayload, afFormData, afTemplate: aggiunte PDF 2.0 che stanno fuori dal sottoinsieme PDF/A-3, quindi tenetele fuori dall'output di archivio

Perché /Subtype /text/plain rompeva la validazione?

Il bug MIME era un errore di tokenizzazione, non un buco di conformità: prima della v3.121.2 l'iniettore concatenava la stringa del chiamante direttamente dopo una barra, producendo /Subtype /text/plain. Nella sintassi PDF la seconda barra inizia un nuovo oggetto name (ISO 32000-1 §7.3.5), quindi il dizionario dello stream teneva improvvisamente la chiave /Subtype, il nome /text, e un nome extra pendente /plain che sbilanciava le coppie chiave-valore. Un validator PDF/A indipendente rifiutava il file mentre analizzava il dizionario EmbeddedFile, prima di arrivare mai a una regola PDF/A, ecco perché il fallimento sembrava corruzione del file anziché una proprietà dell'allegato mancante

La correzione instrada il valore MIME attraverso EscapePdfName, che emette /text#2Fplain: un solo nome il cui valore decodificato è text/plain. L'escaping è volutamente più largo della barra. Ogni byte a 32 o sotto (spazio, tab, CR, LF), ogni byte a 127 o sopra, i delimitatori ()<>[]{}/% e il carattere di escape # stesso diventano #XX. Escapare solo la barra avrebbe lasciato un buco diverso: una stringa MIME contenente >> o spazi bianchi poteva chiudere il dizionario in anticipo o iniettare chiavi extra, quindi il test di regressione somministra un valore ostile con ogni delimitatore più tab, LF e CR e controlla l'output codificato esatto

Perché il MIME subtype text slash plain rompeva il parsing PDF A-3 in PDFium Component: concatenare il valore dopo una barra produceva due oggetti name, /text come valore più un /plain pendente che sbilanciava il dizionario EmbeddedFile, e la correzione della v3.121.2 instrada il valore attraverso EscapePdfName così /text#2Fplain è un nome che decodifica a text/plain
Il fallimento sembrava corruzione del file perché accadeva al parser, prima di ogni regola PDF/A; il nome escapato tiene le coppie bilanciate e il validator a leggere
// Ciò che l'iniettore scrive per MIMEType = 'text/plain'
//   prima della v3.121.2:  /Type /EmbeddedFile /Subtype /text/plain     (due nomi)
//   v3.121.2:              /Type /EmbeddedFile /Subtype /text#2Fplain   (un nome)
//
// I chiamanti passano sempre il valore MIME ordinario. Pre-escaparlo da soli
// lo doppia-codifica: 'text#2Fplain' diventa 'text#232Fplain'
Options.Files[0].MIMEType := 'text/plain';

Costruire un file PDF/A-3 con InjectAssociateFiles

Per l'output PDF/A-3, produrre il documento base conforme con TPdf.SaveAsPdfAToStream e poi chiamare InjectAssociateFiles su quello stream; quella pipeline a due passi è esattamente ciò che la fixture di validazione esegue prima di passare PDF/A-3b. TPdf.SaveAsWithAssociateFiles è il wrapper di comodità, ma salva attraverso il percorso ordinario di SaveAs con saRemoveSecurity anziché attraverso il writer PDF/A, quindi non aggiunge l'identificazione XMP e l'output intent che PDF/A esige. Notate che i record types vivono in FPdfAssocFiles e FPdfPdfa, quindi entrambe le unit appartengono alla vostra clausola uses. Dalla v3.121.3, FileName e Description non devono più essere ASCII puro: /UF e /Desc vengono scritti come stringhe di testo PDF, l'ASCII stampabile letteralmente e tutto il resto come UTF-16BE con byte order mark, mentre il /F legacy è sempre ASCII stampabile portatile con ogni altro carattere sostituito da _, così i reader che decodificano /F con la propria code page mostrano un underscore invece di mojibake. Le build precedenti convertivano tutti e tre attraverso la code page ANSI di sistema su Delphi o scrivevano byte UTF-8 grezzi su Free Pascal, quindi tenete i nomi ASCII solo se build più vecchie devono produrre lo stesso output

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 livello di catalogo
  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';  // scritto come /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);  // riavvolge Base; solleva EPdfAssocFilesError al fallimento
    finally
      Output.Free;
    end;
  finally
    Base.Free;
  end;
end;

Catalogo o pagina: dove atterra l'array /AF?

TAssocFilesOptions.TargetPage decide il proprietario dell'array /AF: 0 lo attacca al catalogo come associazione a livello di documento, e 1..N lo attacca al dizionario di quella pagina, in base 1. L'iniettore aggiunge tutto come un singolo aggiornamento incrementale in un layout fisso (gli stream incorporati, poi le file specification, poi l'array /AF, poi un catalogo o oggetto pagina riscritto), quindi gli oggetti esistenti conservano i loro offset e nulla viene ricompresso. Qualsiasi voce /AF precedente sul dizionario bersaglio viene sostituita, non fusa, il che rende un salvataggio ripetuto idempotente ma significa anche che una seconda chiamata con una lista di file diversa vince. Due comportamenti un tempo meritavano una guardia nel vostro codice, ed entrambi sono cambiati. Prima della v3.122.0 un TargetPage fuori intervallo non falliva; ricadeva sul catalogo, quindi un refuso trasformava un'associazione a livello di pagina in una a livello di documento senza alcun segnale. Dalla v3.122.0, SaveAsWithAssociateFiles e SaveAsWithAssociateFilesToStream sollevano EPdfError quando TargetPage sta fuori da 0..PageCount, e InjectAssociateFiles solleva il nuovo EPdfAssocFilesError per un TargetPage negativo o che non nomina alcuna pagina esistente, lasciando lo stream di destinazione invariato. Prima della v3.121.4 la ricerca della pagina scansionava i byte salvati alla ricerca di dizionari /Type /Page in ordine di file, il che poteva attaccare il file a una pagina diversa una volta che gli oggetti pagina erano memorizzati in un ordine diverso da quello di visualizzazione, per esempio dopo che le pagine erano state riordinate o inserite; dalla v3.121.4 TargetPage nomina la pagina in quella posizione nell'ordine delle pagine del documento

Dove atterra l'array AF in PDFium Component: TargetPage zero lo attacca al catalogo, le pagine da 1 a N lo attaccano al dizionario di pagina, e un valore fuori intervallo, che prima della v3.122.0 ricadeva in silenzio sul catalogo, ora solleva un'eccezione, mentre l'iniettore aggiunge tutto come un aggiornamento incrementale unico in un layout fisso che conserva gli offset esistenti e sostituisce qualsiasi voce AF precedente
Prima della v3.122.0 un TargetPage fuori intervallo diventava in silenzio un'associazione a livello di documento; le release attuali sollevano invece, e una seconda chiamata con una lista di file diversa vince comunque
procedure AttachChartSource(Pdf: TPdf; PageNumber: Integer;
  const CsvBytes: TBytes; const OutPath: string);
var
  Options: TAssocFilesOptions;
begin
  // Dalla v3.122.0 un TargetPage fuori intervallo solleva EPdfError (le build
  // precedenti ricadevano in silenzio su un /AF a livello di catalogo); controllare prima nomina la pagina
  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;

Come si rilegge AFRelationship in modo affidabile?

TPdf.AttachmentRelationship[Index] restituisce il nome /AFRelationship di un allegato tramite l'export nativo FPDFAttachment_GetAFRelationship, ma una stringa vuota ha due significati possibili, quindi chiamate prima AttachmentRelationshipFeaturesAvailable. Il binding viene caricato in modo tollerante: quando la DLL PDFium manca di quell'export, ogni relazione si legge come vuota, il che è indistinguibile da una file specification che semplicemente non ha /AFRelationship. La proprietà condivide il suo indice con AttachmentCount, che conta le voci nell'albero /Names /EmbeddedFiles. L'iniettore scrive solo la catena /AF e non aggiunge una voce all'albero dei nomi, quindi un file allegato tramite InjectAssociateFiles sta fuori da quell'indice; per confermare la catena iniettata, ispezionate i byte salvati o eseguite un validator PDF/A. Le interiorità di quell'albero dei nomi sono trattate in lavorare con gli allegati PDF in 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 risposta vuota sarebbe ambigua, quindi non chiedete
  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;

Che cosa SaveAsWithAssociateFiles non garantisce?

TPdf.SaveAsWithAssociateFiles garantisce l'involucro del formato file e che i file richiesti siano stati iniettati, non la conformità. La parte dell'iniezione è nuova: prima della v3.122.0, quando i byte salvati non avevano un trailer leggibile o il dizionario catalogo non era individuabile, InjectAssociateFiles copiava l'input invariato e il metodo restituiva comunque True. Dalla v3.122.0 InjectAssociateFiles solleva EPdfAssocFilesError in quei casi prima di scrivere qualsiasi cosa, SaveAsWithAssociateFiles restituisce False, e poiché ora costruisce l'output completo in uno store di salvataggio prima di aprire il bersaglio, un salvataggio rifiutato o fallito non tronca più un file esistente. Un array Files vuoto copia comunque il documento invariato per design. Anche il contenuto del payload è responsabilità vostra: l'iniettore non controlla che un file XML sia ben formato, che il tipo MIME corrisponda ai byte, o che il documento base sia PDF/A a tutti gli effetti. Trattate il file finale come non verificato finché un validator non l'ha visto, la stessa disciplina descritta in PDFium Component e conformità archivistica PDF/A. Se analizzate anche voi i dizionari in ingresso, le stesse regole dei nomi #XX valgono al contrario, argomento trattato in le trappole dei name token nell'analizzare dizionari PDF

File associati, output PDF/A, metadati degli allegati e validazione viaggiano tutti nello stesso componente, quindi la pipeline sopra gira senza una seconda libreria PDF nel build. Il riferimento API, il download della trial e le opzioni di licenza sono sulla pagina del prodotto PDFium Component