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á
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ľkaafData→/Data: strojovo čitateľné dáta, z ktorých viditeľný obsah vzišiel alebo ktoré reprezentujeafAlternative→/Alternative,afSupplement→/Supplement,afUnspecified→/UnspecifiedafEncryptedPayload,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
// Č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
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