一套圖組送過來,尺寸標註自帶一個圖層,想藏起它們,直覺做法是把那個群組的參照從預設組態的 /ON 陣列搬進 /OFF。檔案存了、重開了,尺寸線卻照樣印在標題欄上。原因坐在旁邊一個物件裡——群組字典內部:/Usage << /View << /ViewState /ON >> >>,一個渲染器會搶在組態陣列之前讀取的明確狀態
圖層的狀態可以活在 PDF 的好幾個地方,改 /OCProperties 只碰得到其中一個。Google 的 PDFium 引擎渲染選擇性內容沒問題,但它的公開 C API 沒有列舉選擇性內容群組(OCG)、挑選組態或開關群組的函式。驅動圖層的 Delphi 程式碼得自己重寫物件,並且逐字重演渲染器的決策順序,否則圖層清單回報一種狀態、頁面顯示另一種
為什麼編輯 /ON 與 /OFF 改變不了 PDFium 渲染的結果?
/ON 與 /OFF 陣列只是五步中的第二步,後面還有兩步可以推翻它們。ISO 32000-1 §8.11.4.3 定義選擇性內容組態字典的 /BaseState、/ON、/OFF、/Locked、/RBGroups 與 /AS,§8.11.4.4 則定義 OCG 可攜帶的 Usage 字典。規範裡,Usage 描述群組的用途,/AS 裡的自動狀態條目把描述變成 ON 或 OFF。PDFium 走的是一條短路:按某個用途渲染時,群組自己針對該用途的明確狀態贏。摺在一起,順序如下:
- 從組態的
/BaseState起步,其中/ON與/Unchanged都算可見,只有/OFF會藏 - 套用組態的
/ON陣列,再套/OFF陣列,所以兩邊都列出的群組最後是隱藏的 - 套用群組針對所求用途的明確 Usage 狀態,例如
/Usage << /View << /ViewState /OFF >> >>,它壓過前面的一切 - 把
/Intent不含/View也不含/All的群組視為可見,因為它不參與檢視意圖的可見性 - 最後跑所選組態的
/AS陣列,其中符合事件的項目會設定它們所列群組的狀態
第三步正好解釋開頭那份檔案:某些匯出器在它建的每個群組裡都寫 /ViewState /ON,從此陣列怎麼編都藏不住那些圖層。列印與匯出遵循同一條規則,只多一個轉折:渲染器先讀 /PrintState 或 /ExportState,特定項目缺席時才退回 /ViewState。所以單獨一個 /ViewState /ON 連紙上也把圖層釘住了。第四步安靜些。被標成 /Intent /Design 的群組完全在檢視意圖可見性之外,無論陣列如何都會上色;沒有 /Intent 鍵的群組則視為 /View——規範給的預設
用 InspectOptionalContent 列出圖層與它們的渲染狀態
PDFium Component 在 TPdf.InspectOptionalContent 裡重演那個順序。方法填好一個 TPdfOptionalContentInventory、成功回傳 True;Usage 參數預設 ocuView,ConfigurationIndex 參數預設 -1,選中的是索引 0。組態索引 0 是預設的 /D 字典,/Configs 的項目從索引 1 起按檔案順序排列。掃描之前,方法會讓 PDFium 把開啟的文件存進記憶體並展開所有物件串流,所以工作階段裡較早的編輯會反映在結果中。沒有 /OCProperties 的檔案、或範圍之外的組態索引,會讓呼叫回傳 False、原因放進 ErrorMessage;這兩種情況都不丟例外
procedure TFormMain.ListLayers;
var
Inv: TPdfOptionalContentInventory;
Cfg: TPdfOptionalContentConfiguration;
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;
Cfg := Inv.Configurations[Inv.SelectedConfigurationIndex];
Memo1.Lines.Add(Format('Configuration %d "%s", %d groups, /AS present: %s',
[Inv.SelectedConfigurationIndex, string(Cfg.Name), Length(Inv.Groups),
BoolToStr(Cfg.HasAutomaticState, True)]));
for G in Inv.Groups do
Memo1.Lines.Add(Format('obj %d %-24s visible=%s locked=%s radio=%d',
[G.ObjectNumber, string(G.Name),
BoolToStr(G.EffectiveVisible, True),
BoolToStr(G.Locked, True), G.RadioGroupIndex]));
end;
每個 TPdfOptionalContentGroup 帶著自己的物件編號、名稱、Intents,以及 ViewState、PrintState、ExportState 三個明確狀態,各為 ocsUnspecified、ocsOn 或 ocsOff。當某個狀態讀到 ocsOn、而群組又坐在 /OFF 陣列裡,你就撞上了開頭那段講的陷阱。Locked 與 RadioGroupIndex 來自所選組態,不屬於任何 /RBGroups 集合的群組,其 RadioGroupIndex 是 -1
Usage 字典的其餘部分照檔案所寫回報:Language 與 LanguagePreferred、UserType 與 UserNames、PageElementSubtype,以及 HasZoomRange 旗標後面的 MinimumZoom 與 MaximumZoom。EffectiveVisible 不依賴其中任何一項;依縮放顯示是檢視器自己的事。每個 TPdfOptionalContentConfiguration 暴露名稱、BaseState、陣列內容與 RadioGroups,外加 OrderGroupObjectNumbers——把 /Order 攤平成群組參照、跳過巢狀標籤陣列的文字標籤
列印與匯出可能和螢幕不一致
Usage 參數會改變答案,這正是它存在的目的。在 ocuPrint 下,清單讀 /PrintState 並帶退回 /ViewState 的後備,且只有 /Event 為 /Print 的 /AS 條目參與。專為紙上出現而建的浮水印圖層,因此在 ocuView 下回報隱藏、在 ocuPrint 下回報可見。把兩次掃描比一比,就能在列印稿讓任何人吃驚之前找到這類圖層
procedure TFormMain.ReportPrintOnlyLayers;
var
OnScreen, OnPaper: TPdfOptionalContentInventory;
I: Integer;
begin
if not Pdf.InspectOptionalContent(OnScreen, ocuView) or
not Pdf.InspectOptionalContent(OnPaper, ocuPrint) then
Exit;
// 兩次掃描讀的是同一個 /OCGs 陣列,群組順序因此一致
for I := 0 to High(OnScreen.Groups) do
if OnScreen.Groups[I].EffectiveVisible <> OnPaper.Groups[I].EffectiveVisible then
Memo1.Lines.Add(Format('%s: screen=%s, print=%s',
[string(OnScreen.Groups[I].Name),
BoolToStr(OnScreen.Groups[I].EffectiveVisible, True),
BoolToStr(OnPaper.Groups[I].EffectiveVisible, True)]));
end;
OCMD 可見性運算式是怎麼求值的?
選擇性內容成員資格字典(OCMD,ISO 32000-1 §8.11.2.2)透過一個 /P 政策把一塊內容與多個群組綁在一起,或透過以 /And、/Or 與 /Not 組合群組的 /VE 可見性運算式。規範給 /P 預設值 /AnyOn,規定 /VE 存在時優先於 /OCGs 與 /P,並說明沒有群組的 OCMD 對可見性沒有影響
清單的 Memberships 陣列按同一趟掃描的逐群組結果遵循那些規則,你要的 usage 因此一路貫穿。每個成員資格回報它的群組物件編號、Policy(ocmpAnyOn、ocmpAllOn、ocmpAnyOff、ocmpAllOff,或四者之外名稱的 ocmpUnknown)、原始的 VisibilityExpression 文字,以及它自己的 EffectiveVisible。沒有群組的 OCMD 算可見。指向非已知 OCG 物件編號的參照也算可見,單一懸空參照不會藏掉周圍的一切。/VE 求值在巢狀深度 32 處停手、更深一律視為隱藏,自我參照的運算式因此撐爆不了堆疊。掃描列出每個以間接物件儲存、帶 /Type /OCMD 的 OCMD,物件串流在內
用 SaveAsOptionalContentConfigured 寫入新的圖層狀態
TPdf.SaveAsOptionalContentConfigured 接受一個 TPdfOptionalContentStateChange 記錄陣列,每筆帶一個 GroupObjectNumber 與一個 Visible 旗標。它寫出的副本,在五步順序下由所選組態恰好產生那個狀態。你沒提到的群組,保持清單為它們算出的可見性
procedure TFormMain.SaveWithoutDimensions(DimensionsObj, NotesObj: Integer;
ForPrint: Boolean);
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;
Options := TPdfOptionalContentConfigureOptions.Default;
if ForPrint then
begin
Options.Usage := ocuPrint; // 寫出 /Print << /PrintState ... >>
Options.ConfigurationIndex := 1; // 第一個 /Configs 項目,/D 維持原樣
end;
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 名稱樹與編號樹編輯器用的是同一套已驗證增量修訂做法。要確認列印預設,重新載入輸出並呼叫 InspectOptionalContent(Inv, ocuPrint, 1);群組應該回報你傳入的狀態
組態化存檔拒絕做什麼?
組態化存檔拒絕任何文件禁止或無法安全承載的變更,且每一次拒絕都發生在寫入目的地之前。檢查按固定順序執行。簽署文件會在任何事發生前引發 EPdfError,除非你傳 AllowSignedDocument = True。加密文件接著被拒,因為明文增量物件帶不動作用中的安全處理器。然後逐筆檢查要求的變更:不在 /OCGs 裡的物件編號直接失敗,改動列在組態 /Locked 陣列裡的群組會失敗,雖然重述它目前的值可以過。最後,開著 EnforceRadioGroups 時,最終會有超過一個可見成員的 /RBGroups 集合被拒。除簽章那個之外,每一次拒絕都回傳 False、原因放進 Report.ErrorMessage
其中兩道閘門比規範嚴格要求的走得更遠。§8.11.4.3 把 /Locked 描述為對抗透過檢視器使用者介面變更的保護,並把選項按鈕群組描述為「一個被打開時把其他成員關掉」。PDFium Component 對程式化變更也套用這兩條,而且對選項按鈕群組是拒絕而不是替你挑成員關掉。對一個互斥集合要求兩個成員,庫無從知道你指的是哪一個,瞎猜又會跟你的請求矛盾。在同一個 Changes 陣列裡把其他成員一併關掉,存檔就能通過
簽署文件閘門的存在理由是:附加修訂疊在 PDFium 的完整重存之上,那是一段新的位元組序列;既有簽章覆蓋的位元組範圍無從假定還活著。只有在接受失去那份簽章時,才傳 AllowSignedDocument = True
批次工作裡有三個副作用要緊。重寫把所選組態正規化成 /BaseState /ON,作者的 /Unchanged 或 /OFF 基線因此變成結果可見性相同的明確陣列。拿掉 /AS 也移除了只在紙上出現的浮水印圖層這類花招;把 DisableAutomaticState 設為 False 可保留那些規則,代價是接受它們可能在自家事件上壓過你要求的狀態。而且 /ON 與 /OFF 陣列是按為 Options.Usage 算出的狀態寫的,所以存進組態 0 的列印預設,也會改變螢幕上每個沒有 Usage 字典的群組。列印預設請放 /Configs 項目,把 /D 留給螢幕。合規方面,PDF/A-2(ISO 19005-2 條款 6.9)與 PDF/UA(ISO 14289-1 條款 7.10)都禁止組態字典裡出現 /AS,所以預設輸出順手清掉一項你的 PDFium Component 的 PDF/A 預檢驗證原本會回報的發現
在 Delphi 檢視器裡套用圖層變更
在檢視器裡,圖層控制是一份由清單建出的核取清單,後面跟著存進記憶體與重新載入。用 Groups 填核取清單,停用 Locked 旗標已設的項目,把共享 RadioGroupIndex 的成員當互斥,套用時透過串流多載寫進 TMemoryStream。TPdf.LoadDocument 要元件處於未作用狀態,所以先把它關掉;緩衝載入會複製位元組,你可以立刻釋放串流
procedure TFormMain.ApplyLayerChecklist(
const Changes: TPdfOptionalContentStateChanges);
var
Report: TPdfOptionalContentConfigureReport;
Output: TMemoryStream;
KeepPage: Integer;
begin
Output := TMemoryStream.Create;
try
try
if not Pdf.SaveAsOptionalContentConfigured(Output, Changes,
TPdfOptionalContentConfigureOptions.Default, Report) then
begin
ShowMessage('Layer update rejected: ' + Report.ErrorMessage);
Exit;
end;
except
on E: EPdfError do
begin
ShowMessage(E.Message); // 簽署檔案:Output 仍然是空的
Exit;
end;
end;
KeepPage := Pdf.PageNumber;
Pdf.Active := False;
Pdf.LoadDocument(Output); // Buffered 預設 True
Pdf.PageNumber := KeepPage;
finally
Output.Free;
end;
end;
重載之後,PDFium 按新組態上色,螢幕與下一次 InspectOptionalContent 呼叫於是說同一種話。TPdf 與 TPdfView 之間的接線見用 Delphi 的 PDFium VCL 打造功能完整的 PDF 檢視器。文件還沒有圖層而你需要加的時候,在 Delphi 用 OCG 與 OCMD 建立 PDF 圖層會走一遍 SaveAsOptionalContentCreated
定價、試用建置與元件的其他文件功能,列在PDFium Component for Delphi 產品頁