Technical Article

Delphi 中的 PDF 圖層:選用內容群組 (OCG)

測量員開啟地籍圖,希望在保留管線的同時隱藏等高線。審閱者希望紅線註解在螢幕上可見,但在印刷品中消失。一份產品說明書以單一檔案的形式提供三種語言,讀者可以選擇顯示哪種語言。這三者都是相同的 PDF 功能,而在 Acrobat 中驅動它們的面板稱為「圖層」。該面板底層的功能是選用內容,它允許單個頁面攜帶多個獨立的視覺分層,檢視器可以開啟和關閉這些分層。

選用內容在 ISO 32000-1 §8.11 中指定。可見性的單位是選用內容群組 (OCG),這是一個帶有名稱的 /OCG 類型字典。頁面上的標記內容與一個群組關聯,檢視器決定目前是否顯示該群組。相關的結構(選用內容成員字典或 OCMD)允許可見性取決於多個群組的布林組合,但日常情況是代表單個圖層的單個具名群組。檔案透過一個目錄項目 /OCProperties 將整個機制綁定在一起,接下來將對其進行描述。

目錄必須攜帶的內容

OCG 本身是惰性的。為了讓檢視器列出圖層並記住其狀態,檔案目錄需要一個 /OCProperties 字典,而 §8.11.4 精確規劃了其中的內容。有一個 /OCGs 陣列命名檔案中的每個群組,還有一個 /D 項目保存預設設定。預設設定是讀者在首次開啟檔案時套用的部分。它記錄了哪些群組開始時是開啟的,哪些是關閉的,哪些項目被鎖定以防止使用者切換它們,以及透過 /Order 陣列記錄圖層名稱在面板中如何排列和巢狀。

實際的結果是,建立圖層絕非單純的局部行為。群組必須繪製在頁面上,且還必須在先前不存在的目錄級結構中進行註冊。PDFlibPas 會為您處理這兩件事。建立群組的第一次呼叫會將 /OCProperties 項目新增到目錄中並播種預設設定,因此圖層既被繪製又被列出,而無需您進行單獨的記帳。

為什麼合規模式會限制此功能

在執行任何圖層程式碼之前,檔案的相容性目標決定了選用內容是否合法。ISO 19005-1 中定義的封存設定檔 PDF/A-1 在 §6.1.13 中完全禁止 /OCProperties 項目。其合理性符合格式的目的。封存檔案在遙遠的未來對於每位讀者必須呈現完全相同的內容,而檢視器可以變更其可見性的內容是其外觀未固定的內容,因此該設定檔禁止該結構,而不是允許存在模糊的封存檔案。定義於 ISO 19005-2 和 ISO 19005-3 的 PDF/A-2 和 PDF/A-3 在其 §6.9 中持相反觀點,允許選用內容,並對預設可見性制定了規則。

這種差異直接反映在 API 中。當檔案處於 PDF/A-1 模式時,NewOptionalContentGroup 會拒絕建立該群組並傳回零,因為履行該要求將產生一個無法通過自身聲明相容性的檔案。在 PDF/A-2 或 PDF/A-3 模式以及普通不受限的 PDF 中,相同的呼叫會成功並傳回非零的群組識別碼。因此,零結果並非稍後檢查的一般性失敗;它是函式庫在告訴您,目前的相容性層級沒有空間容納此功能。

var
  Pdf: TPDFlib;
  LayerID: Integer;
begin
  Pdf := TPDFlib.Create(nil);
  try
    Pdf.NewDocument;
    Pdf.SetPDFAMode(1);                       // PDF/A-1a: OCProperties forbidden

    LayerID := Pdf.NewOptionalContentGroup('Utilities');
    if LayerID = 0 then
      // refused under PDF/A-1; not a transient error, the mode bans layers
      ShowMessage('Optional content is not available in PDF/A-1 mode.');
  finally
    Pdf.Free;
  end;
end;

每個圖層有兩種狀態,而非一種

圖層並非簡單的可見或不可見。預設設定會記錄其螢幕上狀態和獨立的列印狀態,因為 §8.11.4 區分了檢視器顯示的內容與列印管線發送的內容。這兩者是有意獨立的。草稿浮水印可以顯示在螢幕上但在紙張上丟棄,而裁切線圖層可以隱藏在螢幕上但發送到繪圖機。將兩者合併會迫使一個跟隨另一個,從而失去該功能存在所要提供的精確控制。

PDFlibPas 透過兩個設定器公開這一對。SetOptionalContentGroupVisible 接受群組識別碼和旗標(其中 1 表示可見,0 表示隱藏),並控制預設的螢幕上狀態。SetOptionalContentGroupPrintable 接受群組識別碼和一個旗標,決定檔案列印時是否發送該圖層。相符的取得器 GetOptionalContentGroupVisibleGetOptionalContentGroupPrintable 各傳回 1 或 0,因此您可以分別讀回圖層的螢幕和列印配置,而不是從一個推斷另一個。

在頁面上建立兩個圖層

建立圖層並填滿它遵循固定的順序。您將圖層的內容繪製到目前頁面上,然後使用群組識別碼呼叫 SetContentStreamOptional,這會包裝頁面的目前內容資料流,以便到目前為止繪製的所有內容都屬於該群組。因為呼叫會擷取當時資料流上的任何內容,所以規則是放下一個圖層的標記,分配它們,然後才開始下一個圖層。下面的範例將管線放在第一頁上,將審閱者紅線放在第二頁上,設定每個圖層的螢幕和列印狀態,然後儲存。

var
  Pdf: TPDFlib;
  FontID, UtilLayer, RedlineLayer: Integer;
begin
  Pdf := TPDFlib.Create(nil);
  try
    Pdf.NewDocument;                          // unconstrained PDF: layers allowed
    Pdf.SetPageDimensions(595, 842);          // A4 in points
    FontID := Pdf.AddStandardFont(0);         // Helvetica
    Pdf.SelectFont(FontID);

    // Layer 1: utilities, drawn then assigned to its own group
    Pdf.SetTextColor(0.10, 0.30, 0.65);
    Pdf.DrawText(72, 770, 'Utilities: water main, valve chamber');
    UtilLayer := Pdf.NewOptionalContentGroup('Utilities');
    Pdf.SetContentStreamOptional(UtilLayer);
    Pdf.SetOptionalContentGroupVisible(UtilLayer, 1);   // shown on screen
    Pdf.SetOptionalContentGroupPrintable(UtilLayer, 1); // and on paper

    // Layer 2: reviewer redline on a fresh page
    Pdf.InsertPages(2, 1);                     // append one page after page 1
    Pdf.SetTextColor(0.80, 0.10, 0.10);
    Pdf.DrawText(72, 770, 'REVIEW: revise valve spec before issue');
    RedlineLayer := Pdf.NewOptionalContentGroup('Reviewer markup');
    Pdf.SetContentStreamOptional(RedlineLayer);
    Pdf.SetOptionalContentGroupVisible(RedlineLayer, 1);    // visible while reviewing
    Pdf.SetOptionalContentGroupPrintable(RedlineLayer, 0);  // never printed

    Pdf.SaveToFile('SitePlan_Layers.pdf');
  finally
    Pdf.Free;
  end;
end;

紅線圖層是值得注意的情況。它顯示在螢幕上以便審閱者看到附註,且其可列印旗標為零,因此同一檔案的列印輸出不包含審閱文字。這種不對稱性正是將這兩種狀態分開的關鍵所在。

讀回設定

讀取圖層是對相同結構的另一種遍歷。載入檔案後,GetOptionalContentConfigCount 會回報檔案包含多少個設定字典;第一個預設設定是設定識別碼 1。在設定中,GetOptionalContentConfigOrderCount 會給出順序樹中的項目數,您可以從 1 開始為其編制索引。對於每個項目,GetOptionalContentConfigOrderItemLabel 會傳回其顯示文字,而 GetOptionalContentConfigOrderItemLevel 會傳回其巢狀深度,因此可以逐字重構在標題下縮排子圖層的面板大綱。

每個項目也都有一個類型。GetOptionalContentConfigOrderItemType 區分實際的選用內容群組與僅存在於標記樹之區段的純文字標籤。該區分很重要,因為針對每個群組的狀態查詢僅對真實群組有意義。對於群組項目,GetOptionalContentConfigState 會回報設定開始時是開啟、關閉還是保持不變,而 GetOptionalContentConfigLocked 會回報是否禁止使用者切換它。下面的迴圈轉譯順序樹,顯示每個群組的狀態和鎖定狀態,並按層級縮排。

var
  Pdf: TPDFlib;
  Cfg, Count, I, ItemType, GroupID, Indent: Integer;
  Line: string;
begin
  Pdf := TPDFlib.Create(nil);
  try
    if Pdf.LoadFromFile('SitePlan_Layers.pdf', '') = 0 then Exit;
    if Pdf.GetOptionalContentConfigCount = 0 then Exit;

    Cfg := 1;                                  // the default configuration
    Count := Pdf.GetOptionalContentConfigOrderCount(Cfg);
    for I := 1 to Count do
    begin
      Indent := Pdf.GetOptionalContentConfigOrderItemLevel(Cfg, I);
      Line := StringOfChar(' ', Indent * 2)
              + Pdf.GetOptionalContentConfigOrderItemLabel(Cfg, I);

      ItemType := Pdf.GetOptionalContentConfigOrderItemType(Cfg, I);
      if ItemType = 1 then                     // 1 = optional content group
      begin
        GroupID := Pdf.GetOptionalContentConfigOrderItemID(Cfg, I);
        case Pdf.GetOptionalContentConfigState(Cfg, GroupID) of
          1: Line := Line + '  [on]';
          2: Line := Line + '  [off]';
          3: Line := Line + '  [unchanged]';
        end;
        if Pdf.GetOptionalContentConfigLocked(Cfg, GroupID) = 1 then
          Line := Line + ' (locked)';
      end;
      // ItemType = 2 is a text label heading; it has no per-group state

      Writeln(Line);
    end;
  finally
    Pdf.Free;
  end;
end;

兩個細節使此迴圈保持正確。順序索引是從 1 開始的,從 1 到總數,這與函式庫內部對樹進行編號的方式相符。而每個群組的呼叫僅在項目類型為群組時執行,因為文字標籤是具有名稱和層級的標題,但沒有要查詢的開啟、關閉或鎖定狀態。跳過該防護,您就會向標籤詢問它所沒有的狀態。

這適用於何處

圖層是一種展示機制,因此引擎必須在轉譯頁面的每條路徑上支援它們,轉譯端在我們的 Delphi 多引擎轉譯逐步解說中介紹。它們也與檔案結構相交,因為圖層名稱是面向作者的文字,且讀者可從結構化的圖層大綱中受益,這與我們關於標籤化 PDF 與無障礙結構的文章中的工作相連。兩者都與此處介紹的選用內容 API 配對,這些 API 作為 Delphi PDF 函式庫 的一部分出貨,同時也提供本部落格其他地方討論的頁面、文字、字型和相容性功能。