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ší
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 tabulkaafData→/Data: data čitelná pro stroj, ze kterých viditelný obsah vzešel nebo které reprezentujeafAlternative→/Alternative,afSupplement→/Supplement,afUnspecified→/UnspecifiedafEncryptedPayload,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
// 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
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