Чтобы прикрепить файл-источник к документу 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 запрещает встроенные файлы, какими бы опрятными ни были метаданные
Значение relationship — та часть, которую обычно угадывают. TPdfAFRelationship в FPdfAssocFiles мапит по одному члену enum на каждый name-токен, который может выпустить инжектор, и лишь первые пять принадлежат подмножеству, которое признаёт 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 второй слэш начинает новый 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 и сверяет точный закодированный вывод
// Что пишет инжектор для 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 называет страницу на той позиции в порядке страниц документа
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