Odborný článok

Asociované súbory PDF/A-3 a AFRelationship v Delphi

Aby PDFium Component pripojil zdrojový súbor k dokumentu PDF/A-3 z Delphi, zapisuje reťaz asociovaných súborov PDF 2.0: embedded file stream s MIME /Subtype, file specification nesúcu /AFRelationship a pole /AF zavesené na katalógu alebo strane. InjectAssociateFiles a TPdf.SaveAsWithAssociateFiles postavia ten reťaz v jednej inkrementálnej aktualizácii a od v3.121.2 sa MIME typ serializuje ako jediné, správne escapované PDF meno. Zvyšok tohto príspevku pokrýva, čo validátor kontroluje, jednopísmenový bug, ktorý pokoril text/plain, a miesta, kde staršie vydania poticho urobili niečo iné, než ste chceli

Čo asociovaný súbor PDF/A-3 naozaj potrebuje?

Príloha PDF/A-3 prejde validáciou len vtedy, keď tri objekty sedia medzi sebou: embedded file stream deklaruje /Type /EmbeddedFile plus MIME /Subtype, slovník file specification (ISO 32000-2 §7.11.3) nesie /F, /UF, /EF a /AFRelationship a niečo v dokumente referencuje tú file specification cez pole /AF (ISO 32000-2 §14.13). Holé vloženie cez strom /Names /EmbeddedFiles, čo robí TPdf.CreateAttachment, nenastaví asociačné polia vôbec. Vlastná PDF/A-3b validačná fixtúra PDFium Component spraví tú závislosť konkrétnou: premenujte len kľúč /AFRelationship a súbor padne presne na jednom pravidle v klauzule 6.8 ISO 19005-3; zhoďte len MIME /Subtype a padne iné pravidlo 6.8; vložte tú istú prílohu do kandidáta PDF/A-1b a je odmietnutá rovno, lebo PDF/A-1 zakazuje embedded súbory, nech je metadáta akokoľvek uprataná

Reťaz troch objektov asociovaného súboru PDF A-3 v PDFium Component: EmbeddedFile stream s MIME Subtype ako application xml, file specification s F, UF, EF a AFRelationship nastaveným na Data a pole AF preň z katalógu alebo strany, tri objekty, ktoré validátor kontroluje, skôr než prejde klauzula 6.8 ISO 19005-3
Stream, file specification a pole AF musia sediať; holé vloženie cez name tree v TPdf.CreateAttachment nenastaví žiadne asociačné polia a nikdy nebude

Hodnota vzťahu je tá časť, ktorú ľudia radi hádajú. TPdfAFRelationship v FPdfAssocFiles mapuje jeden člen enumu na každý menný token, ktorý injector vie emitovať, a len prvých päť patrí do podmnožiny, ktorú uznáva ISO 19005-3:

  • afSource → /Source: originál, z ktorého PDF vzniklo, napríklad textový dokument alebo tabuľka
  • afData → /Data: strojovo čitateľné dáta, z ktorých viditeľný obsah vzišiel alebo ktoré reprezentuje
  • afAlternative → /Alternative, afSupplement → /Supplement, afUnspecified → /Unspecified
  • afEncryptedPayload, afFormData, afTemplate: prírastky PDF 2.0, ktoré padajú mimo podmnožiny PDF/A-3, takže ich držte mimo archívneho výstupu

Prečo rozbilo /Subtype /text/plain validáciu?

MIME bug bola tokenizačná chyba, nie medzera v zhode: pred v3.121.2 injector prilepil reťazec volajúceho priamo za lomku, čím vzniklo /Subtype /text/plain. V syntaxi PDF druhá lomka začína nový name objekt (ISO 32000-1 §7.3.5), takže stream slovník zrazu držal kľúč /Subtype, meno /text a visiace extra meno /plain, ktoré rozbalansovalo páry kľúč-hodnota. Nezávislý PDF/A validátor odmietol súbor už pri parsovaní slovníka EmbeddedFile, skôr než vôbec došiel k akémukoľvek pravidlu PDF/A, a preto zlyhanie vyzeralo ako poškodenie súboru namiesto chýbajúcej vlastnosti prílohy

Oprava vedie MIME hodnotu cez EscapePdfName, ktorý emituje /text#2Fplain: jedno meno, ktorého dekódovaná hodnota je text/plain. Escapovanie je zámerne širšie než len lomka. Každý bajt 32 a menej (medzera, tab, CR, LF), každý bajt 127 a viac, oddeľovače ()<>[]{}/% a samotný escape znak # sa menia na #XX. Escapovanie len lomky by nechalo inú dieru: MIME reťazec obsahujúci >> alebo biele znaky mohol slovník predčasne zatvoriť alebo vložiť extra kľúče, takže regresný test podá nepriateľskú hodnotu so všetkými oddeľovačmi plus tab, LF a CR a skontroluje presný zakódovaný výstup

Prečo MIME subtype text lomka plain rozbila parsovanie PDF A-3 v PDFium Component: prilepenie hodnoty za lomku vyrobilo dva name objekty, /text ako hodnotu plus visiace /plain, ktoré rozbalansovalo slovník EmbeddedFile, a oprava vo v3.121.2 vedie hodnotu cez EscapePdfName, takže /text#2Fplain je jedno meno, ktoré sa dekóduje na text/plain
Zlyhanie vyzeralo ako poškodenie súboru, lebo sa stalo v parseri, pred akýmkoľvek pravidlom PDF/A; escapované meno drží páry v balanse a validator v čítaní
// Čo injector zapisuje pre MIMEType = 'text/plain'
//   pred v3.121.2:  /Type /EmbeddedFile /Subtype /text/plain     (dve mená)
//   v3.121.2:         /Type /EmbeddedFile /Subtype /text#2Fplain   (jedno meno)
//
// Volajúci vždy podávajú obyčajnú MIME hodnotu. Vlastné pred-escapovanie
// zdvojnásobne zakóduje '#', čo zmení 'text#2Fplain' na 'text#232Fplain'
Options.Files[0].MIMEType := 'text/plain';

Stavba súboru PDF/A-3 s InjectAssociateFiles

Pre výstup PDF/A-3 vyrobte zhodný základný dokument cez TPdf.SaveAsPdfAToStream a potom na ten stream zavolajte InjectAssociateFiles; túto dvojfázovú pipeline presne beží validačná fixtúra skôr, než prepustí PDF/A-3b. TPdf.SaveAsWithAssociateFiles je pohodlný wrapper, ale ukladá cez obyčajnú cestu SaveAs s saRemoveSecurity namiesto cez PDF/A writer, takže nepridá XMP identifikáciu a output intent, ktoré PDF/A vyžaduje. Všímajte si, že record typy žijú v FPdfAssocFiles a FPdfPdfa, takže obe unity patria do vašej klauzuly uses. Od v3.121.3 nemusia byť FileName a Description už len čisté ASCII: /UF a /Desc sa zapisujú ako PDF textové reťazce, tlačiteľné ASCII doslovne a všetko ostatné ako UTF-16BE s byte order mark, zatiaľ čo legacy meno /F je vždy prenosné tlačiteľné ASCII so všetkými ostatnými znakmi nahradenými _, takže readery dekódujúce /F vlastnou kódovou stránkou ukážu podčiarknik namiesto mojibake. Staršie buildy konvertovali všetky tri cez systémovú ANSI kódovú stránku na Delphi alebo zapisovali surové UTF-8 bajty na Free Pascale, takže mená držte v ASCII len vtedy, keď staršie buildy musia vydať rovnaký výstup

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 úrovni katalógu
  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 sa ako /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);  // pretáča Base; pri zlyhaní vyhodí EPdfAssocFilesError
    finally
      Output.Free;
    end;
  finally
    Base.Free;
  end;
end;

Katalóg alebo strana: kam dopadne pole /AF?

TAssocFilesOptions.TargetPage rozhoduje o majiteľovi poľa /AF: 0 ho pripojí ku katalógu ako asociáciu na úrovni dokumentu a 1..N k slovníku tej strany, 1-based. Injector pripojí všetko ako jedinú inkrementálnu aktualizáciu v pevnom rozložení (embedded streamy, potom file specifications, potom pole /AF, potom prepísaný katalóg alebo objekt strany), takže existujúce objekty si držia offsety a nič sa neprekompresuje. Ďalšia staršia položka /AF na cieľovom slovníku sa nahradí, neslúči, čo robí opakované uloženie idempotentné, ale znamená to aj to, že druhé volanie s iným zoznamom súborov vyhrá. Dve správania si kedysi zaslúžili stráž v vlastnom kóde a oboje sa zmenilo. Pred v3.122.0 TargetPage mimo rozsahu nezlyhal; prepadol na katalóg, takže preklep zmenil asociáciu na úrovni strany na takú na úrovni dokumentu bez akéhokoľvek signálu. Od v3.122.0 vyhodí SaveAsWithAssociateFiles a SaveAsWithAssociateFilesToStream EPdfError, keď TargetPage je mimo 0..PageCount, a InjectAssociateFiles vyhodí nové EPdfAssocFilesError pre záporné TargetPage alebo také, ktoré nepomenuje existujúcu stranu, pričom cieľový stream nechá nezmenený. Pred v3.121.4 hľadanie strany skenovalo uložené bajty po slovníkoch /Type /Page v poradí súboru, čo mohlo pripojiť súbor k inej strane, keď boli objekty strán uložené v inom poradí, než sa zobrazujú, napríklad po presťahovaní alebo vložení strán; od v3.121.4 TargetPage menuje stránku na tej pozícii v poradí strán dokumentu

Kam dopadne pole AF v PDFium Component: TargetPage nula ho pripojí ku katalógu, strany 1 až N k slovníku strany a hodnota mimo rozsahu, ktorá pred v3.122.0 poticho prepadla na katalóg, teraz vyhodí výnimku, zatiaľ čo injector pripojí všetko ako jednu inkrementálnu aktualizáciu v pevnom rozložení, ktoré drží existujúce offsety a nahrádza ďalšiu staršiu položku AF
Pred v3.122.0 sa TargetPage mimo rozsahu poticho stal asociáciou na úrovni dokumentu; aktuálne vydania radšej vyhodia výnimku a druhé volanie s iným zoznamom súborov stále vyhrá
procedure AttachChartSource(Pdf: TPdf; PageNumber: Integer;
  const CsvBytes: TBytes; const OutPath: string);
var
  Options: TAssocFilesOptions;
begin
  // Od v3.122.0 vyhodí TargetPage mimo rozsahu EPdfError (staršie buildy
  // poticho prepadli na /AF na úrovni katalógu); kontrola vopred pomenuje stranu
  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;

Ako spoľahlivo načítať AFRelationship späť?

TPdf.AttachmentRelationship[Index] vracia meno /AFRelationship prílohy cez natívny export FPDFAttachment_GetAFRelationship, ale prázdny reťazec má dva možné významy, takže zavolajte najprv AttachmentRelationshipFeaturesAvailable. Binding sa načítava zhovievavo: keď PDFium DLL ten export postráda, každý vzťah sa číta ako prázdny, čo je nerozlíšiteľné od file specification, ktorá jednoducho nemá /AFRelationship. Vlastnosť tiež zdieľa index s AttachmentCount, ktoré počíta položky v strome /Names /EmbeddedFiles. Injector zapisuje len reťaz /AF a položku name tree nepridáva, takže súbor pripojený cez InjectAssociateFiles je mimo tohto indexu; na potvrdenie injektovaného reťaza prehliadnite uložené bajty alebo pusťte PDF/A validátor. Vnútro toho name tree pokrýva práca s PDF prílohami v Delphi pomocou 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;  // prázdna odpoveď by bola nejednoznačná, tak sa nepýtajte
  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;

Čo SaveAsWithAssociateFiles negarantuje?

TPdf.SaveAsWithAssociateFiles garantuje obálku formátu súboru a to, že požadované súbory boli injektované, nie zhodu. Časť o injekcii je nová: pred v3.122.0, keď uložené bajty nemali čitateľný trailer alebo sa nepodarilo nájsť slovník katalógu, InjectAssociateFiles prekopíroval vstup bez zmeny a metóda aj tak vrátila True. Od v3.122.0 vyhodí InjectAssociateFiles EPdfAssocFilesError v týchto prípadoch skôr, než niečo zapíše, SaveAsWithAssociateFiles vráti False a keďže teraz stavia kompletný výstup v save store skôr, než otvorí cieľ, zamietnuté alebo zlyhané uloženie už neusekne existujúci súbor. Prázdne pole Files stále kopíruje dokument bez zmeny zámerne. Obsah payloadu je tiež vaša zodpovednosť: injector nekontroluje, že XML súbor je well formed, že MIME typ sedí s bajtmi alebo že základný dokument je vôbec PDF/A. Berte finálny súbor ako neverifikovaný, dokiaľ ho nevidí validátor, tá istá disciplína, aká je popísaná v PDFium Component a archívnej zhode PDF/A. Ak si prichádzajúce slovníky parsujete aj sami, platia rovnaké pravidlá mien #XX naopak, téma popísaná v pasciách menných tokenov pri parsovaní PDF slovníkov

Asociované súbory, PDF/A výstup, metadáta príloh aj validácia idú v tom istom komponente, takže pipeline vyššie beží bez druhej PDF knižnice v builde. API referencia, skúšobná verzia a licenčné možnosti sú na produktovej stránke PDFium Component