Техническая статья

Ассоциированные файлы PDF/A-3 и AFRelationship в Delphi

Чтобы прикрепить файл-источник к документу PDF/A-3 из Delphi, PDFium Component пишет цепочку ассоциированных файлов PDF 2.0: stream встроенного файла с MIME /Subtype, file specification с /AFRelationship и массив /AF, подвешенный на каталог или страницу. InjectAssociateFiles и TPdf.SaveAsWithAssociateFiles собирают ту цепочку одним инкрементальным обновлением, а с v3.121.2 MIME-тип сериализуется как единственное, корректно экранированное PDF-имя. Остаток поста — о том, что проверяет валидатор, об односимвольном баге, сломавшем text/plain, и о местах, где старые релизы тихо делали нечто иное, чем вы просили

Что ассоциированному файлу PDF/A-3 действительно нужно?

Вложение PDF/A-3 проходит валидацию, только когда три объекта согласны друг с другом: 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 в PDFium Component делает зависимость осязаемой: переименуйте только ключ /AFRelationship, и файл проваливает ровно одно правило клаузы 6.8 ISO 19005-3; уберите только MIME /Subtype — и падает другое правило 6.8; положите то же вложение в кандидата PDF/A-1b — его отвергнут outright, потому что PDF/A-1 запрещает встроенные файлы, какими бы опрятными ни были метаданные

Цепочка из трёх объектов ассоциированного файла PDF A-3 в PDFium Component: stream EmbeddedFile с MIME Subtype вроде application xml, file specification с F, UF, EF и AFRelationship в значении Data, и массив AF на него с каталога или страницы, — три объекта, которые проверяет валидатор, прежде чем клауза 6.8 ISO 19005-3 пройдёт
Stream, file specification и массив AF обязаны сходиться; простое встраивание через name-tree в TPdf.CreateAttachment не ставит ни одно ассоциативное поле и никогда не поставит

Значение relationship — та часть, которую обычно угадывают. TPdfAFRelationship в FPdfAssocFiles мапит по одному члену enum на каждый name-токен, который может выпустить инжектор, и лишь первые пять принадлежат подмножеству, которое признаёт 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 второй слэш начинает новый name-объект (ISO 32000-1 §7.3.5), поэтому в словаре stream внезапно оказывались ключ /Subtype, имя /text и болтающееся лишнее имя /plain, разбалансировавшее пары ключ-значение. Независимый валидатор PDF/A отвергал файл ещё при разборе словаря EmbeddedFile, не добравшись ни до одного правила PDF/A, — оттого отказ и выглядел как повреждение файла, а не как недостающее свойство вложения

Исправление прогоняет MIME-значение через EscapePdfName, который выдаёт /text#2Fplain: одно имя, чьё декодированное значение — text/plain. Экранирование нарочно шире одного слэша. Каждый байт со значением 32 и ниже (пробел, таб, CR, LF), каждый байт 127 и выше, разделители ()<>[]{}/% и сам escape-символ # становятся #XX. Экранируй мы только слэш — осталась бы другая дыра: MIME-строка с >> или whitespace могла бы закрыть словарь раньше времени или вписать лишние ключи, поэтому регрессионный тест скармливает враждебное значение со всеми разделителями плюс таб, LF и CR и сверяет точный закодированный вывод

Почему MIME-подтип text slash plain ломал парсинг PDF A-3 в PDFium Component: склейка значения после слэша давала два name-объекта, /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 на том stream'е; именно тот двухшаговый конвейер гоняет фикстура валидации, прежде чем сдать 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 text strings — печатный ASCII буквально, всё прочее как UTF-16BE с byte order mark, — тогда как legacy-имя /F всегда portable printable ASCII, где любой другой символ заменён на _, чтобы читатели, декодирующие /F собственной кодовой страницей, видели подчёркивание вместо mojibake. Более ранние сборки конвертировали все три через системную 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 — к словарю той страницы, с нумерацией от единицы. Инжектор дописывает всё одним инкрементальным обновлением в фиксированной раскладке (встроенные stream'ы, затем file specifications, затем массив /AF, затем переписанный каталог или объект страницы), так что существующие объекты сохраняют смещения и ничего не перекомпрессируется. Любая более ранняя запись /AF на целевом словаре заменяется, а не мержится, что делает повторное сохранение идемпотентным, но также означает, что второй вызов с другим списком файлов побеждает. Два поведения раньше заслуживали предохранителя в вашем коде, и оба изменились. До v3.122.0 TargetPage вне диапазона не падал, а откатывался к каталогу, так что опечатка превращала ассоциацию уровня страницы в ассоциацию уровня документа без всякого сигнала. С v3.122.0 SaveAsWithAssociateFiles и SaveAsWithAssociateFilesToStream поднимают EPdfError, когда TargetPage вне 0..PageCount, а InjectAssociateFiles поднимает новый EPdfAssocFilesError на отрицательном TargetPage или том, что не называет существующую страницу, оставляя целевой stream нетронутым. До v3.121.4 поиск страницы сканировал сохранённые байты на словари /Type /Page в файловом порядке, что могло прикрепить файл к другой странице, стоит объектам страниц лечь в файл не в том порядке, в каком отображаются, — скажем, после перестановки или вставки страниц; с v3.121.4 TargetPage называет страницу на той позиции в порядке страниц документа

Куда приземляется массив AF в PDFium Component: TargetPage ноль прикрепляет его к каталогу, страницы 1 до N — к словарю страницы, а значение вне диапазона, которое до v3.122.0 молча откатывалось к каталогу, теперь поднимает исключение, при этом инжектор дописывает всё одним инкрементальным обновлением в фиксированной раскладке, сохраняющей существующие смещения и заменяющей любую прежнюю запись 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 читается как пустая, что неотличимо от file specification, у которой просто нет /AFRelationship. Свойство делит индекс с AttachmentCount, считающим записи в дереве /Names /EmbeddedFiles. Инжектор пишет только цепочку /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 или словарь каталога не удавалось отыскать, InjectAssociateFiles копировал вход нетронутым, а метод всё равно возвращал True. С v3.122.0 InjectAssociateFiles поднимает EPdfAssocFilesError в тех случаях до записи чего-либо, SaveAsWithAssociateFiles возвращает False, и поскольку он теперь собирает полный вывод в save store до открытия цели, отвергнутое или провалившееся сохранение больше не обрезает существующий файл. Пустой массив Files по замыслу по-прежнему прокапывает документ нетронутым. Содержимое полезности — тоже ваша забота: инжектор не проверяет, что XML-файл well-formed, что MIME-тип совпадает с байтами или что базовый документ вообще PDF/A. Считайте финальный файл неверифицированным, пока его не увидел валидатор, — та же дисциплина, что описана в статье о PDFium Component и архивном соответствии PDF/A. Если вы и входящие словари парсите сами, те же правила имён #XX работают в обратную сторону — тема разобрана в статье о ловушках name-токенов при парсинге PDF-словарей

Ассоциированные файлы, вывод PDF/A, метаданные вложений и валидация едут в одном компоненте, так что конвейер выше работает без второй PDF-библиотеки в сборке. Справочник API, пробная загрузка и варианты лицензирования — на странице продукта PDFium Component