Щоб причепити вихідний файл до документа PDF/A-3 з Delphi, PDFium Component пише ланцюжок associated-files за PDF 2.0: потік вбудованого файлу з MIME /Subtype, файлову специфікацію з /AFRelationship і масив /AF, підвішений на каталозі чи сторінці. InjectAssociateFiles і TPdf.SaveAsWithAssociateFiles збудовують той ланцюжок одним інкрементальним оновленням, а від v3.121.2 MIME-тип серіалізується як єдине, правильно екрановане PDF-ім'я. Решта цього посту — про те, що перевіряє валідатор, односимвольний баг, який зламав text/plain, і місця, де старіші релізи тихо робили щось інше, ніж ви просили
Що насправді потрібно associated file у PDF/A-3?
Вкладення PDF/A-3 проходить валідацію лише коли три об'єкти згодні один з одним: потік вбудованого файлу заявляє /Type /EmbeddedFile плюс MIME /Subtype, словник файлової специфікації (ISO 32000-2 §7.11.3) несе /F, /UF, /EF і /AFRelationship, а щось у документі посилається на ту файлову специфікацію через масив /AF (ISO 32000-2 §14.13). Просте вбудовування через дерево /Names /EmbeddedFiles — те, що робить TPdf.CreateAttachment — взагалі не ставить полів асоціації. Власна фікстура валідації PDF/A-3b у PDFium Component робить залежність конкретною: перейменуйте лише ключ /AFRelationship — і файл провалює рівно одне правило в пункті 6.8 ISO 19005-3; приберіть лише MIME /Subtype — інше правило 6.8 провалюється; покладіть те саме вкладення в кандидата PDF/A-1b — і його відкинуть одразу, бо PDF/A-1 забороняє вбудовані файли, наскільки б охайними не були метадані
Значення relationship — та частина, яку люди люблять вгадувати. TPdfAFRelationship у FPdfAssocFiles відображає один член enum на кожен токен імені, який інжектор може емітувати, і лише перші п'ять належать до підмножини, яку визнає ISO 19005-3:
afSource→/Source: оригінал, з якого зроблено PDF, як-от текстовий документ чи електронна таблицяafData→/Data: машиночитані дані, з яких походить видимий вміст або які він представляєafAlternative→/Alternative,afSupplement→/Supplement,afUnspecified→/UnspecifiedafEncryptedPayload,afFormData,afTemplate: доповнення PDF 2.0, що випадають з підмножини PDF/A-3, тож тримайте їх подалі від архівного виходу
Чому /Subtype /text/plain зламав валідацію?
MIME-баг був помилкою токенізації, а не прогалиною відповідності: до v3.121.2 інжектор конкатенував рядок викликача просто після слеша, видаючи /Subtype /text/plain. У синтаксисі PDF другий слеш починає новий об'єкт-ім'я (ISO 32000-1 §7.3.5), тож словник потоку раптом тримав ключ /Subtype, ім'я /text і завислий зайвий /plain, що розбалансував пари ключ-значення. Незалежний валідатор PDF/A відкидав файл ще при розборі словника EmbeddedFile, так і не дійшовши до жодного правила PDF/A, — тому збій виглядав як пошкодження файлу, а не як відсутня властивість вкладення
Фікс проганяє MIME-значення через EscapePdfName, який емітує /text#2Fplain: одне ім'я, чиє декодоване значення — text/plain. Екранування навмисно ширше за слеш. Кожен байт на 32 і нижче (пробіл, табуляція, CR, LF), кожен байт на 127 і вище, розділові знаки ()<>[]{}/% і сам escape-символ # стають #XX. Екранування одного лише слеша лишило б іншу дірку: MIME-рядок із >> чи пробільними символами міг би закрити словник раніше часу або вколоти зайві ключі, тож регресійний тест згодовує вороже значення з кожним розділовим знаком плюс табуляцією, LF і CR і звіряє точний закодований вихід
// Що інжектор пише для MIMEType = 'text/plain'
// до v3.121.2: /Type /EmbeddedFile /Subtype /text/plain (два імені)
// v3.121.2: /Type /EmbeddedFile /Subtype /text#2Fplain (одне ім'я)
//
// Викликачі завжди передають звичайне MIME-значення. Попереднє екранування
// власноруч подвійно кодує '#', що перетворює 'text#2Fplain' на 'text#232Fplain'
Options.Files[0].MIMEType := 'text/plain';
Збірка файлу PDF/A-3 через InjectAssociateFiles
Для виходу PDF/A-3 зробіть відповідну базову документа через TPdf.SaveAsPdfAToStream, а тоді викличте InjectAssociateFiles на тому потоці; саме цей двокроковий конвеєр проганяє фікстура валідації, перш ніж пройти PDF/A-3b. TPdf.SaveAsWithAssociateFiles — обгортка для зручності, але вона зберігає через звичайний шлях SaveAs з saRemoveSecurity, а не через PDF/A-письменник, тож не додає XMP-ідентифікації та output intent, яких вимагає PDF/A. Зауважте, що типи записів живуть у FPdfAssocFiles і FPdfPdfa, тож обидва юніти належать до вашого uses. Від v3.121.3 FileName і Description більше не мусять бути голим ASCII: /UF і /Desc пишуться як PDF текстові рядки — друкований ASCII буквально, а все інше як UTF-16BE з byte order mark, тоді як легасі-ім'я /F — завжди переносимий друкований ASCII, де кожен інший символ замінено на _, тож читачі, що декодують /F своєю кодовою сторінкою, побачать підкреслення замість кракозябр. Старіші збірки конвертували всі три через системну ANSI-кодову сторінку на Delphi чи писали сирі UTF-8 байти на Free Pascal, тож тримайте імена ASCII лише якщо старіші збірки мусять дати той самий вихід
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 на рівні каталогу
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); // перемотує Base; підіймає EPdfAssocFilesError при провалі
finally
Output.Free;
end;
finally
Base.Free;
end;
end;
Каталог чи сторінка: куди приземляється масив /AF?
TAssocFilesOptions.TargetPage вирішує власника масиву /AF: 0 причіплює його до каталогу як асоціацію на рівні документа, а 1..N — до словника тієї сторінки, нумерація від 1. Інжектор дописує все одним інкрементальним оновленням у фіксованому layout (вбудовані потоки, потім файлові специфікації, потім масив /AF, потім переписаний об'єкт каталогу чи сторінки), тож наявні об'єкти тримають свої зсуви, і нічого не перетискається. Будь-який раніший вхід /AF на цільовому словнику замінюється, а не зливається, що робить повторне збереження ідемпотентним, але також означає: другий виклик з іншим списком файлів перемагає. Дві поведінки колись заслуговували на сторожа у власному коді, і обидві змінилися. До v3.122.0 TargetPage поза діапазоном не провалювався; він падав назад до каталогу, тож одрук перетворював асоціацію на рівні сторінки на асоціацію на рівні документа без жодного сигналу. Від v3.122.0 SaveAsWithAssociateFiles і SaveAsWithAssociateFilesToStream підіймають EPdfError, коли TargetPage поза 0..PageCount, а InjectAssociateFiles підіймає новий EPdfAssocFilesError для від'ємного TargetPage чи такого, що не називає жодної наявної сторінки, лишаючи цільовий потік немодифікованим. До v3.121.4 пошук сторінки сканував збережені байти на словники /Type /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 (старіші збірки
// мовчки падали назад до /AF на рівні каталогу); перевірка спершу називає сторінку
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 вкладення через нативний експорт FPDFAttachment_GetAFRelationship, але порожній рядок має два можливі значення, тож спершу викличте AttachmentRelationshipFeaturesAvailable. Біндінг завантажується поблажливо: коли в PDFium DLL немає того експорту, кожен relationship читається як порожній, що невиразно від файлової специфікації, яка просто не має /AFRelationship. Властивість також ділить індекс із AttachmentCount, який рахує входи в дереві /Names /EmbeddedFiles. Інжектор пише лише ланцюжок /AF і не додає входу в name tree, тож файл, причеплений через InjectAssociateFiles, поза тим індексом; щоб підтвердити інжектований ланцюжок, огляньте збережені байти чи прогоніть валідатор PDF/A. Внутрішність того дерева імен розібрана в статті про роботу з 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 чи словник каталогу не вдавалося знайти, InjectAssociateFiles копіював вхід без змін, а метод досі повертав True. Від v3.122.0 InjectAssociateFiles підіймає EPdfAssocFilesError у тих випадках перед будь-яким записом, SaveAsWithAssociateFiles повертає False, і оскільки він тепер будує повний вихід у save store перед відкриттям цілі, відхилене чи провалене збереження більше не обрізає наявний файл. Порожній масив Files досі пропускає документ крізь без змін за задумом. Вміст payload-а — теж ваша відповідальність: інжектор не перевіряє, що XML-файл well-formed, що MIME-тип збігається з байтами чи що базовий документ взагалі PDF/A. Трактовте фінальний файл як неверифікований, доки його не бачив валідатор, — та сама дисципліна, описана в статті про PDFium Component і архівну відповідність PDF/A. Якщо ви також парсите вхідні словники самі, ті самі правила імен #XX діють навпаки — тема розібрана в підводних каменях токенів імен при розборі словників PDF
Associated files, вихід PDF/A, метадані вкладень і валідація виходять в одному компоненті, тож конвеєр вище працює без другої PDF-бібліотеки в збірці. Довідник API, пробне завантаження і варіанти ліцензування — на сторінці продукту компонент PDFium для Delphi