Ca să atașați un fișier sursă la un document PDF/A-3 din Delphi, PDFium Component scrie un lanț de fișiere asociate PDF 2.0: un flux de fișier înglobat cu un /Subtype MIME, o specificație de fișier care poartă /AFRelationship și un tablou /AF atârnat pe catalog sau pe o pagină. InjectAssociateFiles și TPdf.SaveAsWithAssociateFiles construiesc acel lanț într-o singură actualizare incrementală, iar din v3.121.2 tipul MIME e serializat ca un singur nume PDF corect escapate. Restul articolului acoperă ce verifică un validator, bug-ul de un caracter care a spart text/plain și locurile în care versiunile mai vechi făceau în tăcere altceva decât ce ați cerut
De ce are nevoie de fapt un fișier asociat PDF/A-3?
Un atașament PDF/A-3 trece validarea doar când trei obiecte sunt de acord între ele: fluxul de fișier înglobat declară /Type /EmbeddedFile plus un /Subtype MIME, dicționarul de specificație de fișier (ISO 32000-2 §7.11.3) poartă /F, /UF, /EF și /AFRelationship, iar ceva din document referențiază specificația aceea de fișier printr-un tablou /AF (ISO 32000-2 §14.13). Înglobarea simplă prin arborele /Names /EmbeddedFiles, ceea ce face TPdf.CreateAttachment, nu setează deloc câmpurile de asociere. Fixtura proprie de validare PDF/A-3b a PDFium Component face dependența concretă: redenumiți doar cheia /AFRelationship și fișierul pică exact la o singură regulă din clauza 6.8 a ISO 19005-3; renunțați doar la /Subtype-ul MIME și pică o altă regulă 6.8; puneți același atașament într-un candidat PDF/A-1b și e respins frontal, pentru că PDF/A-1 interzice fișierele înglobate indiferent cât de îngrijite sunt metadatele
Valoarea relației e partea pe care oamenii tind să o ghicească. TPdfAFRelationship din FPdfAssocFiles mapează câte un membru de enum la fiecare name token pe care injectorul îl poate emite, iar doar primele cinci aparțin submulțimii pe care ISO 19005-3 o recunoaște:
afSource→/Source: originalul din care a fost produs PDF-ul, precum un fișier de procesare de text sau o foaie de calculafData→/Data: date lizibile de mașină din care conținutul vizibil a fost derivat sau pe care le reprezintăafAlternative→/Alternative,afSupplement→/Supplement,afUnspecified→/UnspecifiedafEncryptedPayload,afFormData,afTemplate: adaosuri PDF 2.0 care cad în afara submulțimii PDF/A-3, deci țineți-le departe de ieșirile de arhivă
De ce /Subtype /text/plain a spart validarea?
Bug-ul MIME era o eroare de tokenizare, nu un gol de conformitate: înainte de v3.121.2 injectorul concatena șirul apelantului direct după un slash, producând /Subtype /text/plain. În sintaxa PDF, al doilea slash pornește un nou obiect nume (ISO 32000-1 §7.3.5), deci dicționarul de flux conținea brusc cheia /Subtype, numele /text și un nume în plus atârnat, /plain, care dezechilibra perechile cheie-valoare. Un validator PDF/A independent respingea fișierul în timp ce parsea dicționarul EmbeddedFile, înainte să ajungă vreodată la o regulă PDF/A, motiv pentru care eșecul arăta ca o corupție de fișier, nu ca o proprietate de atașament lipsă
Repararea trece valoarea MIME prin EscapePdfName, care emite /text#2Fplain: un singur nume a cărui valoare decodată este text/plain. Escaparea e deliberat mai lată decât slash-ul. Fiecare byte la sau sub 32 (spațiu, tab, CR, LF), fiecare byte la sau peste 127, delimitatorii ()<>[]{}/% și caracterul de escape # în sine devin #XX. Escaparea doar a slash-ului ar fi lăsat o altă gaură: un șir MIME care conține >> sau spațiu alb putea închide dicționarul devreme sau injecta chei în plus, deci testul de regresie hrănește o valoare ostilă cu fiecare delimitator plus tab, LF și CR și verifică exact ieșirea codată
// Ce scrie injectorul pentru MIMEType = 'text/plain'
// înainte de v3.121.2: /Type /EmbeddedFile /Subtype /text/plain (două nume)
// v3.121.2: /Type /EmbeddedFile /Subtype /text#2Fplain (un nume)
//
// Apelanții pasează mereu valoarea MIME obișnuită. Dacă o pre-escapați singuri
// dublați codarea '#'-ului, ceea ce transformă 'text#2Fplain' în 'text#232Fplain'
Options.Files[0].MIMEType := 'text/plain';
Construirea unui fișier PDF/A-3 cu InjectAssociateFiles
Pentru ieșiri PDF/A-3, produceți documentul de bază conform cu TPdf.SaveAsPdfAToStream și apoi apelați InjectAssociateFiles pe acel flux; pipeline-ul în doi pași este exact ce rulează fixtura de validare înainte să treacă PDF/A-3b. TPdf.SaveAsWithAssociateFiles este wrapper-ul de comoditate, dar salvează pe calea obișnuită SaveAs cu saRemoveSecurity în loc de calea scriitorului PDF/A, deci nu adaugă identificarea XMP și output intent-ul de care PDF/A are nevoie. De reținut că tipurile de înregistrare trăiesc în FPdfAssocFiles și FPdfPdfa, deci ambele unit-uri aparțin în clauza uses a dumneavoastră. Din v3.121.3, FileName și Description nu mai trebuie să fie ASCII simplu: /UF și /Desc se scriu ca șiruri de text PDF, ASCII tipăribil literal și orice altceva ca UTF-16BE cu marcaj de ordine a byte-ilor, în timp ce numele /F din moștenire e mereu ASCII tipăribil portabil cu orice alt caracter înlocuit de _, astfel încât cititorii care decodează /F cu propria pagină de cod arată un underscore în loc de mojibake. Build-urile mai vechi converteau toate trei prin pagina de cod ANSI a sistemului pe Delphi sau scriau byte UTF-8 brute pe Free Pascal, deci țineți numele doar-ASCII dacă build-uri mai vechi trebuie să producă aceeași ieșire
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 la nivel de catalog
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'; // scris ca /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); // reînfășoară Base; ridică EPdfAssocFilesError la eșec
finally
Output.Free;
end;
finally
Base.Free;
end;
end;
Catalog sau pagină: unde aterizează tabloul /AF?
TAssocFilesOptions.TargetPage decide deținătorul tabloului /AF: 0 îl atașează catalogului ca asociere la nivel de document, iar 1..N îl atașează dicționarului paginii aceleia, cu bază 1. Injectorul adaugă totul ca o singură actualizare incrementală într-un aranjament fix (fluxurile înglobate, apoi specificațiile de fișier, apoi tabloul /AF, apoi catalogul sau obiectul de pagină rescris), deci obiectele existente își păstrează offseturile și nimic nu e recomprimat. Orice intrare /AF anterioară pe dicționarul țintă e înlocuită, nu îmbinată, ceea ce face o salvare repetată idempotentă, dar înseamnă și că un al doilea apel cu o altă listă de fișiere câștigă. Două comportări meritau cândva o gardă în propriul cod, și ambele s-au schimbat. Înainte de v3.122.0, un TargetPage în afara intervalului nu pică; cădea pe catalog, deci o greșeală de tastare transforma o asociere la nivel de pagină într-una la nivel de document fără niciun semnal. Din v3.122.0, SaveAsWithAssociateFiles și SaveAsWithAssociateFilesToStream ridică EPdfError când TargetPage e în afara lui 0..PageCount, iar InjectAssociateFiles ridică noul EPdfAssocFilesError pentru un TargetPage negativ sau unul care nu numește nicio pagină existentă, lăsând fluxul destinație nemodificat. Înainte de v3.121.4, căutarea paginii scana byte-i salvați după dicționare /Type /Page în ordinea fișierului, ceea ce putea atașa fișierul unei alte pagini odată ce obiectele de pagină erau stocate în altă ordine decât sunt afișate, de exemplu după ce paginile au fost reordonate sau inserate; din v3.121.4 TargetPage numește pagina din poziția aceea în ordinea paginilor documentului
procedure AttachChartSource(Pdf: TPdf; PageNumber: Integer;
const CsvBytes: TBytes; const OutPath: string);
var
Options: TAssocFilesOptions;
begin
// Din v3.122.0, un TargetPage în afara intervalului ridică EPdfError (build-uri
// mai vechi cădeau în tăcere pe un /AF la nivel de catalog); verificarea dinainte numește 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;
Cum citiți AFRelationship înapoi în mod fiabil?
TPdf.AttachmentRelationship[Index] întoarce numele /AFRelationship al unui atașament prin exportul nativ FPDFAttachment_GetAFRelationship, dar un șir vid are două sensuri posibile, deci apelați AttachmentRelationshipFeaturesAvailable mai întâi. Legătura e încărcată tolerant: când DLL-ul PDFium nu are acel export, orice relație se citește vid, ceea ce e de neosemit de o specificație de fișier care pur și simplu nu are /AFRelationship. Proprietatea împarte indexul cu AttachmentCount, care numără intrările din arborele /Names /EmbeddedFiles. Injectorul scrie doar lanțul /AF și nu adaugă o intrare în name-tree, deci un fișier atașat prin InjectAssociateFiles e în afara indexului aceluia; ca să confirmați lanțul injectat, inspectați byte-i salvați sau rulați un validator PDF/A. Interiorul arborelui de nume e acoperit în lucrul cu atașamente PDF în Delphi folosind 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; // un răspuns vid ar fi ambiguu, deci nu întrebați
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;
Ce nu garantează SaveAsWithAssociateFiles?
TPdf.SaveAsWithAssociateFiles garantează plicul de format de fișier și că fișierele cerute au fost injectate, nu conformitatea. Partea de injecție e nouă: înainte de v3.122.0, când byte-i salvați nu aveau un trailer lizibil sau dicționarul catalogului nu putea fi localizat, InjectAssociateFiles copia intrarea prin neschimbată, iar metoda întorcea tot True. Din v3.122.0, InjectAssociateFiles ridică EPdfAssocFilesError în acele cazuri înainte să scrie orice, SaveAsWithAssociateFiles întoarce False, iar pentru că acum construiește ieșirea completă într-un save store înainte să deschidă ținta, o salvare respinsă sau eșuată nu mai trunchiază un fișier existent. Un tablou Files vid copie tot documentul prin neschimbat, din design. Conținutul payload-ului e tot responsabilitatea dumneavoastră: injectorul nu verifică că un fișier XML e bine format, că tipul MIME se potrivește cu byte-ii sau că documentul de bază este măcar PDF/A. Tratați fișierul final ca neverificat până nu-l vede un validator, aceeași disciplină descrisă în PDFium Component și conformitatea de arhivare PDF/A. Dacă parseați și dumneavoastră dicționare primite, aceleași reguli de nume #XX se aplică invers, subiect acoperit în capcanele name token la parsarea dicționarelor PDF
Fișierele asociate, ieșirea PDF/A, metadatele de atașament și validarea sosesc toate în aceeași componentă, deci pipeline-ul de mai sus rulează fără o a doua bibliotecă PDF în build. Referința API, descărcarea de încercare și opțiunile de licențiere sunt pe pagina de produs PDFium Component