Skip to main content

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}