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

Действия жизненного цикла PDF: /AA каталога против /AA страницы в Delphi

PDFlibPas, нативная PDF-библиотека для Delphi и C++Builder, даёт документу PDF два отдельных места для навешивания автоматического поведения: действия жизненного цикла уровня документа, такие как WillClose, WillSave, DidSave, WillPrint и DidPrint, хранимые в словаре /AA каталога, и действия жизненного цикла уровня страницы — Open и Close, — хранимые вместо этого в собственном словаре /AA каждого объекта страницы. Спутать эти два контейнера — самый распространённый способ, которым действие жизненного цикла молча ничего не делает

Мотивирующие случаи обычны. Финансовая команда хочет шаблон выписки, что проставляет временну́ю метку печати и логирует, кто её напечатал, в момент, когда печать реально начинается, а не когда файл просто открывается. Рабочему процессу, насыщенному формами, нужно, чтобы значения полей автоматически отправлялись на сервер прежде, чем клиенту PDF читателя будет позволено закрыть окно, так что закрытая вкладка никогда не означает потерянную правку. Многостраничный отчёт хочет специфичный для страницы баннер, что появляется только пока эта страница на экране. PDF на самом деле предлагает третий уровень ниже документа и страницы для такого рода поведения — действия, прикреплённые к собственной записи /A отдельного поля формы или ссылки, тема сопутствующей статьи об интерактивных действиях форм и JavaScript, — но эта статья остаётся на двух уровнях выше него: весь документ и одна страница

Какие триггеры живут на /AA каталога документа?

Пять триггеров живут в словаре /AA каталога, и каждый из них срабатывает на событие, затрагивающее весь документ, а не одну страницу. ISO 32000-1 §12.6.3 (Trigger Events) перечисляет ключи уровня документа как WC, WS, DS, WP и DP — буквальные двухбуквенные имена, записываемые в словарь /AA, — для WillClose, WillSave, DidSave, WillPrint и DidPrint соответственно, и PDFlibPas точно отражает этот набор в перечислении TPDFlibDocumentActionTrigger: datWillClose, datWillSave, datDidSave, datWillPrint, datDidPrint. SetDocumentAction — единственная точка входа, что прикрепляет любое из пяти, а параметр ActionKind, что она принимает, — одна из десяти констант PDF_ACTION_BUILDER_*, разделяемых каждым вызовом построителя действий в библиотеке, от простого URI до скрипта до перехода к назначению. Что на самом деле делает действие GoTo, внешнего файла, встроенного файла или Launch, будучи запущенным, — вопрос отдельный от того, куда оно прикрепляется, и это тема сопутствующей статьи о действиях GoTo, внешних, встроенных и запуска — эта же остаётся на вопросе контейнера, каталог или страница, а не на вопросе вида действия

var
  Lib: TPDFlib;
begin
  Lib := TPDFlib.Create;
  try
    Lib.AddStandardFont(4);
    Lib.DrawText(40, 700, 'Quarterly statement');
    Lib.SetDocumentAction(datWillSave, PDF_ACTION_BUILDER_WEB,
      'https://example.com/audit/will-save', '', 0, 0);
    Lib.SetDocumentAction(datWillClose, PDF_ACTION_BUILDER_SUBMIT,
      'https://example.com/forms/submit', 'CustomerName;OrderTotal', 0, 0);
    Lib.SaveToFile('statement.pdf');
  finally
    Lib.Free;
  end;
end;

Чем триггер уровня страницы отличается от триггера уровня документа?

Триггер уровня страницы срабатывает только для того единственного объекта страницы, к которому прикреплён, и PDFlibPas хранит его в собственном словаре /AA этой страницы, а не каталога. Есть только два триггера страницы, Open и Close, соответствующие ключам O и C, что ISO 32000-1 определяет для словаря дополнительных действий страницы, и PDFlibPas предоставляет их как patOpen и patClose через SetPageAction, что прикрепляется к той странице, что сейчас выбрана через SelectPage, — деталь, важная в тот момент, когда вы обходите документ циклом, ожидая, что один вызов применится всюду, потому что этого никогда не происходит. Прикрепление любого из видов триггера также поднимает минимальную версию PDF файла, и два контейнера требуют разных минимумов: PDFlibPas поднимает документ как минимум до PDF 1.4 при первой записи записи /AA каталога, и как минимум до PDF 1.5 при первой записи записи /AA страницы, независимо от того, какой вид действия внутри. Это требование на уровне контейнера, накладываемое поверх того, что нужно самому действию само по себе, так что голое действие URI, которое само по себе потребовало бы только PDF 1.1, всё равно тянет весь файл вверх до PDF 1.5, как только оно обёрнуто в триггер открытия страницы

Lib.SelectPage(3);
Lib.SetPageAction(patOpen, PDF_ACTION_BUILDER_JAVASCRIPT,
  'app.alert("Section 3: internal review only");', '', 0, 0);
Lib.SetPageAction(patClose, PDF_ACTION_BUILDER_WEB,
  'https://example.com/analytics/page-3-closed', '', 0, 0);

Чтение и удаление действий жизненного цикла

GetDocumentActionInfo и GetPageActionInfo оба возвращают запись TPDFlibActionInfo, и поле Kind возвращается как akNone всякий раз, когда к этому триггеру ничего не прикреплено, так что проверяйте Kind, прежде чем доверять любому другому полю записи, — URI, JavaScript, FileName и остальные имеют смысл только для того одного вида действия, о котором реально сообщает Kind, поскольку одна и та же форма записи переиспользуется для каждого типа действия, что может произвести построитель. RemoveDocumentAction и RemovePageAction каждый очищает один триггер и сообщает 1, если нашёл что удалить, 0, если триггер уже был пуст; когда удалённая запись была последней, оставшейся в словаре /AA, PDFlibPas удаляет теперь-пустой сам /AA, а не оставляет висящий, бессмысленный контейнер на каталоге или странице

var
  Info: TPDFlibActionInfo;
begin
  Info := Lib.GetDocumentActionInfo(datWillSave);
  if Info.Kind = akURI then
    WriteLn('WillSave calls out to: ', string(Info.URI));

  if Lib.RemoveDocumentAction(datWillSave) = 1 then
    Lib.SetDocumentAction(datWillSave, PDF_ACTION_BUILDER_WEB,
      'https://example.com/audit/will-save-v2', '', 0, 0);
end;

Допускает ли PDF/A действия жизненного цикла вообще?

Нет. Соответствие PDF/A отклоняет весь контейнер дополнительных действий целиком, а не только виды действий, что звучат рискованно, потому что ISO 19005 ограничивает интерактивную модель действий PDF, исходя из предположения, что архивный файл должен отображаться так же спустя десятилетия, не завися от движка скриптов или сетевого соединения, которых к тому моменту может уже не существовать. SetLifecycleAction, общий построитель за SetDocumentAction и SetPageAction, проверяет PDFAMode ещё до того, как вообще посмотреть на ActionKind, так что действие URI, что просто открывает веб-страницу компании, или именованное действие, что означает лишь «перейти к следующей странице», попадает в ту же сеть, что и опасное, — ничто, что рецензент безопасности обычно бы отметил, всё равно блокируется, потому что ограничение структурное, а не разбираемое по конкретному случаю. Практическая опасность в том, что отклонение происходит молча: SetDocumentAction и SetPageAction оба возвращают 0, не выбрасывая исключения, так что место вызова, что никогда не проверяет возвращаемое значение, выпускает документ, тихо лишённый триггера, который должен был нести

Lib.SetPDFAMode(2); // PDF/A-1b
if Lib.SetDocumentAction(datWillClose, PDF_ACTION_BUILDER_NAMED,
     '', '', 0, 0) = 0 then
  // rejected: PDF/A-1b forbids Catalog /AA, even a plain Named action
  WriteLn('lifecycle action not attached');

Одну асимметрию стоит держать в уме. RemoveDocumentAction и RemovePageAction никогда не проверяют PDFAMode, так что загрузка файла, что уже несёт несоответствующие действия жизненного цикла, и их удаление на пути к сохранению, соответствующему PDF/A, работают в точности как ожидается, — только путь записи, прикрепление нового триггера, ограничен режимом соответствия

Куда вписывается печать при открытии без триггера WillOpen?

В словаре /AA каталога вообще нет записи WillOpen, намеренно — /AA уровня документа в ISO 32000-1 определяет ровно пять ключей, WillClose, WillSave, DidSave, WillPrint и DidPrint, и ничто в этом списке не срабатывает исключительно от того, что файл был открыт. Крючок момента открытия живёт в отдельной записи каталога, /OpenAction, которую PDFlibPas предоставляет через собственное семейство вызовов, среди них SetOpenActionJavaScript, SetOpenActionDestination и SetOpenActionNamedDestination, ни один из которых вообще не трогает словарь /AA или перечисление TPDFlibDocumentActionTrigger. Впрочем, оба механизма сочетаются, и это обычно то, что реально нужно шаблону печати при открытии: постройте шаблон так, чтобы его /OpenAction запускал задание печати, обычно действие JavaScript, вызывающее собственную команду печати просмотрщика, а сама печать — то, что даёт WillPrint и DidPrint нечто, на чём выполняться: временну́ю метку, проставленную до постановки страниц в очередь на печать, запись аудита, записанную, как только они завершены

Насколько надёжны эти триггеры в разных просмотрщиках PDF?

Не каждый просмотрщик их выполняет, даже вне PDF/A, так что относитесь к действию жизненного цикла как к запросу, а не как к гарантии. Acrobat и большинство полноценных настольных программ чтения выполняют весь набор добросовестно, но большая доля реального потребления PDF вообще никогда не касается словаря дополнительных действий: встроенные в браузер просмотрщики, большинство мобильных программ чтения, и почти каждый серверный конвейер рендеринга или извлечения текста либо полностью игнорируют /AA, либо соблюдают только узкий срез из него, причём WillPrint и DidPrint обычно справляются хуже всего, поскольку у безголовой конвертации нет операции печати, к которой они могли бы подключиться. Если действие отправки формы WillClose — единственный путь, что захватывает данные формы, это ненадёжный путь — сочетайте его с явной кнопкой отправки и относитесь к автоматическому триггеру как к удобству для тех программ чтения, что случайно его поддерживают

Триггеры документа, страницы и поля — три уровня одного и того же лежащего в основе механизма словаря действий, и как только контейнер ясен, остальное — выбор правильной константы ActionKind и проверка кода возврата. Эти триггеры жизненного цикла, наряду с более широким API построителя действий, которого касается эта статья, поставляются как часть стандартной PDF-библиотеки PDFlibPas для Delphi, с полным справочником триггеров и видов действий в документации продукта