Tehnički članak

PDF/A-3 pridruženi fajlovi i AFRelationship u Delphi-ju

Da biste iz Delphi-ja zakačili izvorni fajl na PDF/A-3 dokument, PDFium Component upisuje PDF 2.0 lanac pridruženih fajlova: embedded file stream sa MIME /Subtype-om, file specification koja nosi /AFRelationship i /AF niz okačen na katalog ili stranicu. InjectAssociateFiles i TPdf.SaveAsWithAssociateFiles grade taj lanac u jednom inkrementalnom ažuriranju, i od v3.121.2 MIME tip se serijalizuje kao jedno, pravilno escapovano PDF ime. Ostatak teksta pokriva šta validator proverava, bug od jednog znaka koji je slomio text/plain i mesta na kojima su starija izdanja tiho radila nešto drugačije od onoga što ste tražili

Šta PDF/A-3 pridruženi fajl zapravo treba?

PDF/A-3 prilog prolazi validaciju samo kada se tri objekta slažu međusobno: embedded file stream deklariše /Type /EmbeddedFile plus MIME /Subtype, rečnik file specification (ISO 32000-2 §7.11.3) nosi /F, /UF, /EF i /AFRelationship, i nešto u dokumentu referencira tu file specification kroz /AF niz (ISO 32000-2 §14.13). Obično ugrađivanje kroz /Names /EmbeddedFiles stablo, kakvo radi TPdf.CreateAttachment, polja asocijacije uopšte ne postavlja. PDFium Component-ov sopstveni PDF/A-3b validacioni fixture tu zavisnost čini konkretnom: preimenujte samo ključ /AFRelationship i fajl pada na tačno jedno pravilo u tački 6.8 ISO 19005-3; izbacite samo MIME /Subtype i pada drugo pravilo iz 6.8; ubacite isti prilog u PDF/A-1b kandidata i odmah je odbijen, jer PDF/A-1 zabranjuje ugrađene fajlove ma koliko metapodaci bili uredni

Lanac od tri objekta PDF/A-3 pridruženog fajla u PDFium Component: EmbeddedFile stream sa MIME Subtype-om poput application xml, file specification sa F, UF, EF i AFRelationship postavljenim na Data, i AF niz za njega sa kataloga ili stranice, tri objekta koje validator proverava pre nego što prođe tačka 6.8 ISO 19005-3
Stream, file specification i AF niz moraju se slagati; obično name-tree ugrađivanje iz TPdf.CreateAttachment ne postavlja nijedno polje asocijacije i neće nikada

Vrednost relacije je deo oko koga ljudi rado nagađaju. TPdfAFRelationship u FPdfAssocFiles-u preslikava po jednog člana enum-a na svaki name token koji injektor ume da emituje, i samo prvih pet pripada podskupu koji ISO 19005-3 prepoznaje:

  • afSource → /Source: original iz koga je PDF proizveden, poput fajla iz tekst procesora ili tabele
  • afData → /Data: mašinski čitljivi podaci iz kojih je vidljivi sadržaj izveden ili koje predstavlja
  • afAlternative → /Alternative, afSupplement → /Supplement, afUnspecified → /Unspecified
  • afEncryptedPayload, afFormData, afTemplate: PDF 2.0 dodaci van PDF/A-3 podskupa, pa ih držite van arhivskog izlaza

Zašto je /Subtype /text/plain slomilo validaciju?

MIME bug bila je greška u tokenizaciji, ne rupa u usaglašenosti: pre v3.121.2 injektor je string pozivaoca lepio pravo iza kose crte, proizvodeći /Subtype /text/plain. Po PDF sintaksi druga kosa crta počinje novi name objekat (ISO 32000-1 §7.3.5), pa je rečnik streama iznenada držao ključ /Subtype, ime /text i višak, objeseno ime /plain koje je izbacilo iz ravnoteže parove ključ-vrednost. Nezavisni PDF/A validator odbio je fajl tokom parsiranja EmbeddedFile rečnika, pre nego što je uopšte došao do nekog PDF/A pravila, pa je kvar izgledao kao oštećen fajl, a ne kao nedostajuće svojstvo priloga

Popravka MIME vrednost provlači kroz EscapePdfName, koji emituje /text#2Fplain: jedno ime čija je dekodovana vrednost text/plain. Escapovanje je namerno šire od same kose crte. Svaki bajt na ili ispod 32 (razmak, tab, CR, LF), svaki bajt na ili iznad 127, graničnici ()<>[]{}/% i sam # escape znak postaju #XX. Bekstvo samo kose crte ostavilo bi drugu rupu: MIME string koji sadrži >> ili beli prostor mogao je ranije zatvoriti rečnik ili ubaciti dodatne ključeve, pa regresioni test daje neprijateljsku vrednost sa svakim graničnikom plus tab, LF i CR i proverava tačan enkodovani izlaz

Zašto je MIME subtype text slash plain slomio PDF/A-3 parsiranje u PDFium Component: lepljenje vrednosti iza kose crte dalo je dva name objekta, /text kao vrednost plus objeseni /plain koji je izbacio iz ravnoteže EmbeddedFile rečnik, a popravka iz v3.121.2 vrednost provlači kroz EscapePdfName pa je /text#2Fplain jedno ime koje se dekoduje u text/plain
Kvar je izgledao kao oštećen fajl jer se desio u parseru, pre bilo kog PDF/A pravila; escapovano ime drži parove uravnoteženim i validator pri čitanju
// Šta injektor upisuje za MIMEType = 'text/plain'
//   pre v3.121.2:  /Type /EmbeddedFile /Subtype /text/plain     (dva imena)
//   v3.121.2:         /Type /EmbeddedFile /Subtype /text#2Fplain   (jedno ime)
//
// Pozivaoci uvek predaju običnu MIME vrednost. Ako je sami pre-escapujete
// duplo enkodujete '#', što od 'text#2Fplain' pravi 'text#232Fplain'
Options.Files[0].MIMEType := 'text/plain';

Građenje PDF/A-3 fajla uz InjectAssociateFiles

Za PDF/A-3 izlaz, proizvedite usaglašen osnovni dokument sa TPdf.SaveAsPdfAToStream, a zatim pozovite InjectAssociateFiles nad tim streamom; taj dvokoračni pipeline je baš ono što validacioni fixture pokreće pre nego što prođe PDF/A-3b. TPdf.SaveAsWithAssociateFiles je udobni omot, ali čuva preko obične SaveAs putanje sa saRemoveSecurity umesto preko PDF/A pisca, pa ne dodaje XMP identifikaciju i output intent koje PDF/A traži. Primetite da tipovi zapisa žive u FPdfAssocFiles-u i FPdfPdfa-i, pa obe jedinice pripadaju vašoj uses klauzuli. Od v3.121.3, FileName i Description više ne moraju biti čist ASCII: /UF i /Desc upisuju se kao PDF text stringovi, štampani ASCII bukvalno a sve ostalo kao UTF-16BE sa byte order mark-om, dok je zastarelo /F ime uvek prenosivi štampani ASCII sa svakim drugim znakom zamenjenim za _, pa čitači koji /F dekoduju svojom code page stranicom pokazuju donju crtu umesto krnjeg teksta. Stariji buildovi sva tri pretvarali su kroz sistemsku ANSI code page stranicu na Delphi-ju ili pisali sirove UTF-8 bajtove na Free Pascal-u, pa imena držite ASCII samo ako stariji buildovi moraju dati identičan izlaz

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 na nivou kataloga
  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';  // upisano kao /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);  // namota Base nazad; diže EPdfAssocFilesError pri padu
    finally
      Output.Free;
    end;
  finally
    Base.Free;
  end;
end;

Katalog ili stranica: gde sleće /AF niz?

TAssocFilesOptions.TargetPage odlučuje ko poseduje /AF niz: 0 ga kači na katalog kao asocijaciju na nivou dokumenta, a 1..N na rečnik te stranice, od 1. Injektor sve dodaje kao jedno inkrementalno ažuriranje u fiksnom rasporedu (ugrađeni streamovi, pa file specification-i, pa /AF niz, pa prepisan katalog ili objekat stranice), pa postojeći objekti zadržavaju svoje offsete i ništa se ne rekompresuje. Svaki raniji /AF unos na ciljnom rečniku zamenjuje se, ne spaja, što ponovljeno čuvanje čini idempotentnim ali znači i da drugi poziv sa drugačijom listom fajlova pobeđuje. Dva ponašanja su nekada zasluživala čuvu u vašem sopstvenom kodu, i obe su se promenila. Pre v3.122.0 TargetPage van opsega nije padao; vraćao se na katalog, pa je greška u kucanju pretvarala asocijaciju sa nivoa stranice u dokumentnu bez ikakvog signala. Od v3.122.0, SaveAsWithAssociateFiles i SaveAsWithAssociateFilesToStream dižu EPdfError kada je TargetPage van 0..PageCount, a InjectAssociateFiles diže novi EPdfAssocFilesError za negativan TargetPage ili onaj koji ne imenuje postojeću stranicu, ostavljajući odredišni stream netaknut. Pre v3.121.4 pretraga stranice skenirala je sačuvane bajtove za /Type /Page rečnike po redosledu u fajlu, što je fajl moglo zakačiti za drugu stranicu čim su objekti stranica zapisani drugačijim redosledom nego što se prikazuju, na primer posle premeštanja ili ubacivanja stranica; od v3.121.4 TargetPage imenuje stranicu na toj poziciji u redosledu stranica dokumenta

Gde sleće AF niz u PDFium Component: TargetPage nula kači ga na katalog, stranice 1 do N na rečnik stranice, a vrednost van opsega, koja je pre v3.122.0 tiho padala na katalog, sada diže izuzetak, dok injektor sve dodaje kao jedno inkrementalno ažuriranje u fiksnom rasporedu koji čuva postojeće offsete i zamenjuje svaki raniji AF unos
Pre v3.122.0 TargetPage van opsega tiho je postajao asocijacija na nivou dokumenta; trenutna izdanja dižu izuzetak, i drugi poziv sa drugačijom listom fajlova i dalje pobeđuje
procedure AttachChartSource(Pdf: TPdf; PageNumber: Integer;
  const CsvBytes: TBytes; const OutPath: string);
var
  Options: TAssocFilesOptions;
begin
  // Od v3.122.0 TargetPage van opsega diže EPdfError (stariji buildovi
  // su tiho padali na /AF nivou kataloga); provera prvo imenuje stranicu
  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;

Kako pouzdano pročitati AFRelationship nazad?

TPdf.AttachmentRelationship[Index] vraća /AFRelationship ime priloga kroz nativni FPDFAttachment_GetAFRelationship export, ali prazan string ima dva moguća značenja, pa prvo pozovite AttachmentRelationshipFeaturesAvailable. Povezivanje se učitava blago: kada PDFium DLL nema taj export, svaka relacija čita se kao prazna, što se ne razlikuje od file specification koja jednostavno nema /AFRelationship. Svojstvo deli indeks sa AttachmentCount-om, koji broji unose u /Names /EmbeddedFiles stablu. Injektor upisuje samo /AF lanac i ne dodaje unos u name-tree, pa je fajl zakačen kroz InjectAssociateFiles van tog indeksa; da potvrdite ubačeni lanac, pregledajte sačuvane bajtove ili pustite PDF/A validator. Unutrašnjost tog stabla imena pokriva rad sa PDF prilozima u Delphi-ju uz 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;  // prazan odgovor bio bi dvosmislen, pa nemojte ni pitati
  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;

Šta SaveAsWithAssociateFiles ne garantuje?

TPdf.SaveAsWithAssociateFiles garantuje omot fajl formata i da su traženi fajlovi ubačeni, a ne usaglašenost. Deo sa ubacivanjem je nov: pre v3.122.0, kada sačuvani bajtovi nisu imali čitljiv trailer ili kada se rečnik kataloga nije mogao locirati, InjectAssociateFiles prepisao bi ulaz nepromenjen a metod bi i dalje vratio True. Od v3.122.0 InjectAssociateFiles u tim slučajevima diže EPdfAssocFilesError pre nego što bilo šta napiše, SaveAsWithAssociateFiles vraća False, i pošto sada kompletan izlaz gradi u spremištu pre nego što otvori cilj, odbijeno ili pokvareno čuvanje više ne odseca postojeći fajl. Prazan Files niz i dalje po dizajnu prepisuje dokument nepromenjen. Sadržaj payload-a je isto vaša odgovornost: injektor ne proverava da li je XML fajl dobro oblikovan, da li MIME tip odgovara bajtovima, niti da li je osnovni dokument uopšte PDF/A. Konačni fajl tretirajte kao neverifikovan dok ga validator ne vidi, ista disciplina koju opisuje PDFium Component i PDF/A arhivska usaglašenost. Ako i sami parsirate pristigle rečnike, ista #XX pravila imena važe i obrnuto, tema koju pokriva zamke name tokena pri parsiranju PDF rečnika

Pridruženi fajlovi, PDF/A izlaz, metapodaci priloga i validacija stižu u istoj komponenti, pa pipeline gore radi bez druge PDF biblioteke u buildu. API referenca, probna verzija i opcije licenciranja su na stranici proizvoda PDFium Component