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
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 calcoloafData→/Data: dati leggibili dalla macchina da cui il contenuto visibile deriva o che rappresentaafAlternative→/Alternative,afSupplement→/Supplement,afUnspecified→/UnspecifiedafEncryptedPayload,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
// 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
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