stet_pdf_reader/layers/ocmd.rs
1// stet-pdf-reader
2// Copyright (c) 2026 Scott Bowman
3// SPDX-License-Identifier: Apache-2.0 OR MIT
4
5//! Optional Content Membership Dictionary (OCMD) parsing.
6//!
7//! Defined in ISO 32000-2 §8.11.2.2. An OCMD wraps a set of OCGs in a
8//! visibility predicate that's more expressive than a single OCG ref:
9//!
10//! - `/P` membership policy over `/OCGs` — `AllOn` / `AnyOn` /
11//! `AllOff` / `AnyOff`. Default policy is `AnyOn`.
12//! - `/VE` visibility expression — a nested array using `/And` /
13//! `/Or` / `/Not` operators over OCG refs. Available since
14//! PDF 1.6.
15//!
16//! `/VE` takes precedence over `/P` when both are present.
17//!
18//! This module produces [`OcgVisibility`] values for the display
19//! list. The matching evaluator lives in
20//! `stet_graphics::layer_set::LayerSet`.
21
22use stet_graphics::display_list::{MembershipPolicy, OcgVisibility, VisibilityExpr};
23
24use crate::diagnostics::{ParsePhase, Severity, WarningSink};
25use crate::objects::{PdfDict, PdfObj};
26use crate::resolver::Resolver;
27
28/// Maximum nesting depth for `/VE` expressions.
29///
30/// Real-world OCMDs nest 1–3 levels deep; this cap stops pathological
31/// or maliciously cyclic expressions from blowing the stack.
32const MAX_VE_DEPTH: u32 = 64;
33
34/// Build an [`OcgVisibility`] for an OCMD dict.
35///
36/// The caller has already determined that the dict's `/Type` is
37/// `/OCMD`. `default_visible` is the variant-level fallback returned
38/// by [`stet_graphics::layer_set::LayerSet::evaluate`] when no leaf
39/// has an explicit override; pass the result of statically evaluating
40/// the OCMD against the document's default-config OCG state.
41pub fn build_ocmd_visibility(
42 resolver: &Resolver,
43 ocmd: &PdfDict,
44 default_visible: bool,
45 sink: &WarningSink,
46) -> OcgVisibility {
47 if let Some(ve_obj) = ocmd.get(b"VE") {
48 let resolved = resolver.deref(ve_obj).ok();
49 let view = resolved.as_ref().unwrap_or(ve_obj);
50 if let Some(arr) = view.as_array() {
51 if let Some(expr) = parse_visibility_expression(resolver, arr, 0, sink) {
52 return OcgVisibility::Expression {
53 expr,
54 default_visible,
55 };
56 }
57 // Unparseable /VE — fall through to /P / /AnyOn membership.
58 } else {
59 sink.record(
60 ParsePhase::Layers,
61 None,
62 Severity::Warning,
63 "/VE expression on OCMD is not an array; ignored",
64 );
65 }
66 }
67
68 let mut ocg_ids = Vec::new();
69 if let Some(ocgs_obj) = ocmd.get(b"OCGs") {
70 match ocgs_obj {
71 PdfObj::Ref(num, _) => ocg_ids.push(*num),
72 PdfObj::Array(arr) => {
73 for item in arr {
74 if let Some((num, _)) = item.as_ref() {
75 ocg_ids.push(num);
76 }
77 }
78 }
79 _ => {}
80 }
81 }
82
83 let policy = match ocmd.get_name(b"P") {
84 Some(b"AllOn") => MembershipPolicy::AllOn,
85 Some(b"AllOff") => MembershipPolicy::AllOff,
86 Some(b"AnyOff") => MembershipPolicy::AnyOff,
87 _ => MembershipPolicy::AnyOn,
88 };
89
90 OcgVisibility::Membership {
91 ocg_ids,
92 policy,
93 default_visible,
94 }
95}
96
97/// Parse an OCMD `/VE` array into a [`VisibilityExpr`].
98///
99/// The grammar from ISO 32000-2 §8.11.2.2:
100///
101/// ```text
102/// expr := array-form | ocg-ref
103/// array-form := [ /And operand+ ]
104/// | [ /Or operand+ ]
105/// | [ /Not operand ] (exactly one operand)
106/// operand := expr
107/// ocg-ref := indirect ref to an OCG dict
108/// ```
109///
110/// Returns `None` and emits a warning when the array is malformed
111/// (unknown leading name, wrong arity on `/Not`, missing operands,
112/// nested non-array non-ref leaves).
113pub fn parse_visibility_expression(
114 resolver: &Resolver,
115 arr: &[PdfObj],
116 depth: u32,
117 sink: &WarningSink,
118) -> Option<VisibilityExpr> {
119 if depth > MAX_VE_DEPTH {
120 sink.record(
121 ParsePhase::Layers,
122 None,
123 Severity::Error,
124 format!("/VE depth limit {MAX_VE_DEPTH} reached; expression truncated"),
125 );
126 return None;
127 }
128
129 // Empty array — never valid.
130 let head = arr.first()?;
131 let head_name = head.as_name()?;
132
133 let parse_operand = |obj: &PdfObj| -> Option<VisibilityExpr> {
134 // Operands are either arrays (sub-expressions) or OCG refs.
135 if let Some((ocg_id, _)) = obj.as_ref() {
136 // Resolve once to confirm it points at an OCG, but emit
137 // even if it doesn't (keeps the structural shape).
138 return Some(VisibilityExpr::Layer(ocg_id));
139 }
140 let resolved = resolver.deref(obj).ok();
141 let view = resolved.as_ref().unwrap_or(obj);
142 if let Some(sub_arr) = view.as_array() {
143 return parse_visibility_expression(resolver, sub_arr, depth + 1, sink);
144 }
145 sink.record(
146 ParsePhase::Layers,
147 None,
148 Severity::Warning,
149 "/VE operand is neither an OCG ref nor a nested array; dropped",
150 );
151 None
152 };
153
154 match head_name {
155 b"And" | b"Or" => {
156 let mut operands = Vec::new();
157 for item in &arr[1..] {
158 if let Some(o) = parse_operand(item) {
159 operands.push(o);
160 }
161 }
162 if operands.is_empty() {
163 sink.record(
164 ParsePhase::Layers,
165 None,
166 Severity::Warning,
167 format!(
168 "/VE /{} has no usable operands; dropped",
169 std::str::from_utf8(head_name).unwrap_or("?")
170 ),
171 );
172 return None;
173 }
174 if head_name == b"And" {
175 Some(VisibilityExpr::And(operands))
176 } else {
177 Some(VisibilityExpr::Or(operands))
178 }
179 }
180 b"Not" => {
181 if arr.len() != 2 {
182 sink.record(
183 ParsePhase::Layers,
184 None,
185 Severity::Warning,
186 format!("/VE /Not expects 1 operand, got {}; dropped", arr.len() - 1),
187 );
188 return None;
189 }
190 let inner = parse_operand(&arr[1])?;
191 Some(VisibilityExpr::Not(Box::new(inner)))
192 }
193 other => {
194 sink.record(
195 ParsePhase::Layers,
196 None,
197 Severity::Warning,
198 format!(
199 "/VE has unknown leading operator /{}; dropped",
200 String::from_utf8_lossy(other)
201 ),
202 );
203 None
204 }
205 }
206}