技術文章

PDFium Component 操作 PDF 選擇性內容圖層

PDFium Component 在 Delphi 裡透過兩個 TPdf 方法控制 PDF 選擇性內容圖層(OCG):InspectOptionalContent 列出每個圖層連同 PDFium 實際會渲染的可見性,SaveAsOptionalContentConfigured 則寫出一份經過驗證的副本,您選的圖層在其中被切換為開或關。第二個方法還會中和掉那些原本會悄悄復原您編輯的 Usage 與 /AS 規則。兩者都作用於 TPdf 裡已開啟的文件,所以不需要第二個剖析器去跟檢視器顯示的內容保持同步

需求通常來自 CAD 或 GIS 公司:圖組帶著尺寸標註、註解與標題欄,分別放在不同圖層上,客戶要一份把尺寸藏起來的副本,再送給供應商。PDFium 渲染選擇性內容沒問題,但它的公開 ABI 沒有任何函式可以列舉 OCG、挑選組態或切換圖層狀態。於是您降到物件層級、編輯 /OCProperties、存檔、重新載入,然後圖層還在。原因在 PDFium 的可見性邏輯,在碰任何位元組之前值得先搞懂

為什麼編輯 /ON 與 /OFF 改變不了 PDFium 渲染的結果?

只編輯組態字典的 /ON 與 /OFF 陣列不夠,因為 PDFium 讓 OCG 自己 /Usage 字典裡的明確狀態壓過這些陣列,而 /AS 自動狀態規則接著又能壓過兩者。ISO 32000-1 §8.11.4 把組態與 usage 字典描述成兩套分開的機制;PDFium 的渲染器把它們摺疊成單一決定,而 InspectOptionalContent 按這個順序重現它:

  • 從組態的 /BaseState 起步,其中 /ON 與 /Unchanged 都算可見,只有 /OFF 會藏
  • 套用組態的 /ON 陣列,再套 /OFF 陣列,所以兩邊都列出的群組最後是隱藏的
  • 套用群組針對所求 usage 的明確 Usage 狀態,例如 /Usage << /View << /ViewState /OFF >> >>,它壓過前面的一切
  • 把 /Intent 不含 /View 也不含 /All 的群組視為可見,因為它不參與檢視意圖的可見性
  • 最後跑所選組態的 /AS 陣列,其中符合事件的項目會設定它們所列群組的狀態
PDFium Component 在 Delphi 裡為每個 PDF 選擇性內容群組重演的五步可見性決定:BaseState 定起點、組態的 ON 與 OFF 陣列依序套用、明確的 Usage ViewState 或 PrintState 項目壓過兩者、Intent 不參與算可見、AS 陣列最後執行
只編 ON 與 OFF 陣列不夠,因為 PDFium 把 BaseState、兩個陣列、群組 Usage 狀態,最後還有 AS 自動狀態規則摺成一份裁決,而 InspectOptionalContent 逐步重現它

燒到人的是第三步。排版工具存出的檔案常常在每個 OCG 上都帶 /ViewState /ON,PDFium 於是無視您精心編輯的 /OFF 陣列:存檔成功、檔案乾淨地重新打開,圖層照樣畫出來。對 Print 與 Export,OcExplicitUsageState 先讀 PrintState 或 ExportState,缺了特定項目才退回 ViewState,所以單獨一個 ViewState /ON 連列印時也把圖層釘住。參照 OCMD(§8.11.2.2)的標記內容接著對這些逐群組結果解析,透過 /P 政策,或在存在時透過 /VE 可見性運算式

怎麼列出 PDFium 實際會顯示的圖層?

TPdf.InspectOptionalContent 回傳一個 TPdfOptionalContentInventory,其 Groups 陣列帶著每個 OCG 的物件編號、名稱、intents、三個 Usage 狀態、語言、縮放範圍、Locked 旗標、選項按鈕群組索引與算好的 EffectiveVisible。方法先讓 PDFium 存出目前記憶體中的文件、展開物件串流,再掃描結果,所以工作階段裡較早做的編輯會被反映。組態索引 0 永遠是預設的 /D 字典,/Configs 的項目從索引 1 開始跟在後面;預設引數 -1 選的是索引 0。沒有 /OCProperties 的文件會讓方法回傳 False、原因放進 ErrorMessage,而不是丟例外

procedure TFormMain.ListLayers;
var
  Inv: TPdfOptionalContentInventory;
  G: TPdfOptionalContentGroup;
begin
  // Usage 預設 ocuView;-1 選組態 0,即 /D 字典
  if not Pdf.InspectOptionalContent(Inv) then
  begin
    Memo1.Lines.Add('No usable layers: ' + Inv.ErrorMessage);
    Exit;
  end;
  Memo1.Lines.Add(Format('Configuration %d: %s',
    [Inv.SelectedConfigurationIndex,
     string(Inv.Configurations[Inv.SelectedConfigurationIndex].Name)]));
  for G in Inv.Groups do
    Memo1.Lines.Add(Format('obj %d  %s  visible=%s  locked=%s  radio=%d',
      [G.ObjectNumber, string(G.Name),
       BoolToStr(G.EffectiveVisible, True),
       BoolToStr(G.Locked, True), G.RadioGroupIndex]));
end;

Memberships 陣列回報每個 OCMD 及其 Policy(ocmpAnyOn、ocmpAllOn、ocmpAnyOff、ocmpAllOff)、原始的 VisibilityExpression 文字與它自己的 EffectiveVisible。有幾條邊界規則是刻意的。/P 預設為 /AnyOn,沒有群組的 OCMD 算可見。指向不是已知 OCG 的物件編號的參照被視為可見,而不是讓整個運算式失敗。/VE 求值在巢狀深度 32 處停手,更深的都視為隱藏,這讓敵意或自我參照的運算式沒辦法把檢查變成堆疊溢位

用 SaveAsOptionalContentConfigured 寫入新的圖層狀態

TPdf.SaveAsOptionalContentConfigured 接受一個 TPdfOptionalContentStateChange 記錄陣列(群組物件編號加 Visible),寫出一份所選組態恰好產生該狀態的文件。所選組態拿到 /BaseState /ON 加上涵蓋每個群組的完整 /ON 與 /OFF 陣列,而每個已有 Usage 字典的 OCG 會收到符合新狀態的明確 ViewState(或按 Options.Usage 是 PrintState / ExportState)。用 TPdfOptionalContentConfigureOptions.Default 時,所選組態的 /AS 鍵會被移除,開啟、列印或匯出事件就沒辦法把圖層翻回去

procedure TFormMain.SaveWithoutDimensions(DimensionsObj, NotesObj: Integer);
var
  Changes: TPdfOptionalContentStateChanges;
  Options: TPdfOptionalContentConfigureOptions;
  Report: TPdfOptionalContentConfigureReport;
begin
  SetLength(Changes, 2);
  Changes[0].GroupObjectNumber := DimensionsObj;
  Changes[0].Visible := False;
  Changes[1].GroupObjectNumber := NotesObj;
  Changes[1].Visible := True;

  // 組態 0、ocuView、DisableAutomaticState 與 EnforceRadioGroups 為 True
  Options := TPdfOptionalContentConfigureOptions.Default;

  if not Pdf.SaveAsOptionalContentConfigured('C:\Out\Drawing-NoDims.pdf',
    Changes, Options, Report) then
    raise Exception.Create('Layer update rejected: ' + Report.ErrorMessage);

  Log(Format('%d of %d groups changed, %d Usage states rewritten, /AS removed: %s',
    [Report.ChangedGroupCount, Report.GroupCount,
     Report.UpdatedUsageStateCount,
     BoolToStr(Report.RemovedAutomaticState, True)]));
end;

寫入路徑把 PDFium 自己的存檔輸出當成逐位元組的前綴,只附加重寫過的組態擁有者與帶 Usage 字典的 OCG 物件,後面跟著新的 xref 區段與 trailer。在任何一個位元組抵達目的地之前,結果會在一個獨立的 TPdf 裡以嚴格載入政策重新打開,交叉參照表驗不過方法就失敗。檔案多載更進一步:它先寫到目標旁邊的暫存檔,驗證成功才替換目標,所以被拒的更新絕不會留下半寫的圖檔。這正是 PDFium Component 的 PDF 名稱樹與編號樹編輯器使用的同一套已驗證增量修訂做法

PDFium Component 的 SaveAsOptionalContentConfigured 如何寫出圖層已切換的 Delphi PDF:狀態變更與選項進場、所選組態以完整的 ON 與 OFF 陣列與 Usage 狀態重寫、附加已驗證的增量修訂,而且嚴格的重新開啟必須先驗過才輪得到任何寫入
組態化存檔把 PDFium 自己的重存當成位元組前綴,附加重寫的組態擁有者加一個新 xref 區段,並在碰目的地之前於獨立的 TPdf 裡重新開啟結果

組態化存檔拒絕做什麼?

組態化存檔拒絕任何文件自身禁止或無法安全表示的變更,而且每一次拒絕都發生在碰目的地之前。不在 /OCGs 裡的物件編號直接失敗。改動列在組態 /Locked 陣列裡的群組會失敗,不過重述它目前的值是被允許的。開著 EnforceRadioGroups 時,任何最後會有超過一個可見成員的 /RBGroups 集合都會被拒,而不是默默把其他成員關掉。加密文件被拒,因為明文的增量物件帶不動作用中的安全處理器。簽署文件會丟 EPdfError,除非您傳 AllowSignedDocument = True,因為改變頁面顯示的內容可能破壞簽章覆蓋範圍或認證政策

PDFium Component 的 SaveAsOptionalContentConfigured 在寫出組態化 Delphi PDF 之前套用的拒絕閘門:OCGs 之外的物件編號失敗、鎖定的群組失敗、可見成員超過一個的 RBGroups 集合被拒、加密文件帶不動明文增量物件、簽署檔案要求 AllowSignedDocument
每一次拒絕都發生在碰目的地之前,失敗原因落在 Report.ErrorMessage,而不是留下半寫的圖檔
function TFormMain.SavePrintPreset(Target: TStream;
  const Changes: TPdfOptionalContentStateChanges): Boolean;
var
  Options: TPdfOptionalContentConfigureOptions;
  Report: TPdfOptionalContentConfigureReport;
begin
  Options := TPdfOptionalContentConfigureOptions.Default;
  Options.Usage := ocuPrint;          // 寫出 /Print << /PrintState ... >>
  Options.ConfigurationIndex := 1;    // /Configs 的第一個項目,不是 /D
  try
    Result := Pdf.SaveAsOptionalContentConfigured(Target, Changes, Options,
      Report);                        // AllowSignedDocument 維持 False
    if not Result then
      ShowMessage(Report.ErrorMessage);
  except
    on E: EPdfError do
    begin
      ShowMessage(E.Message);         // 簽署檔案:Target 什麼都沒寫
      Result := False;
    end;
  end;
end;

把它接進批次工作之前,先弄清楚取捨。附加的修訂疊在 PDFium 的完整重存之上,不是您的原始檔案位元組,這正是簽署輸入需要明確同意的原因。重寫也把所選組態正規化成 /BaseState /ON,所以作者的 /Unchanged 或 /OFF 基線會被結果可見性相同的明確陣列取代。拿掉 /AS 會移除只在紙上出現的浮水印圖層這類花招;把 DisableAutomaticState 設成 False 可以保留那些規則,代價是接受它們可能在該事件上壓過您要求的狀態。好的一面是,PDF/A-2(ISO 19005-2 條款 6.9)與 PDF/UA(ISO 14289-1 條款 7.10)都禁止組態字典裡出現 /AS,所以預設輸出少了一項您的 PDFium Component 的 PDF/A 預檢驗證原本會回報的問題

圖層控制在 Delphi PDF 檢視器裡的位置

在檢視器裡,圖層控制是一份由清單驅動、再加一次重載已存結果的核取清單。用 Groups 填出核取清單、停用 Locked 的項目、把共享 RadioGroupIndex 的成員當成互斥,套用時寫進 TMemoryStream 再把那個串流載回 TPdf,讓檢視畫出新狀態。TPdf 與 TPdfView 之間的接線見用 Delphi 的 PDFium VCL 打造功能完整的 PDF 檢視器。授權、試用版下載與其餘功能清單在 PDFium Component for Delphi 產品頁