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

Ассоциированные файлы уровня страницы в PDF 2.0 и PDFlibPas

PDFlibPas прикрепляет встроенный файл к конкретной странице, а не к документу в целом: массив /AF пишется в словарь страницы, тогда как само содержимое остаётся зарегистрированным в дереве имён EmbeddedFiles документа. Именно это разделение описывает ISO 32000-2 §14.13, и именно оно позволяет ридеру ответить на вопрос, на который вложение уровня документа ответить не может: к какой странице относятся эти данные

Сценарии здесь более конкретные, чем у обычных вложений. Отчёт об обследовании, где каждая страница несёт ряд исходных измерений за своим графиком. Пакет сканов, где каждая страница хранит OCR-результат, из которого собран её текстовый слой. Комплект чертежей, где каждый лист возит с собой CAD-выборку, из которой был отрисован. Во всех этих случаях список вложений уровня документа превратился бы в кучу файлов, чьи имена кодируют номера страниц, а это конвенция, а не структура

Одно содержимое, две точки ссылки на него

Важный структурный момент: ассоциация уровня страницы не создаёт второй копии ничего. Файл встраивается один раз и регистрируется в дереве имён EmbeddedFiles ровно так же, как вложение уровня документа, той же машинерией файловой спецификации. Разница в том, куда пишется ссылка и её ключ отношения: в словарь страницы вместо каталога документа

Из этого два следствия. Во-первых, ридер, знающий только о вложениях уровня документа, всё равно найдёт содержимое, потому что оно лежит в том дереве имён, куда такой ридер и смотрит. Во-вторых, снятие страничной ассоциации удаляет привязку, а не файл. ClearPageAssociatedFiles отвязывает страницу от её ассоциированных файлов и оставляет содержимое доступным через дерево имён — это консервативное поведение: операция, которая говорит «сними ассоциацию», не должна молча уничтожать данные, на которые может ссылаться другая часть документа

Структура ассоциированного файла уровня страницы в документе PDF 2.0, записанном PDFlibPas: содержимое встраивается один раз и регистрируется в дереве имён EmbeddedFiles под каталогом документа, а словарь страницы несёт массив /AF, ссылающийся на ту же файловую спецификацию с ключом AFRelationship, поэтому ClearPageAssociatedFiles снимает привязку, не уничтожая данные
Ассоциация уровня страницы добавляет вторую ссылку, а не вторую копию: ридеры, знающие только вложения уровня документа, всё равно находят содержимое в дереве имён, а снятие страничной привязки оставляет встроенный поток доступным

У этой функции есть одно намеренно узкое условие успеха, о котором стоит знать. Она сообщает успех только тогда, когда страница действительно несла ключ /AF. Страница, у которой ассоциаций никогда не было, возвращает неудачу, а не бодрое подтверждение, поэтому вызывающий не спутает no-op с завершённой очисткой

var
  Lib: TPDFlib;
  Idx, I: Integer;
begin
  Lib := TPDFlib.Create(nil);
  try
    Lib.LoadFromFile('survey-report.pdf');

    // Прикрепляем ряд измерений, по которому построен график на странице 3
    Idx := Lib.AddPageAssociatedFileFromFile(3,
      'series-03.csv',            // файл на диске
      'measurements.csv',         // отображаемое имя внутри PDF
      'text/csv',                 // MIME-тип
      'Raw measurement series for figure 3',
      'Data');                    // AFRelationship, ISO 32000-2 14.13

    if Idx < 0 then
      raise Exception.Create('page association refused');

    for I := 0 to Lib.GetPageAssociatedFileCount(3) - 1 do
      Writeln('page 3 associated file, embedded index ',
        Lib.GetPageAssociatedFileEmbeddedIndex(3, I));

    Lib.SaveToFile('survey-report-with-data.pdf');
  finally
    Lib.Free;
  end;
end;

Строка отношения на практике не является свободным текстом. ISO 32000-2 определяет словарь: Source, Data, Alternative, Supplement, EncryptedPayload, FormData, Schema и Unspecified, и консюмеры завязываются на него. Data — для чисел за графиком, Source — для документа, из которого страница сгенерирована, Alternative — для эквивалентного представления. Выбирайте из словаря, даже если пока ничто в вашем пайплайне его не читает: следующему инструменту в цепочке может понадобиться

Почему одному и тому же поиску нужен FollowRef в обе стороны?

Потому что переход по ссылкам отвечает на два разных вопроса, и код должен знать, какой из них он задаёт. Поиск ключа, который переходит по косвенным ссылкам, возвращает объект, на который ссылка указывает. Поиск без перехода возвращает саму ссылку. Оба корректны, и использование не того даёт тихое неправильное поведение вместо ошибки

Чтение ассоциированного файла демонстрирует первое направление. Чтобы получить номер объекта встроенного потока за ключами /EF и /F файловой спецификации, поиск должен не переходить по ссылке: переход разрешит ссылку в объект потока, и номер объекта потерян. Правило обобщается: любой путь кода, которому нужна идентичность объекта, а не содержимое, обязан взять сырую ссылку

Optional content показывает противоположное направление, и обошлось оно дороже в обнаружении. Словарь свойств опционального контента пишется в каталог как косвенный объект, поэтому код, читающий его обратно без перехода, получает ссылку вместо словаря. Проверка типа на этом значении проваливается, срабатывает естественная ветка фолбэка — если конфигурации нет, создай её — и затирает конфигурацию, которая уже была. Ничего не бросает исключений. Слои, описанные в группах опционального контента и слоях, просто теряют своё состояние видимости по умолчанию

Урок обобщается за пределы обоих случаев. Когда поиск может вернуть либо ссылку, либо объект, голая проверка типа — не обработка ошибок: это ветка, которая рано или поздно будет выполнена по неправильной причине. Решайте явно, что нужно каждому месту вызова, и предпочитайте публичный API, отвечающий на вопрос напрямую, например свойство количества опционального контента, вместо лазания в защищённый акцессор за словарём каталога

Карта решений перехода по ссылкам в PDF-поиске, как это реализовано в PDFlibPas: чтение /EF и /F под файловой спецификацией не должно переходить по ссылке, потому что ответ — номер объекта встроенного потока, а косвенный словарь /OCProperties в каталоге переходить обязан, иначе провалившаяся проверка типа молча затрёт существующую конфигурацию опционального контента
Один и тот же поиск отвечает на два разных вопроса: идентичности нужна сырая ссылка, содержимому — разрешённый объект, и голая проверка типа вместо этого решения рано или поздно выполнит не ту ветку, не бросив исключений
// Вложения уровня документа и ассоциации уровня страницы сосуществуют.
// Встроенный файл можно пометить ассоциированным и на уровне документа
if Lib.IsEmbeddedFileAssociated(0) = 0 then
  Lib.SetEmbeddedFileAssociated(0, 1, 'Supplement');

Writeln('document associated files: ', Lib.GetAssociatedFileCount);
Writeln('page 3 associated files  : ',
        Lib.GetPageAssociatedFileCount(3));

// Очистка отвязывает страницу; содержимое остаётся в дереве имён
if Lib.ClearPageAssociatedFiles(3) > 0 then
  Writeln('page 3 associations removed, payloads still reachable');

Что режимы соответствия делают с вложениями

Архивные профили ограничивают, что можно встраивать, и ограничение проверяется на входе, а не при сохранении. PDF/A-1 запрещает встроенные файлы полностью, PDF/A-2 разрешает только встроенные документы PDF/A, а PDF/A-3 — тот профиль, который открыл встраивание произвольных типов файлов, и именно поэтому гибридные форматы счетов строятся на нём

PDFlibPas отказывает во вложении, когда активный режим соответствия его не разрешает, — в месте вызова, а не через сотни операций во время вывода. Это осознанный выбор о том, где ошибку дешевле всего обработать: отказ на месте вызова называет файл, который вы добавляли, а отказ при сохранении называет документ и оставляет вам вычислять, какое из сорока вложений виновато

Поэтому же ассоциированные файлы так часто встречаются в электронных счетах. Гибридный счёт — это PDF, который читает человек, с машиночитаемым XML-содержимым, приложенным и помеченным правильным отношением, и профиль контейнера, и ключ отношения — часть спецификации, а не конвенции. Эта конструкция разобрана в построении гибридных счетов Factur-X и ZUGFeRD, сторона метаданных — в XMP extension schema для PDF/A-3

Когда ассоциация должна быть на страницу, а не на документ?

Когда консюмеру нужно знать, к какой странице относятся данные, и только тогда. Вложения уровня документа проще, шире поддерживаются вьюерами и достаточны везде, где содержимое описывает весь документ: XML счёта, манифест подписи, архив исходников. К ассоциации уровня страницы обращайтесь, когда содержимое реально привязано к странице и идентичность страницы — часть его смысла

Поддержка — практическое ограничение. Ассоциированные файлы уровня страницы — конструкция PDF 2.0, и поддержка во вьюерах тоньше, чем у вложений уровня документа. Поскольку содержимое в любом случае лежит в дереве имён, вьюер, игнорирующий /AF на страницах, всё равно покажет файл в списке вложений, так что деградация плавная. Но если страничная привязка для вашего консюмера существенна, а не просто полезные метаданные, проверяйте ридер, под который реально целитесь, а не надейтесь

Ассоциированные файлы уровня страницы, вложения уровня документа и шлюзы архивных профилей, управляющие и теми и другими, поставляются в Delphi PDF-библиотеке PDFlibPas. Если вы попутно чините старые файлы на входе, работа с метаданными и соответствием в конвертации в PDF/A с починкой метаданных — это то, что решает, какой из этих способов вложения вам вообще доступен