技術文章

PDF 生命週期動作:Delphi 中的 Catalog /AA 與 Page /AA

PDFlibPas 這個原生 Delphi 與 C++Builder PDF 函式庫,為 PDF 文件提供兩個彼此分開的自動行為附加位置:WillClose、WillSave、DidSave、WillPrint 與 DidPrint 等文件層級生命週期動作,儲存在 Catalog 的 /AA 字典中;而頁面層級生命週期動作 Open 與 Close,則儲存在每個 Page 物件自己的 /AA 字典中。混淆這兩個容器,是生命週期動作靜默失效最常見的原因

促成這些需求的情境都很常見。財務團隊希望報表範本在實際開始列印的那一刻蓋上列印時間戳記並記錄列印者,而不是在檔案僅僅開啟時就執行。表單密集的工作流程需要在閱讀器 PDF 用戶端允許使用者關閉視窗前,自動將欄位值推送至伺服器,避免關閉分頁代表遺失編輯內容。多頁報表則可能需要一個頁面專屬的橫幅,只有在該頁顯示於畫面上時才出現。PDF 實際上還為這類行為提供位於文件與頁面之下的第三個層級,也就是附加至個別表單欄位或連結自身 /A 項目的動作,詳見互動式表單動作與 JavaScript 的配套文章;但本文只處理上面的兩個層級:整份文件與單一頁面

哪些觸發條件存在於文件 Catalog 的 /AA 中

Catalog /AA 字典中有五個觸發條件,每一個都會在影響整份文件而非單一頁面的事件發生時執行。ISO 32000-1 §12.6.3(觸發事件)將文件層級鍵列為 WCWSDSWPDP,這些是寫入 /AA 字典的兩個字母 literal 名稱,分別對應 WillClose、WillSave、DidSave、WillPrint 與 DidPrint;PDFlibPas 也在 TPDFlibDocumentActionTrigger 列舉中精確提供同一組:datWillClosedatWillSavedatDidSavedatWillPrintdatDidPrintSetDocumentAction 是附加這五種動作的單一進入點,而它接受的 ActionKind 參數,是函式庫所有動作建構呼叫共用的十個 PDF_ACTION_BUILDER_* 常數之一,涵蓋普通 URI、指令碼到目的地跳轉。GoTo、遠端檔案、嵌入檔案或 Launch 動作在觸發後實際做什麼,和它附加的位置是不同問題,詳見GoTo、遠端、嵌入與啟動動作的配套文章;本文專注於容器問題,也就是 Catalog 或 Page,而非動作種類問題

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;

頁面層級觸發條件與文件層級觸發條件有何不同

頁面層級觸發條件只會針對它所附加的單一 Page 物件執行,而 PDFlibPas 會將它儲存在該頁自己的 /AA 字典,而不是 Catalog 的字典中。頁面觸發條件只有兩個:Open 與 Close,分別對應 ISO 32000-1 為頁面額外動作字典定義的 OC 鍵;PDFlibPas 透過 SetPageAction 將它們公開為 patOpenpatClose,並附加至目前透過 SelectPage 選取的頁面。這項細節在第一次遍歷文件、以為一次呼叫就能套用至所有頁面時尤其重要,因為它從來不會這樣做。附加任一種觸發條件也會提高檔案的最低 PDF 版本,而且兩個容器要求不同的版本下限:PDFlibPas 第一次寫入 Catalog /AA 項目時,會將文件至少提高至 PDF 1.4;第一次寫入 Page /AA 項目時,則至少提高至 PDF 1.5,不論其中放入哪一種動作。這是疊加在動作本身需求之上的容器層級要求,因此單獨使用只需要 PDF 1.1 的基本 URI 動作,在包裝於頁面開啟觸發條件後,仍會使整份檔案提高至 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);

讀取與移除生命週期動作

GetDocumentActionInfoGetPageActionInfo 都會回傳 TPDFlibActionInfo 記錄;當該觸發條件沒有附加任何內容時,Kind 欄位會回傳 akNone,因此請先檢查 Kind,再信任記錄中的其他欄位。URIJavaScriptFileName 及其他欄位,只有在 Kind 實際回報的那一種動作種類下才有意義,因為同一種記錄結構會在建構器能產生的所有動作類型之間重複使用。RemoveDocumentActionRemovePageAction 各自清除一個觸發條件,找到要移除的內容時回報 1,觸發條件原本已經空白時回報 0;當被移除的項目是 /AA 字典中最後剩下的項目時,PDFlibPas 會一併刪除現在已空白的 /AA 本身,不會在 Catalog 或頁面上留下懸置且沒有意義的容器

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 的互動動作模型,其前提是保存用檔案必須在數十年後仍以相同方式呈現,不能依賴屆時可能不存在的指令碼引擎或網路連線。SetLifecycleActionSetDocumentActionSetPageAction 共用的建構器,它會在查看 ActionKind 之前先檢查 PDFAMode,因此只是開啟公司網頁的 URI 動作,或僅代表前往下一頁的 Named 動作,也會和危險動作一樣被攔截。安全性審查人員通常不會標記的內容也一律遭到封鎖,因為這項限制是結構性的,而不是逐一案例判斷。實際風險在於拒絕是靜默發生的:SetDocumentActionSetPageAction 都會回傳 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');

有一項不對稱之處值得記住。RemoveDocumentActionRemovePageAction 從不檢查 PDFAMode,因此載入一份已帶有不符合規範的生命週期動作的檔案,並在儲存為符合 PDF/A 的檔案前將它們剝除,會如預期般正常運作。只有寫入路徑,也就是附加新觸發條件時,才會受到相容性模式管制

沒有 WillOpen 觸發條件時,開啟時列印應如何處理

Catalog /AA 字典根本沒有 WillOpen 項目,這是刻意的設計。ISO 32000-1 中的文件層級 /AA 明確定義五個鍵:WillClose、WillSave、DidSave、WillPrint 與 DidPrint,清單中沒有任何一項會純粹因為檔案被開啟而執行。開啟時的掛勾位於另一個 Catalog 項目 /OpenAction 中,PDFlibPas 透過自己的呼叫系列公開它,其中包括 SetOpenActionJavaScriptSetOpenActionDestinationSetOpenActionNamedDestination,這些呼叫完全不會接觸 /AA 字典或 TPDFlibDocumentActionTrigger 列舉。不過兩種機制可以組合,這通常正是開啟時列印範本需要的做法:建立範本,使其 /OpenAction 啟動列印工作,通常是由呼叫閱讀器自身列印命令的 JavaScript 動作完成;而列印本身就會讓 WillPrint 與 DidPrint 有可執行的對象,例如在頁面開始送入列印佇列前蓋上時間戳記,並在完成後寫入稽核項目

這些觸發條件在不同 PDF 閱讀器中有多可靠

即使不考慮 PDF/A,也不是每個閱讀器都會執行它們,因此應將生命週期動作視為要求,而不是保證。Acrobat 與大多數完整桌面閱讀器會忠實執行整組動作,但大量真實世界的 PDF 使用情境根本不會處理額外動作字典:瀏覽器內嵌閱讀器、大多數行動閱讀器,以及幾乎所有伺服器端轉譯或文字擷取管線,會完全忽略 /AA,或只支援其中很小的一部分;其中 WillPrint 與 DidPrint 通常最不可靠,因為無頭轉換沒有列印操作可供它們掛接。如果 WillClose 提交表單動作是擷取表單資料的唯一途徑,那就不是可靠途徑,請搭配明確的提交按鈕,並將自動觸發條件視為支援它的閱讀器所提供的便利功能

文件、頁面與欄位觸發條件,是同一套底層動作字典機制的三個層級;一旦釐清容器,剩下的工作就是選擇正確的 ActionKind 常數並檢查回傳碼。這些生命週期觸發條件,以及本文提及的更完整動作建構器 API,都是標準PDFlibPas Delphi PDF 函式庫的一部分,完整的觸發條件與動作種類參考可在產品文件中找到