Tehnički članak

PDF/A-3 pridružene datoteke i AFRelationship u Delphiju

Da bi iz Delphija prikačili izvornu datoteku uz PDF/A-3 dokument, PDFium Component zapisuje PDF 2.0 lanac associated-fileova: embedded file stream s MIME /Subtype-om, file specifikaciju koja nosi /AFRelationship i /AF niz obješen na katalog ili stranicu. InjectAssociateFiles i TPdf.SaveAsWithAssociateFiles taj lanac grade u jednom inkrementalnom updateu, a od v3.121.2 MIME tip serializira se kao jedno ispravno escapirano PDF ime. Ostatak ovog posta pokriva što validator provjerava, grešku od jednog znaka koja je slomila text/plain, i mjesta gdje su starija izdanja tiho radila nešto drugo nego što ste tražili

Što PDF/A-3 pridružena datoteka zapravo treba?

PDF/A-3 privitak prolazi validaciju samo kad se tri objekta slažu jedan s drugim: embedded file stream deklarira /Type /EmbeddedFile plus MIME /Subtype, rječnik file specifikacije (ISO 32000-2 §7.11.3) nosi /F, /UF, /EF i /AFRelationship, i nešto u dokumentu referencira tu file specifikaciju kroz /AF niz (ISO 32000-2 §14.13). Obično ugrađivanje kroz /Names /EmbeddedFiles stablo, koje radi TPdf.CreateAttachment, polja asocijacije uopće ne postavlja. PDFium Componentov vlastiti PDF/A-3b validacijski fixture tu ovisnost čini konkretnom: preimenujte samo ključ /AFRelationship pa datoteka pada na točno jedno pravilo u klauzuli 6.8 ISO 19005-3; skinite samo MIME /Subtype pa pada drugo pravilo 6.8; stavite isti privitak u PDF/A-1b kandidata pa se odbacuje odmah, jer PDF/A-1 zabranjuje ugrađene datoteke koliko god bili uredni metapodaci

Lanac od tri objekta PDF A-3 pridružene datoteke u PDFium Componentu: EmbeddedFile stream s MIME Subtype poput application xml, file specifikacija s F, UF, EF i AFRelationship postavljenim na Data i AF niz za nju iz kataloga ili stranice, tri objekta koje validator provjerava prije nego klauzula 6.8 ISO 19005-3 prolazi
Stream, file specifikacija i AF niz moraju se slagati; obično name-tree ugrađivanje TPdf.CreateAttachmenta ne postavlja nijedno polje asocijacije i nikad neće

Vrijednost relacije dio je koji ljudi vole pogađati. TPdfAFRelationship u FPdfAssocFiles mapira po jednog člana enuma na svaki name token koji injector smije ispisati, i samo prvih pet pripada podskupu koji ISO 19005-3 prepoznaje:

  • afSource → /Source: izvornik iz kojeg je PDF proizveden, poput datoteke za obradu teksta ili proračunske tablice
  • afData → /Data: strojno čitljivi podaci iz kojih je vidljivi sadržaj izveden ili ih on predstavlja
  • afAlternative → /Alternative, afSupplement → /Supplement, afUnspecified → /Unspecified
  • afEncryptedPayload, afFormData, afTemplate: PDF 2.0 dodaci koji ispadaju iz PDF/A-3 podskupa, pa ih držite izvan arhivskog izlaza

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

MIME greška bila je greška tokenizacije, ne rupa u usklađenosti: prije v3.121.2 injector je string pozivatelja nadovezao ravno iza kose crte, proizvodeći /Subtype /text/plain. U PDF sintaksi druga kosa crta počinje novi name objekt (ISO 32000-1 §7.3.5), pa je rječnik streama odjednom držao ključ /Subtype, ime /text i višak ime /plain koje je ostalo bez para i izbacilo iz ravnoteže parove ključ-vrijednost. Nezavisni PDF/A validator odbacio je datoteku dok je parsirao rječnik EmbeddedFile, prije nego je uopće došao do PDF/A pravila, pa je pad izgledao kao oštećenje datoteke a ne nedostajuće svojstvo privitka

Popravak MIME vrijednost provlači kroz EscapePdfName, koji ispisuje /text#2Fplain: jedno ime čija je dekodirana vrijednost text/plain. Escaping je namjerno širi od kose crte. Svaki bajt na 32 ili ispod (razmak, tab, CR, LF), svaki bajt na 127 ili iznad, graničnici ()<>[]{}/% i sam # znak za escapiranje postaju #XX. Escapiranje samo kose crte ostavilo bi drugu rupu: MIME string koji sadrži >> ili bjelinu mogao bi zatvoriti rječnik ranije ili ugnijezditi višak ključeva, pa u regresijski test ide neprijateljska vrijednost sa svim graničnicima plus tab, LF i CR i provjerava se točan kodirani izlaz

Zašto je MIME subtype text slash plain slomio PDF A-3 parsiranje u PDFium Componentu: nadovezivanje vrijednosti iza kose crte proizvelo je dva name objekta, /text kao vrijednost plus višak /plain koji je izbacio iz ravnoteže EmbeddedFile rječnik, a popravak v3.121.2 provlači vrijednost kroz EscapePdfName pa je /text#2Fplain jedno ime koje se dekodira u text/plain
Pad je izgledao kao oštećenje datoteke jer se dogodio u parseru, prije bilo kojeg PDF/A pravila; escapirano ime drži parove uravnoteženima i validatora u čitanju
// Što injector zapisuje za MIMEType = 'text/plain'
//   prije v3.121.2:  /Type /EmbeddedFile /Subtype /text/plain     (dva imena)
//   v3.121.2:         /Type /EmbeddedFile /Subtype /text#2Fplain   (jedno ime)
//
// Pozivatelji uvijek šalju običnu MIME vrijednost. Ako je sami pre-escapate
// dvostruko kodirate '#' što 'text#2Fplain' pretvara u 'text#232Fplain'
Options.Files[0].MIMEType := 'text/plain';

Gradnja PDF/A-3 datoteke s InjectAssociateFiles

Za PDF/A-3 izlaz, proizvedite sukladan bazni dokument s TPdf.SaveAsPdfAToStream pa na taj stream pozovite InjectAssociateFiles; ta dvostupanjska pipeline točno je ono što validacijski fixture izvodi prije nego prođe PDF/A-3b. TPdf.SaveAsWithAssociateFiles je udobna omota, ali sprema kroz običan SaveAs put s saRemoveSecurity umjesto kroz PDF/A writer, pa ne dodaje XMP identifikaciju i output intent koje PDF/A traži. Imajte na umu da tipovi zapisa žive u FPdfAssocFiles i FPdfPdfa, pa obje unit-e pripadaju u Vašu uses klauzulu. Od v3.121.3 FileName i Description više ne moraju biti čisti ASCII: /UF i /Desc zapisuju se kao PDF text stringovi, ispisivi ASCII doslovno a sve ostalo kao UTF-16BE s byte order markom, dok se naslijeđeno /F ime uvijek piše kao prenosivi ispisivi ASCII sa svakim drugim znakom zamijenjenim s _, pa čitači koji /F dekodiraju vlastitom kodnom stranicom prikažu podcrtu umjesto razbacanih krivo kodiranih znakova. Stariji buildovi sva tri pretvarali su kroz sistemsku ANSI kodnu stranicu na Delphiju ili pisali sirove UTF-8 bajtove na Free Pascalu, pa imena držite ASCII samo ako stariji buildovi moraju proizvesti isti 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 razini 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';  // zapisuje se 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 natrag; podiže EPdfAssocFilesError pri padu
    finally
      Output.Free;
    end;
  finally
    Base.Free;
  end;
end;

Katalog ili stranica: gdje slijeće /AF niz?

TAssocFilesOptions.TargetPage odlučuje vlasnika /AF niza: 0 ga kači na katalog kao asocijaciju razine dokumenta, a 1..N na rječnik te stranice, od 1. Injector sve dodaje kao jedan inkrementalni update u fiksnom rasporedu (embedded streamovi, pa file specifikacije, pa /AF niz, pa prepisani katalog ili page objekt), pa postojeći objekti zadržavaju svoje pomake i ništa se ne rekomprimira. Svaki raniji /AF unos na ciljanom rječniku zamjenjuje se, a ne spaja, što ponovljeno spremanje čini idempotentnim ali znači i da drugi poziv s drugačijim popisom datoteka pobjeđuje. Dva ponašanja nekoć su zasluživala vlastitu strazu u Vašem kodu, i oboje se promijenilo. Prije v3.122.0 TargetPage izvan raspona nije padao; padao je natrag na katalog, pa je greška u kucanju asocijaciju razine stranice pretvarala u asocijaciju razine dokumenta bez ikakvog signala. Od v3.122.0 SaveAsWithAssociateFiles i SaveAsWithAssociateFilesToStream podižu EPdfError kad je TargetPage izvan 0..PageCount, a InjectAssociateFiles podiže novi EPdfAssocFilesError za negativan TargetPage ili onaj koji ne imenuje postojeću stranicu, ostavljajući odredišni stream nepromijenjenim. Prije v3.121.4 pretraga stranice skenirala je spremljene bajtove za rječnike /Type /Page u redoslijedu datoteke, što je moglo prikačiti datoteku na drugu stranicu jednom kad su se page objekti pohranili u drugačijem redoslijedu nego što se prikazuju, primjerice nakon što su stranice presložene ili umetnute; od v3.121.4 TargetPage imenuje stranicu na toj poziciji u redoslijedu stranica dokumenta

Gdje slijeće AF niz u PDFium Componentu: TargetPage nula kači ga na katalog, stranice 1 do N na rječnik stranice, a vrijednost izvan raspona, koja je prije v3.122.0 tiho padala natrag na katalog, sada podiže iznimku, dok injector sve dodaje kao jedan inkrementalni update u fiksnom rasporedu koji čuva postojeće pomake i zamjenjuje svaki raniji AF unos
Prije v3.122.0 TargetPage izvan raspona tiho je postajao asocijacijom razine dokumenta; sadašnja izdanja podižu iznimku, a drugi poziv s drugačijim popisom datoteka i dalje pobjeđuje
procedure AttachChartSource(Pdf: TPdf; PageNumber: Integer;
  const CsvBytes: TBytes; const OutPath: string);
var
  Options: TAssocFilesOptions;
begin
  // Od v3.122.0 TargetPage izvan raspona podiže EPdfError (stariji buildovi
  // tiho su padali na /AF razine kataloga); provjera ispred 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 AFRelationship pouzdano pročitati natrag?

TPdf.AttachmentRelationship[Index] vraća /AFRelationship ime privitka kroz nativni export FPDFAttachment_GetAFRelationship, ali prazan string ima dva moguća značenja, pa prvo pozovite AttachmentRelationshipFeaturesAvailable. Binding se loada blago: kad PDFium DLL-u fali taj export, svaka relacija čita se kao prazna, što je nerazlučivo od file specifikacije koja jednostavno nema /AFRelationship. Svojstvo dijeli indeks s AttachmentCount, koji broji unose u /Names /EmbeddedFiles stablu. Injector piše samo /AF lanac i ne dodaje name-tree unos, pa je datoteka prikačena kroz InjectAssociateFiles izvan tog indeksa; da potvrdite injektirani lanac, pregledajte spremljene bajtove ili pustite PDF/A validator. Unutrašnjost tog name stabla pokrivena je u radu s PDF privicima u Delphiju 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;

Što SaveAsWithAssociateFiles ne jamči?

TPdf.SaveAsWithAssociateFiles jamči omotnicu formata datoteke i da su zatražene datoteke injektirane, a ne usklađenost. Dio o injektiranju je nov: prije v3.122.0, kad spremljeni bajtovi nisu imali čitljiv trailer ili se rječnik kataloga nije mogao locirati, InjectAssociateFiles kopirao je ulaz kroz nepromijenjen a metoda je i dalje vraćala True. Od v3.122.0 InjectAssociateFiles u tim slučajevima podiže EPdfAssocFilesError prije nego što bilo što napiše, SaveAsWithAssociateFiles vraća False, i budući da sada gradi kompletan izlaz u save storeu prije nego otvori cilj, odbijeno ili palo spremanje više ne odrezuje postojeću datoteku. Prazan Files niz dizajnom i dalje provlači dokument kroz nepromijenjen. Sadržaj tereta je isto Vaša odgovornost: injector ne provjerava je li XML dobro oblikovan, da li MIME tip odgovara bajtovima ili je li bazni dokument uopće PDF/A. Konačnu datoteku tretirajte kao neprovjerenu dok je validator ne vidi, ista disciplina opisana u PDFium Component i PDF/A arhivskoj usklađenosti. Ako ulazne rječnike i sami parsirate, ista #XX name pravila vrijede obrnuto, tema obrađena u zamkama name tokena pri parsiranju PDF rječnika

Associated fileovi, PDF/A izlaz, metapodaci privitaka i validacija isporučuju se u istoj komponenti, pa pipeline gore radi bez druge PDF biblioteke u buildu. API referenca, probna verzija i licencne opcije su na stranici proizvoda PDFium Component