Technický článek

Soubory Associated Files a AFRelationship v PDF/A-3 v Delphi

Chcete-li z Delphi připojit zdrojový soubor k dokumentu PDF/A-3, zapíše PDFium Component řetěz associated files podle PDF 2.0: embedded file stream s MIME /Subtype, file specification nesoucí /AFRelationship a pole /AF pověšené na katalogu nebo na stránce. InjectAssociateFiles a TPdf.SaveAsWithAssociateFiles postaví tenhle řetěz jedním inkrementálním updatem a od v3.121.2 se MIME typ serializuje jako jediný, správně escapovaný PDF name. Zbytek článku pokrývá, co kontroluje validátor, jednoznakovou chybu, která rozbila text/plain, a místa, kde starší releasy potichu udělaly něco jiného, než jste chtěli

Co doopravdy potřebuje associated file v PDF/A-3?

Příloha PDF/A-3 projde validací jen tehdy, když spolu souhlasí tři objekty: embedded file stream deklaruje /Type /EmbeddedFile plus MIME /Subtype, slovník file specification (ISO 32000-2 §7.11.3) nese /F, /UF, /EF a /AFRelationship a něco v dokumentu odkazuje na tuhle file specification přes pole /AF (ISO 32000-2 §14.13). Holé vkládání přes strom /Names /EmbeddedFiles, které dělá TPdf.CreateAttachment, nenastavuje pole asociace vůbec. Vlastní validační fixture PDF/A-3b v PDFium Component dělá závislost konkrétní: přejmenujte jen klíč /AFRelationship a soubor selže přesně na jednom pravidle klauzule 6.8 ISO 19005-3; upusťte jen MIME /Subtype a selže jiné pravidlo 6.8; dejte tutéž přílohu do kandidáta PDF/A-1b a je odmítnutá rovnou, protože PDF/A-1 zakazuje embedded files, ať je metadata sebeuklizenější

Tříobjektový řetěz associated file v PDF/A-3 v PDFium Component: stream EmbeddedFile s MIME Subtype jako application xml, file specification s F, UF, EF a AFRelationship nastaveným na Data a pole AF pro ni z katalogu nebo ze stránky — tři objekty, které validátor kontroluje, než projde klauzule 6.8 ISO 19005-3
Stream, file specification i pole AF musí souhlasit; holé vložení přes name tree v TPdf.CreateAttachment nenastavuje žádné pole asociace a nastavovat je nebude

Hodnota relationship je část, u které lidé bývají spíš tipující. TPdfAFRelationship v FPdfAssocFiles mapuje jednoho člena enumu na každý name token, který injector umí vypustit, a jen prvních pět patří do podmnožiny, kterou uznává ISO 19005-3:

  • afSource → /Source: originál, ze kterého PDF vzniklo, například textový dokument nebo tabulka
  • afData → /Data: data čitelná pro stroj, ze kterých viditelný obsah vzešel nebo které reprezentuje
  • afAlternative → /Alternative, afSupplement → /Supplement, afUnspecified → /Unspecified
  • afEncryptedPayload, afFormData, afTemplate: přírůstky z PDF 2.0, které padají mimo podmnožinu PDF/A-3, takže je z archivního výstupu držte venku

Proč rozbil /Subtype /text/plain validaci?

MIME bug byla tokenizační chyba, ne díra v compliance: před v3.121.2 injector přilepil string volajícího rovnou za lomítko a vzniklo /Subtype /text/plain. V syntaxi PDF druhé lomítko zahajuje nový name objekt (ISO 32000-1 §7.3.5), takže stream slovník najednou držel klíč /Subtype, name /text a navíc visící /plain, který rozbalansoval páry klíč-hodnota. Nezávislý PDF/A validátor soubor odmítl už při parsování slovníku EmbeddedFile, ještě než se vůbec dostal k nějakému pravidlu PDF/A, proto selhání vypadalo jako poškozený soubor místo chybějící vlastnosti přílohy

Oprava pouští MIME hodnotu přes EscapePdfName, které vypustí /text#2Fplain: jediný name, jehož dekódovaná hodnota je text/plain. Escapování je záměrně širší než lomítko. Každý bajt 32 a méně (mezera, tab, CR, LF), každý bajt 127 a víc, oddělovače ()<>[]{}/% a samotný escape znak # se mění na #XX. Escapovat jen lomítko by zanechalo jinou díru: MIME string obsahující >> nebo bílé znaky mohl slovník předčasně zavřít nebo vstříknout extra klíče, takže regresní test pouští hostilní hodnotu se všemi oddělovači plus tab, LF a CR a kontroluje přesný enkódovaný výstup

Proč MIME subtype text slash plain rozbil parsování PDF A-3 v PDFium Component: slepení hodnoty za lomítko vytvořilo dva name objekty, /text jako hodnotu plus visící /plain, které rozbalansovaly slovník EmbeddedFile, a oprava v3.121.2 pouští hodnotu přes EscapePdfName, takže /text#2Fplain je jeden name, který se dekóduje na text/plain
Selhání vypadalo jako poškozený soubor, protože se stalo v parseru, před jakýmkoli pravidlem PDF/A; escapovaný name drží páry v balanci a validátor ve čtení
// Co injector zapíše pro MIMEType = 'text/plain'
//   před v3.121.2:  /Type /EmbeddedFile /Subtype /text/plain     (dva names)
//   v3.121.2:       /Type /EmbeddedFile /Subtype /text#2Fplain   (jeden name)
//
// Volající vždy předávají obyčejnou MIME hodnotu. Předescapování na vlastní pěst
// dvakrát enkóduje '#', což z 'text#2Fplain' udělá 'text#232Fplain'
Options.Files[0].MIMEType := 'text/plain';

Stavba souboru PDF/A-3 přes InjectAssociateFiles

Pro výstup PDF/A-3 vyrobte konformní základní dokument přes TPdf.SaveAsPdfAToStream a pak na tenhle stream zavolejte InjectAssociateFiles; dvoufázová pipeline je přesně to, co validační fixture pouští, než projde PDF/A-3b. TPdf.SaveAsWithAssociateFiles je pohodlný obal, ale ukládá přes obyčejnou cestu SaveAs s saRemoveSecurity místo přes PDF/A writer, takže nepřidává XMP identifikaci ani output intent, které PDF/A vyžaduje. Všimněte si, že typy záznamů bydlí v FPdfAssocFiles a FPdfPdfa, takže do klauzule uses patří obě jednotky. Od v3.121.3 už FileName a Description nemusejí být čistě ASCII: /UF a /Desc se zapisují jako PDF text strings, tisknutelné ASCII doslova a cokoliv jiného jako UTF-16BE s byte order mark, zatímco legacy name /F je vždy přenositelné tisknutelné ASCII s každým jiným znakem nahrazeným za _, takže readery, které dekódují /F vlastní kódovou stránkou, ukážou podtržítko místo mojibake. Starší buildy konvertovaly všechny tři přes systémovou ANSI kódovou stránku na Delphi nebo zapisovaly syrové UTF-8 byty na Free Pascal, takže držte názvy v ASCII, jen pokud musí starší buildy produkovat stejný 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 katalogu
  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';  // zapsané jako /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);  // přetáče Base; při selhání vyhodí EPdfAssocFilesError
    finally
      Output.Free;
    end;
  finally
    Base.Free;
  end;
end;

Katalog nebo stránka: kam dopadne pole /AF?

TAssocFilesOptions.TargetPage rozhoduje o majiteli pole /AF: 0 ho připojí ke katalogu jako asociaci na úrovni dokumentu a 1..N ke slovníku té stránky, počítáno od 1. Injector připojuje všechno jako jediný inkrementální update v pevném rozložení (embedded streamy, pak file specifications, pak pole /AF, pak přepsaný katalog nebo page objekt), takže existující objekty drží své offsety a nic se překomprimovává. Případná dřívější položka /AF na cílovém slovníku se nahradí, neslučuje, což dělá z opakovaného uložení idempotentní operaci, ale znamená to taky, že druhé volání s jiným seznamem souborů vyhraje. Dvě chování si dřív zasloužily vlastní pojistku ve vašem kódu, obojí se změnilo. Před v3.122.0 nevyhodil mimorozsahový TargetPage chybu; spadl zpět ke katalogu, takže překlep změnil asociaci na úrovni stránky na asociaci na úrovni dokumentu bez jakéhokoli signálu. Od v3.122.0 vyhazují SaveAsWithAssociateFiles a SaveAsWithAssociateFilesToStream EPdfError, když je TargetPage mimo 0..PageCount, a InjectAssociateFiles vyhazuje nové EPdfAssocFilesError pro záporný TargetPage nebo takový, který nejmenuje žádnou existující stránku, přičemž cílový stream zůstane nezměněný. Před v3.121.4 hledal page lookup slovníky /Type /Page v uložených bytech v pořadí souboru, což mohlo připojit soubor k jiné stránce, jakmile se page objekty uložily v jiném pořadí, než v jakém se zobrazují, například po přeuspořádání nebo vložení stránek; od v3.121.4 TargetPage jmenuje stránku na té pozici v pořadí stránek dokumentu

Kam dopadne pole AF v PDFium Component: TargetPage 0 ho připojí ke katalogu, stránky 1 až N ke slovníku stránky a mimorozsahová hodnota, která se před v3.122.0 potichu vracela ke katalogu, teď vyhodí výjimku, zatímco injector připojuje všechno jako jediný inkrementální update v pevném rozložení, které drží existující offsety a nahrazuje každou dřívější položku AF
Před v3.122.0 se mimorozsahový TargetPage potichu stal asociací na úrovni dokumentu; současné releasy místo toho vyhazují výjimku a druhé volání s jiným seznamem souborů pořád vyhraje
procedure AttachChartSource(Pdf: TPdf; PageNumber: Integer;
  const CsvBytes: TBytes; const OutPath: string);
var
  Options: TAssocFilesOptions;
begin
  // Od v3.122.0 vyhodí mimorozsahový TargetPage EPdfError (starší buildy
  // potichu spadly ke /AF na úrovni katalogu); ověření nejdřív pojmenuje stránku
  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;

Jak číst AFRelationship zpět spolehlivě?

TPdf.AttachmentRelationship[Index] vrací name /AFRelationship přílohy přes nativní export FPDFAttachment_GetAFRelationship, ale prázdný string má dva možné významy, takže zavolejte nejdřív AttachmentRelationshipFeaturesAvailable. Vazba se načítá shovívavě: když PDFium DLL tenhle export nemá, čte se každý relationship jako prázdný, což je nerozlišitelné od file specification, která prostě žádné /AFRelationship nemá. Property taky sdílí index s AttachmentCount, které počítá položky ve stromu /Names /EmbeddedFiles. Injector zapisuje jen řetěz /AF a položku do name tree nepřidává, takže soubor připojený přes InjectAssociateFiles je mimo tenhle index; k potvrzení injektovaného řetěze prohlédněte uložené byty nebo pusťte PDF/A validátor. Vnitřnosti name tree pokrývá Přílohy PDF v Delphi s PDFium Component: Čtení, přidání, mazání

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ázdná odpověď by byla dvojznačná, takže se neptejte
  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;

Co SaveAsWithAssociateFiles negarantuje?

TPdf.SaveAsWithAssociateFiles garantuje obálku formátu souboru a to, že požadované soubory se injektovaly, ne konformitu. Ta injekční část je nová: před v3.122.0, když uložené byty neměly čitelný trailer nebo se nepodařilo najít slovník katalogu, zkopíroval InjectAssociateFiles vstup beze změny a metoda pořád vrátila True. Od v3.122.0 vyhazuje InjectAssociateFiles v těchto případech EPdfAssocFilesError dřív, než cokoli zapíše, SaveAsWithAssociateFiles vrací False a protože teď staví kompletní výstup v save store dřív, než otevře cílový soubor, odmítnuté nebo neúspěšné uložení už neusekne existující soubor. Prázdné pole Files záměrně pořád kopíruje dokument beze změny. Obsah payloadu je taky vaše starost: injector nekontroluje, že je XML soubor dobře formovaný, že MIME typ odpovídá bytům nebo že je základní dokument vůbec PDF/A. Berte finální soubor jako neověřený, dokud si ho neprohlédne validátor — stejná disciplína, jakou popisuje Soulad PDF/A pro archivaci v Delphi s PDFium VCL. Pokud si i vy parsujete příchozí slovníky sami, platí stejná pravidla pro name #XX obráceně — téma, které rozebírá Bezpečné analyzování PDF slovníků v Delphi: Tokeny názvů

Associated files, PDF/A výstup, metadata příloh i validace shipují ve stejné komponentě, takže výše uvedená pipeline běží bez druhé PDF knihovny v buildu. API reference, trial ke stažení a licenční volby jsou na produktové stránce PDFium Component