Технічна стаття

PDF/A-3 Associated Files та AFRelationship у Delphi

Щоб причепити вихідний файл до документа 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 забороняє вбудовані файли, наскільки б охайними не були метадані

Ланцюжок трьох об'єктів associated file у PDF/A-3 у PDFium Component: потік EmbeddedFile з MIME Subtype на кшталт application xml, файлова специфікація з F, UF, EF і AFRelationship у значенні Data і масив AF для неї з каталогу чи сторінки — три об'єкти, які перевіряє валідатор, перш ніж пройде пункт 6.8 ISO 19005-3
Потік, файлова специфікація і масив AF мусять згоджуватися; просте вбудовування в name tree від TPdf.CreateAttachment не ставить жодного поля асоціації і не поставить

Значення relationship — та частина, яку люди люблять вгадувати. TPdfAFRelationship у FPdfAssocFiles відображає один член enum на кожен токен імені, який інжектор може емітувати, і лише перші п'ять належать до підмножини, яку визнає ISO 19005-3:

  • afSource → /Source: оригінал, з якого зроблено PDF, як-от текстовий документ чи електронна таблиця
  • afData → /Data: машиночитані дані, з яких походить видимий вміст або які він представляє
  • afAlternative → /Alternative, afSupplement → /Supplement, afUnspecified → /Unspecified
  • afEncryptedPayload, 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 і звіряє точний закодований вихід

Чому MIME-підтип text slash plain зламав розбір PDF/A-3 у PDFium Component: конкатенація значення після слеша давала два об'єкти-імена, /text як значення плюс завислий /plain, що розбалансовував словник EmbeddedFile, а фікс v3.121.2 проганяє значення через EscapePdfName, тож /text#2Fplain — одне ім'я, що декодується в text/plain
Збій виглядав як пошкодження файлу, бо траплявся в парсері, до будь-якого правила PDF/A; екрановане ім'я тримає пари збалансованими, а валідатор — читаючим
// Що інжектор пише для 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 називає сторінку на тій позиції в порядку сторінок документа

Куди приземляється масив AF у PDFium Component: TargetPage нуль причіплює його до каталогу, сторінки 1–N — до словника сторінки, а значення поза діапазоном, яке до v3.122.0 мовчки падало назад до каталогу, тепер підіймає виняток, тоді як інжектор дописує все одним інкрементальним оновленням у фіксованому layout, що тримає наявні зсуви і замінює будь-який раніший вхід AF
До v3.122.0 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