PDFium Component 透過 TPdf.SaveAsOptionalContentCreated 從 Delphi 建立新的 PDF 圖層。在一次經過驗證的存檔裡,方法加入可選內容群組(OCG)、可選內容成員字典(OCMD),以及呼叫方提供、包進 Form XObject 的繪圖,其 /OC 條目指向新群組,PDFium 與其他檢視器因此在圖層切換時顯示或隱藏那份繪圖
需求很少是抽象的。工廠工程團隊想在同一張圖紙上放公制與英制尺寸,恰好一組可見。印刷廠想在螢幕上顯示、但在打樣前能關掉的裁切線覆蓋層。這些檔案都還沒有任何圖層,所以無東西可切換。切換既有圖層是另一份工作,見用 SaveAsOptionalContentConfigured 切換既有 PDF 可選內容圖層;本文講的是把圖層造出來
為什麼不能用 PDFium 直接在頁面物件上設 /OC?
PDFium 沒有為既有頁面物件附加可選內容的公開函式。公開標頭裡沒有 FPDFPageObj_SetOC 或任何等價物,所以常規的先編輯再 FPDFPage_GenerateContent 路線產不出受控內容。閘門必須寫進檔案結構,唯一的問題是 PDFium 渲染時認哪種結構
ISO 32000-1 §8.11.3 給了三種讓圖形可選的方式,PDFium 對它們的處理不同:
- 包在
/OC /Name BDC...EMC標記內容裡的頁面內容流區段,渲染時按頁面物件檢查 - 自身字典帶
/OC條目的 Form XObject 與影像 XObject,繪製 XObject 時檢查 - 註解字典裡帶
/OC條目的註解,PDFium 渲染路徑完全不看它,所以受控註解無論圖層狀態如何都保持可見
標記內容包裝意味著逐運算子改寫既有內容流,註解則被渲染器排除。剩下 Form XObject 路線,這正是 PDFium Component 用的:您的繪圖放進一個新的 Form XObject,/OC 指向群組,表單註冊進頁面的 /Resources /XObject 字典,一條只含單個 /Name Do 的新內容流追加到頁面 /Contents。既有頁面內容從不被解析或改寫
在 Delphi 中描述群組與受控表單
TPdf.SaveAsOptionalContentCreated 收三個陣列、回傳一份報告。兩個重載——一個寫 TStream、一個寫檔名——共用同一組參數:Groups: TPdfOptionalContentCreateGroups、Memberships: TPdfOptionalContentCreateMemberships、Forms: TPdfOptionalContentCreateForms、out Report: TPdfOptionalContentCreateReport,以及可選的 AllowSignedDocument: Boolean = False。這個方法隨 PDFium Component v3.154.0 推出
每個 TPdfOptionalContentCreateGroup 有 Name、可選的 Creator、pociView 或 pociDesign 的 Intent、InitialStateVisible 旗標、Locked 旗標與 RadioTag。共用同一個 RadioTag 的群組落進同一個 /RBGroups 陣列,檢視器靠它知道開一個就關其他。每個 TPdfOptionalContentCreateForm 以 GroupIndex(您 Groups 陣列的零基索引)指名它的閘控群組,外加一個從 1 起算的 PageNumber、原始內容流位元組、邊界方塊、可選矩陣與可選 ResourceName
var
Pdf: TPdf;
Groups: TPdfOptionalContentCreateGroups;
Forms: TPdfOptionalContentCreateForms;
Report: TPdfOptionalContentCreateReport;
begin
Pdf := TPdf.Create(nil);
try
Pdf.FileName := 'sheet-a1.pdf';
Pdf.Active := True;
SetLength(Groups, 2); // SetLength 會把記錄歸零
Groups[0].Name := 'Dimensions (mm)';
Groups[0].Intent := pociView;
Groups[0].InitialStateVisible := True;
Groups[0].RadioTag := 'Units';
Groups[1].Name := 'Dimensions (in)';
Groups[1].Intent := pociView;
Groups[1].InitialStateVisible := False;
Groups[1].RadioTag := 'Units';
SetLength(Forms, 2);
Forms[0].GroupIndex := 0;
Forms[0].PageNumber := 1;
Forms[0].Content := TEncoding.ASCII.GetBytes(
'0 0 1 RG 1.5 w 72 120 m 540 120 l S');
Forms[0].BBoxRight := 612;
Forms[0].BBoxTop := 792;
Forms[0].ResourceName := 'DimMetric';
Forms[1] := Forms[0];
Forms[1].GroupIndex := 1;
Forms[1].Content := TEncoding.ASCII.GetBytes(
'1 0 0 RG 1.5 w 72 140 m 540 140 l S');
Forms[1].ResourceName := 'DimImperial';
if not Pdf.SaveAsOptionalContentCreated('sheet-a1-layers.pdf',
Groups, nil, Forms, Report) then
raise Exception.Create(Report.ErrorMessage);
finally
Pdf.Free;
end;
end;
注意表單內容是純向量繪圖。元件在每個表單上只寫一個空的 /Resources 字典,所以內容流必須自足:裝置色、路徑、裁剪與變換都能用,但任何需要具名資源的東西(Tf 要的字型、Do 要的影像、gs 要的 ExtGState)都沒有可解析的對象。ResourceName 留空時,表單命名為 FmOc1、FmOc2 依此類推;自訂名稱只能含字母、數字、底線、減號與句點
OCMD 比普通群組多給了什麼?
可選內容成員字典讓可見性一次取決於多個群組。ISO 32000-1 §8.11.2.2 定義兩種形式:帶 /P 政策(如 /AnyOn 或 /AllOn)的 /OCs 清單,以及由規範中的 /And、/Or、/Not 組成的 /VE 可見性運算式。PDFium Component 收一個帶 MemberGroupIndices、Policy 與 Expression 的 TPdfOptionalContentCreateMembership;Expression.GroupIndices 非空時,運算式勝出,/VE 與 /OCs 並列寫出
procedure AddUnitMemberships(var Memberships: TPdfOptionalContentCreateMemberships);
begin
SetLength(Memberships, 2);
// 任一單位圖層開啟時可見:/OCs [...] /P /AnyOn
SetLength(Memberships[0].MemberGroupIndices, 2);
Memberships[0].MemberGroupIndices[0] := 0;
Memberships[0].MemberGroupIndices[1] := 1;
Memberships[0].Policy := ocmpAnyOn;
// 僅在英制圖層關閉時可見:/VE [ /Not ref ]
SetLength(Memberships[1].MemberGroupIndices, 1);
Memberships[1].MemberGroupIndices[0] := 1;
Memberships[1].Expression.Kind := poceNot;
SetLength(Memberships[1].Expression.GroupIndices, 1);
Memberships[1].Expression.GroupIndices[0] := 1;
end;
支援的子集刻意很窄。政策限 ocmpAllOn 與 ocmpAnyOn;ocmpAnyOff 與 ocmpAllOff 被拒。運算式一層深,以 poceAll、poceAny 或 poceNot 作用於已建立的群組,/Not 恰好要一個群組索引。poceAll 序列化為 /And,poceAny 為 /Or,poceNot 為 /Not,這是 §8.11.2.2 定義的僅有三個運算子。3.181.2 之前的版本寫的是 /All 與 /Any,PDFium 把它們評估為隱藏,所以若您用舊版建過 OCMD,請重新生成,別相信那個運算式。每個成員即使帶運算式,MemberGroupIndices 也至少要一項,因為那份清單會變成 /OCs 陣列。新字典的物件編號在 Report.CreatedMembershipObjectNumbers 裡回傳
在 OCMD 上繼續蓋樓之前,有兩個行為值得知道。第一,同一次呼叫裡的受控表單只參照群組:TPdfOptionalContentCreateForm.GroupIndex 索引的是 Groups,不是 Memberships,所以表單無法透過這個 API 被新建的 OCMD 閘控。第二,PDFium Component 把每個新 OCMD 加進 catalog 的 /OCProperties /OCGs 陣列,而 /ON、/OFF 與 /Order 保持僅限群組。ISO 32000-1 §8.11.4.2 把 /OCGs 描述為群組清單,但少了那次註冊,TPdf.InspectOptionalContent 根本不會列舉這個成員。副作用是輸出的盤點會把每個 OCMD 列成 Inventory.Groups 裡多出來的無名條目,所以請按 Name 而不是按位置比對群組
元件怎麼修補既有的 /OCProperties?
TPdf.SaveAsOptionalContentCreated 要嘛從零建 /OCProperties,要嘛擴充 catalog 裡已有的那個,擴充靠追加,從不重建。沒有圖層的文件得到一個新字典,帶 /OCGs 與一個預設配置 /D,後者攜帶 /ON、/OFF 與 /Order,有群組要求時再加 /Locked 與 /RBGroups,布局按 ISO 32000-1 §8.11.4.3
對已有圖層的文件,每個目標陣列都以「在收尾括號之前插入新參照」的方式修補。這聽起來挑剔,但它有理由。/Order 陣列可以裝巢狀陣列與文字標籤,它們塑造檢視器顯示的圖層樹,早先一版把陣列攤平重建的做法弄丟了那些標籤。在末尾插入既保留既有樹,又把新群組簡單列在其後。住在自己間接物件裡的陣列會被解析並原地修補
從 v3.175.0 起,預設配置本身也可以是間接的。有些生產器寫 /OCProperties << /OCGs [...] /D 12 0 R >>,PDFium Component 現在會追進那個參照、修補目標字典,而不是以畸形條目錯誤失敗。直接與間接的頁面 /Resources 字典,包括間接的 /XObject 子字典,也照同樣方式處理
證明新圖層真的能切換
存在於 /OCProperties 卻什麼都不閘控的圖層,很容易造出來也很難察覺。開發期間,表單寫入器的第一版把一個已帶 N 0 obj 頭與 endobj 尾的流物件又包進了第二層頭。PDFium 解析不了重複的物件,而且不報錯,悄悄跳過了 Do 運算子。檔案開得了、圖層面板看著正常,受控繪圖卻從不出現。暴露它的是一支切換圖層、比較渲染像素的探針:毫無差異,所以哪裡不對
這就是方法在交付前先驗證的原因。輸出經嚴格載入重開,交叉參照表受檢,每個被要求的群組透過 InspectOptionalContent 讀回,確認名稱、初始狀態與鎖定註冊。全部通過之後輸出才複製到您的流,檔案重載寫暫存檔並原子式替換目標。您可以自己跑同一個切換檢查:重開輸出、按名稱找到群組、以相反狀態存一份副本
function SaveImperialPreset(const SourceName, TargetName: string): Boolean;
var
Pdf: TPdf;
Inventory: TPdfOptionalContentInventory;
Changes: TPdfOptionalContentStateChanges;
Options: TPdfOptionalContentConfigureOptions;
CfgReport: TPdfOptionalContentConfigureReport;
I: Integer;
begin
Pdf := TPdf.Create(nil);
try
Pdf.FileName := SourceName;
Pdf.Active := True;
if not Pdf.InspectOptionalContent(Inventory) then
raise Exception.Create(Inventory.ErrorMessage);
for I := 0 to High(Inventory.Groups) do
if (Inventory.Groups[I].Name = 'Dimensions (mm)') or
(Inventory.Groups[I].Name = 'Dimensions (in)') then
begin
SetLength(Changes, Length(Changes) + 1);
Changes[High(Changes)].GroupObjectNumber := Inventory.Groups[I].ObjectNumber;
Changes[High(Changes)].Visible := Inventory.Groups[I].Name = 'Dimensions (in)';
end;
Options := TPdfOptionalContentConfigureOptions.Default;
Result := Pdf.SaveAsOptionalContentConfigured(TargetName, Changes,
Options, CfgReport);
finally
Pdf.Free;
end;
end;
兩個檔案各渲染第一頁,藍線與紅線應該互換。沒有互換的話,先在盤點裡檢視群組的 EffectiveVisible 值;它反映的是配置陣列與使用規則套用之後,PDFium 實際會渲染的狀態
該提前規劃的限制與陷阱
TPdf.SaveAsOptionalContentCreated 是一個有邊界的 profile,多數意外來自把它當成通用圖層編輯器。下列約束都提前執行,違反的呼叫以設好的 Report.ErrorMessage 失敗、不寫任何輸出:
- 至少需要一個群組;群組、成員與表單各以 4096 條目為上限,群組名稱必須唯一、長 1 到 255 字元,每個表單最多收 16 MiB 的內容位元組
- 加密文件被拒,已簽署文件需要
AllowSignedDocument = True;輸出是完整改寫,既有簽章將不再驗得過 - 每個
Forms條目變成自己的 Form XObject,所以把同一個覆蓋層放上 200 頁,內容會存 200 份而不是一份 - 新的
Do流追加在既有頁面內容之後,所以它跑在先前流留下的任何圖形狀態裡;結構良好的頁面會還原狀態,但q/Q不平衡的頁面可能讓您的覆蓋層位移 - 既有頁面內容永遠不會被搬進圖層;被閘控的只有您放進
Forms的繪圖
大文件要掂量的是重複這一點。如果覆蓋層很重、每頁都出現,基於 Form XObject 的可重用頁面印章的共享物件做法在檔案裡只留一份,代價是自己處理圖層閘門
FAQ
PDFium Component 能把既有頁面內容放進新圖層嗎?
不能。TPdf.SaveAsOptionalContentCreated 只閘控您在 Forms 裡提供的內容;它不把既有運算子包進標記內容,也不把它們搬進 XObject。要給既有圖稿分層,請把它重新生成為自足的表單內容,並另一步移除原繪圖
圖層關閉時,受控註解為什麼還看得見?
PDFium 的渲染路徑不檢查註解字典的 /OC 條目,所以註解層級的閘控在基於 PDFium 的檢視器裡沒有效果。這正是 PDFium Component 透過 Form XObject 附加圖層內容的原因——PDFium 對它們確實閘控
之後切換新圖層時該用哪些物件編號?
Report.CreatedGroupObjectNumbers 存的是剛寫出的那份檔案裡的編號。之後重開該檔並配置它時,請取 TPdf.InspectOptionalContent 的 ObjectNumber,並按 Name 比對——即使後來的存檔重新編號物件,這樣仍然正確
PDFium Component 把 SaveAsOptionalContentCreated、SaveAsOptionalContentConfigured 與 InspectOptionalContent 作為一套可選內容工具組提供給 Delphi 與 C++Builder,建構在 PDFium 渲染器上。細節與下載在PDFium Component 產品頁