Articol tehnic

Fișiere asociate PDF/A-3 și AFRelationship în Delphi

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

Lanțul de trei obiecte al unui fișier asociat PDF A-3 în PDFium Component: un flux EmbeddedFile cu un Subtype MIME precum application xml, o specificație de fișier cu F, UF, EF și AFRelationship setat pe Data, și un tablou AF pentru el din catalog sau dintr-o pagină, cele trei obiecte pe care un validator le verifică înainte să treacă clauza 6.8 din ISO 19005-3
Fluxul, specificația de fișier și tabloul AF trebuie să fie de acord; înglobarea simplă prin name-tree a lui TPdf.CreateAttachment nu setează niciun câmp de asociere și nu o va face niciodată

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 calcul
  • afData → /Data: date lizibile de mașină din care conținutul vizibil a fost derivat sau pe care le reprezintă
  • afAlternative → /Alternative, afSupplement → /Supplement, afUnspecified → /Unspecified
  • afEncryptedPayload, 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ă

De ce subtype-ul MIME text slash plain a spars parsarea PDF A-3 în PDFium Component: concatenarea valorii după un slash producea două obiecte nume, /text ca valoare plus un /plain atârnat care deechilibra dicționarul EmbeddedFile, iar repararea din v3.121.2 trece valoarea prin EscapePdfName astfel încât /text#2Fplain este un singur nume care decodează la text/plain
Eșecul părea o corupție de fișier pentru că se întâmpla la parser, înaintea oricărei reguli PDF/A; numele escapate ține perechile echilibrate și validatorul citind
// 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

Unde aterizează tabloul AF în PDFium Component: TargetPage zero îl atașează catalogului, paginile 1 până la N îl atașează dicționarului paginii, iar o valoare în afara intervalului, care înainte de v3.122.0 cădea în tăcere pe catalog, ridică acum o excepție, în timp ce injectorul adaugă totul ca o singură actualizare incrementală într-un aranjament fix care păstrează offseturile existente și înlocuiește orice intrare AF anterioară
Înainte de v3.122.0 un TargetPage în afara intervalului devenea în tăcere o asociere la nivel de document; versiunile actuale ridică în schimb excepție, iar un al doilea apel cu altă listă de fișiere câștigă tot
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