За да прикачите source файл към PDF/A-3 документ от Delphi, PDFium Component записва associated-file верига по PDF 2.0: embedded file stream с MIME /Subtype, file specification, носеща /AFRelationship, и /AF масив, закачен на catalog-а или на страница. InjectAssociateFiles и TPdf.SaveAsWithAssociateFiles строят тази верига в една incremental update, а от v3.121.2 MIME типът се сериализира като единично, коректно escaped PDF име. Остатъкът от поста покрива какво проверява валидатор, едносимволния бъг, който счупи text/plain, и местата, където по-старите версии тихо правеха нещо друго, освен поисканото
Какво всъщност трябва на associated файл в PDF/A-3?
Прикачен файл в PDF/A-3 минава валидацията само когато три обекта се разбират помежду си: embedded file stream-ът декларира /Type /EmbeddedFile плюс MIME /Subtype, речникът file specification (ISO 32000-2 §7.11.3) носи /F, /UF, /EF и /AFRelationship, а нещо в документа реферира тази file specification чрез /AF масив (ISO 32000-2 §14.13). Обикновеното вграждане през дървото /Names /EmbeddedFiles, което прави TPdf.CreateAttachment, изобщо не задава полетата за асоциация. Собственият PDF/A-3b валидационен fixture на PDFium Component прави зависимостта конкретна: преименувате ли само ключа /AFRelationship, файлът проваля точно едно правило в клауза 6.8 на ISO 19005-3; махнете ли само MIME /Subtype, проваля друго правило 6.8; пуснете ли същата прикачена файл в кандидат PDF/A-1b, тя се отхвърля изцяло, защото PDF/A-1 забранява embedded файлове, колкото и подредени да са метаданните
Стойността за relationship е частта, която хората обичат да гадаят. TPdfAFRelationship в FPdfAssocFiles мапва по един член на enum към всеки name token, който injector-ът може да излъчи, и само първите пет принадлежат на подмножеството, което ISO 19005-3 разпознава:
afSource→/Source: оригиналът, от който е произведен PDF-ът — например текстообработващ файл или spreadsheetafData→/Data: машиночитеми данни, от които е изведено видимото съдържание или които то представляваafAlternative→/Alternative,afSupplement→/Supplement,afUnspecified→/UnspecifiedafEncryptedPayload,afFormData,afTemplate: добавки от PDF 2.0, които са извън подмножеството на PDF/A-3, така че държете ги извън архивния изход
Защо /Subtype /text/plain счупи валидацията?
MIME бъгът беше грешка при токенизация, не дупка в съответствието: преди v3.121.2 injector-ът слепваше string-а на извикващия направо след наклонена черта, произвеждайки /Subtype /text/plain. В PDF синтаксисът втората наклонена черта започва нов name обект (ISO 32000-1 §7.3.5), така че изведнъж stream речникът съдържаше ключа /Subtype, името /text и увиснало допълнително име /plain, което разстрои двойките ключ-стойност. Независим PDF/A валидатор отхвърли файла още при парсването на речника EmbeddedFile, преди изобщо да стигне някое PDF/A правило — затова провалът изглеждаше като повреден файл, а не като липсващо свойство на прикачена файл
Fix-ът прекарва MIME стойността през EscapePdfName, който излъчва /text#2Fplain: едно име, чиято декодирана стойност е text/plain. Escaping-ът е нарочно по-широк от наклонената черта. Всеки байт на 32 или под (space, tab, CR, LF), всеки байт на 127 или над, разделителите ()<>[]{}/% и самият escape символ # стават #XX. Escaping на една черта би оставил друга дупка: MIME string, съдържащ >> или интервал, би могъл да затвори речника рано или да вкара излишни ключове, така че регресионният тест подава враждебна стойност с всеки разделител плюс tab, LF и CR и проверява точния кодиран изход
// Какво записва injector-ът за MIMEType = 'text/plain'
// преди v3.121.2: /Type /EmbeddedFile /Subtype /text/plain (два имена)
// v3.121.2: /Type /EmbeddedFile /Subtype /text#2Fplain (едно име)
//
// Извикващите винаги подават обикновената MIME стойност. Собствено pre-escaping
// двойно кодира '#', което превръща 'text#2Fplain' в 'text#232Fplain'
Options.Files[0].MIMEType := 'text/plain';
Строене на PDF/A-3 файл с InjectAssociateFiles
За PDF/A-3 изход произведете съответстващия базов документ с TPdf.SaveAsPdfAToStream и после викнете InjectAssociateFiles върху този stream; точно този двустъпков pipeline пуска валидационният fixture, преди да мине PDF/A-3b. TPdf.SaveAsWithAssociateFiles е удобната обвивка, но записва през обичайния път на SaveAs с saRemoveSecurity, а не през PDF/A writer-а, така че не добавя XMP идентификацията и output intent, които PDF/A изисква. Обърнете внимание, че record типовете живеят в FPdfAssocFiles и FPdfPdfa, така че и двата unit-а трябва да са в uses клаузата ви. От v3.121.3 FileName и Description вече не е задължително да са чист ASCII: /UF и /Desc се записват като PDF текстови string-ове — печатаем ASCII литерално, а всичко останало като UTF-16BE с byte order mark, докато legacy името /F е винаги преносим печатаем ASCII с всеки друг символ заменен от _, така че reader-и, декодирали /F със собствената си кодова страница, показват подчертавка вместо mojibake. По-старите build-ове конвертираха и трите през системната ANSI кодова страница на Delphi или записваха сурови UTF-8 байтове на Free Pascal, така че дръжте имената ASCII само ако по-стари build-ове трябва да дават същия изход
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 на ниво catalog
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'; // записано като /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); // rewind-ва Base; вдига EPdfAssocFilesError при провал
finally
Output.Free;
end;
finally
Base.Free;
end;
end;
Catalog или страница: къде каца /AF масивът?
TAssocFilesOptions.TargetPage решава кой е собственикът на /AF масива: 0 го закача за catalog-а като document-level асоциация, а 1..N — за речника на съответната страница, 1-based. Injector-ът добавя всичко като една incremental update в фиксиран layout (embedded stream-овете, после file specification-ите, после /AF масивът, после пренаписан catalog или page обект), така че съществуващите обекти пазат отместванията си и нищо не се препакова. По-ранен /AF запис на целевия речник се заменя, не се слива, което прави повторния запис idempotent, но значи и че второ извикване с друг списък файлове печели. Два поведения някога заслужаваха проверка в собствения ви код, а и двете са сменили. Преди v3.122.0 TargetPage извън диапазона не се проваляше; падаше обратно на catalog-а, така че печатна грешка превръщаше page-level асоциация в document-level без никакъв сигнал. От v3.122.0 SaveAsWithAssociateFiles и SaveAsWithAssociateFilesToStream вдигат EPdfError, когато TargetPage е извън 0..PageCount, а InjectAssociateFiles вдига новия EPdfAssocFilesError за отрицателен TargetPage или такъв, който не назовава съществуваща страница, оставяйки целевия stream недокоснат. Преди v3.121.4 издирването на страница сканираше записаните байтове за речници /Type /Page в файловия ред, което можеше да закачи файла за друга страница, щом page обектите бяха съхранени в друг ред от показания — например след пренареждане или вмъкване на страници; от v3.121.4 TargetPage назовава страницата на тази позиция в страничния ред на документа
procedure AttachChartSource(Pdf: TPdf; PageNumber: Integer;
const CsvBytes: TBytes; const OutPath: string);
var
Options: TAssocFilesOptions;
begin
// От v3.122.0 TargetPage извън диапазона вдига EPdfError (по-стари build-ове
// тихо падаха на /AF на ниво catalog); проверката първо назовава страницата
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;
Как четете AFRelationship обратно надеждно?
TPdf.AttachmentRelationship[Index] връща името /AFRelationship на прикачена файл чрез нативния export FPDFAttachment_GetAFRelationship, но празен string има две възможни значения, така че първо извикайте AttachmentRelationshipFeaturesAvailable. Обвързката се зарежда снизходително: когато PDFium DLL-ът няма този export, всяка relationship се чете като празна, което е неотличимо от file specification, която просто няма /AFRelationship. Свойството споделя индекса си с AttachmentCount, който брои записите в дървото /Names /EmbeddedFiles. Injector-ът записва само /AF веригата и не добавя запис в name tree, така че файл, прикачен чрез InjectAssociateFiles, е извън този индекс; за да потвърдите инжектираната верига, инспектирайте записаните байтове или пуснете PDF/A валидатор. Вътрешността на това name tree е разгледана в работата с PDF прикачени файлове в Delphi с 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; // празен отговор би бил двусмислен, така че не питайте
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;
Какво не гарантира SaveAsWithAssociateFiles?
TPdf.SaveAsWithAssociateFiles гарантира файловия формат-плик и че поисканите файлове са инжектирани, не съответствието. Частта за инжектиране е нова: преди v3.122.0, когато записаните байтове нямат четим trailer или речникът catalog не може да бъде намерен, InjectAssociateFiles копираше входа непроменен и методът все пак връщаше True. От v3.122.0 InjectAssociateFiles вдига EPdfAssocFilesError в тези случаи, преди да е записала нещо, SaveAsWithAssociateFiles връща False, а понеже вече строи целия изход в save store, преди да отвори целта, отхвърлен или провален запис вече не отрязва съществуващ файл. Празен Files масив по замисъл и продължава да копира документа непроменен. Съдържанието на payload-а също е ваша отговорност: injector-ът не проверява дали XML файл е well formed, дали MIME типът съвпада с байтовете или дали базовият документ изобщо е PDF/A. Отнасяйте се към крайния файл като към неверифициран, докато някой валидатор го е видял — същата дисциплина, описана в PDFium Component и PDF/A архивното съответствие. Ако сами парсвате входящи речници, същите правила за имена #XX важат обратно — тема, разгледана в капаните на name token при парсване на PDF речници
Associated файлове, PDF/A изход, метаданни на прикачени файлове и валидация пътуват в един и същ компонент, така че pipeline-ът по-горе върви без втора PDF библиотека в build-а. API справочникът, trial изтеглянето и лицензионните опции са на продуктовата страница на PDFium Component