技術文章

PDFlibPas 實作 PDF 2.0 頁面層關聯檔案(AF)

PDFlibPas 把內嵌檔案掛到某一頁、而不是整份文件,做法是在頁面字典裡寫入 /AF 陣列,酬載本身則照舊登記在文件的 EmbeddedFiles 名稱樹。ISO 32000-2 §14.13 描述的正是這道切分,也正是它讓檢視器答得出文件層附件答不了的問題:這份資料屬於哪一頁

它的使用情境比一般附件更特定。勘察報告的每一頁都帶著圖表背後的原始量測數列;掃描批次裡每一頁都留著產出其文字層的 OCR 結果;圖集裡每一張圖都帶著它算繪自的那份 CAD 擷取。這些情況若改用文件層附件清單,就是一堆把頁碼編進檔名的檔案,那是一種約定俗成,不是結構

一份酬載,兩處參照它的地方

結構上最重要的點是:頁面層關聯不會替任何東西造出第二份副本。檔案只內嵌一次,並且與文件層附件完全一樣地登記在 EmbeddedFiles 名稱樹裡,用的是同一套檔案規格機制。不同的只在參照與其關係鍵寫在哪裡:寫進頁面字典,而不是文件目錄(catalog)

由此有兩個後果。第一,只認得文件層附件的閱讀器仍然找得到酬載,因為它就在那種閱讀器會去找的名稱樹裡。第二,清掉頁面關聯解除的是繫結,不是檔案。ClearPageAssociatedFiles 把頁面與其關聯檔案分開,酬載仍可循名稱樹觸及,這是保守的行為:一個名義上是清除關聯的操作,不該無聲無息地銷毀文件其他部分可能參照的資料

PDFlibPas 寫出的 PDF 2.0 文件裡頁面層關聯檔案的結構:酬載只內嵌一次並登記在文件目錄底下的 EmbeddedFiles 名稱樹,頁面字典則帶著一個 /AF 陣列,以 AFRelationship 鍵參照同一份檔案規格,因此 ClearPageAssociatedFiles 解除繫結而不銷毀資料
頁面層關聯加的是第二道參照,不是第二份副本:只認得文件層附件的閱讀器仍能在名稱樹裡找到酬載,清除頁面繫結之後內嵌串流照樣觸及得到

這個函式有一個刻意收得很窄的成功條件,值得知道。只有當頁面真的帶過 /AF 鍵,它才回報成功。從未有過關聯的頁面回傳的是失敗,而不是一聲愉快的確認,呼叫端因此不會把一次什麼都沒做的呼叫誤當成清理完成

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 定義了一份詞彙表:SourceDataAlternativeSupplementEncryptedPayloadFormDataSchemaUnspecified,消費端會按它行事。Data 給圖表背後的數字,Source 給頁面由之產生的文件,Alternative 給等價的另一種呈現。就算您的管線裡目前沒有任何東西在讀它,也請從這份詞彙表裡挑,因為鏈上的下一個工具可能會

為什麼同一種查找需要 FollowRef 的兩個方向?

因為追蹤參照回答的是兩個不同的問題,程式碼必須知道自己問的是哪一個。會追蹤間接參照的鍵查找,回傳參照所指的那個物件;不追蹤的查找,回傳的是參照本身。兩者都正確,用錯那一個得到的是無聲的錯誤行為,不是錯誤訊息

讀取關聯檔案示範了第一個方向。要取得檔案規格 /EF/F 鍵背後那個內嵌串流的物件編號,查找就不能追蹤,因為一追蹤,參照就被解析成串流物件本身,物件編號就沒了。這條規則可以推廣:任何需要的是物件身分、而不是物件內容的程式路徑,都必須拿原始參照

選用內容展示的是相反方向,而這一個花更多功夫才找出來。選用內容性質字典是以間接物件寫進目錄的,不追蹤就讀回來的程式碼拿到的是參照,不是字典。對那個值做的型別檢查於是失敗,順理成章的 fallback 分支(沒有設定就建一個)接著執行,把已經存在的設定整個蓋掉。什麼例外都沒丟。選用內容群組與圖層描述的那些圖層,就這樣默默失去它們的預設可見狀態

這一課可以推廣到這兩個案例之外。當一個查找可能回傳參照、也可能回傳物件,光禿禿的型別檢查不是錯誤處理:它是一個遲早會因為錯的理由被走到的分支。明確決定每個呼叫點需要的是什麼,能直接回答問題的公開 API(例如選用內容數量的性質)優先於伸手進 protected 存取子去撈目錄字典

PDFlibPas 實作的 PDF 查找中參照追蹤的決策圖:讀取檔案規格下的 /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 混合式發票,詮釋資料那一側則在PDF/A-3 XMP 延伸綱要

什麼時候該用每頁關聯、而不是每份文件?

當消費端需要知道資料屬於哪一頁的時候,而且只有在那個時候。文件層附件更簡單、受到更多檢視器支援,只要酬載描述的是整份文件(發票 XML、簽章清單、原始碼封存檔)就夠用。等到酬載真的以頁為範圍、頁的身分本身就是其意義的一部分時,再伸手拿頁面層關聯

支援度是實務上的限制。頁面層關聯檔案是 PDF 2.0 的構造,檢視器支援比文件層附件單薄。因為酬載無論如何都放在名稱樹裡,忽略頁面上 /AF 的檢視器照樣會在附件清單裡顯示該檔案,所以退化是優雅的。但如果頁面繫結對您的消費端是不可或缺、不只是好用的詮釋資料,請驗證您實際鎖定的閱讀器,不要用猜的

頁面層關聯檔案、文件層附件,以及治理兩者的封存設定檔關卡,都隨 PDFlibPas Delphi PDF library 出貨。如果您在檔案進來的路上還要順手修老檔,轉換 PDF/A 並修復詮釋資料裡的詮釋資料與符合性工程,正是決定這些附件路線一開始哪些用得上的那件事