Skip to main content

pdfboss_core/
oc.rs

1//! Optional-content visibility (ISO 32000-1 §8.11): which optional content
2//! groups the document's default configuration turns off, and whether
3//! content gated by an `/OC` entry or a `BDC /OC` span is visible.
4
5use std::sync::Arc;
6
7use crate::hash::FastSet;
8use crate::object::{Dict, ObjRef, Object};
9use crate::source::AsyncObjectSource;
10
11/// Maximum `/VE` visibility-expression nesting depth. Real expressions are
12/// one or two levels deep; past the cap the expression reads as malformed,
13/// and malformed means visible.
14const MAX_VE_DEPTH: u32 = 8;
15
16/// The document's optional-content visibility under its default
17/// configuration (`/OCProperties` `/D`, ISO 32000-1 §8.11.4.3): the set of
18/// groups that configuration turns off. A group's identity is its indirect
19/// reference — groups are shared by reference between the configuration,
20/// marked-content properties, and `/OC` entries (§8.11.2.1).
21///
22/// Everything here is lenient: an entry that is missing, malformed, or will
23/// not resolve leaves content visible, never hidden.
24#[derive(Debug, Clone, Default, PartialEq, Eq)]
25pub struct OcState {
26    off: FastSet<ObjRef>,
27}
28
29impl OcState {
30    /// Builds the state from the catalog's `/OCProperties`, or `None` when
31    /// the document declares none — no optional content, everything
32    /// visible. The default `/D` configuration is applied in specification
33    /// order: `/BaseState` (default `ON`), then `/ON`, then `/OFF`, so a
34    /// group named in both `/ON` and `/OFF` ends up off.
35    pub async fn load_with<S: AsyncObjectSource>(src: &S, trailer: &Dict) -> Option<OcState> {
36        let root = trailer.get("Root")?;
37        let catalog = src.resolve(root).await.ok()?;
38        let props = src
39            .resolve(catalog.as_dict()?.get("OCProperties")?)
40            .await
41            .ok()?;
42        let props = props.as_dict()?;
43        let config = match props.get("D") {
44            Some(o) => src.resolve(o).await.ok(),
45            None => None,
46        };
47        let config = config.as_ref().and_then(Object::as_dict);
48        let base_off = match config.and_then(|d| d.get("BaseState")) {
49            Some(o) => matches!(
50                src.resolve(o).await.ok().as_ref().and_then(Object::as_name),
51                Some(n) if n.0 == "OFF"
52            ),
53            None => false,
54        };
55        let mut off: FastSet<ObjRef> = FastSet::default();
56        if base_off {
57            off.extend(group_refs(src, props.get("OCGs")).await);
58        }
59        if let Some(config) = config {
60            for group in group_refs(src, config.get("ON")).await {
61                off.remove(&group);
62            }
63            off.extend(group_refs(src, config.get("OFF")).await);
64        }
65        Some(OcState { off })
66    }
67
68    /// Whether the configuration turns `group` off.
69    pub fn hidden(&self, group: ObjRef) -> bool {
70        self.off.contains(&group)
71    }
72
73    /// Whether content gated by `value` — the operand of a stream or
74    /// annotation dictionary's `/OC` entry — is visible: a group reference
75    /// is visible unless the group is off; a membership dictionary follows
76    /// its `/VE` visibility expression, else its `/P` policy over `/OCGs`.
77    /// A direct group dictionary has no
78    /// reference identity to be turned off by, and anything malformed is
79    /// left visible.
80    pub async fn visible_with<S: AsyncObjectSource>(&self, src: &S, value: &Object) -> bool {
81        match value {
82            Object::Ref(r) => {
83                let Ok(resolved) = src.resolve(value).await else {
84                    return true;
85                };
86                let Some(dict) = resolved.as_dict() else {
87                    return true;
88                };
89                if is_ocmd(dict) {
90                    return self.ocmd_visible(src, dict).await;
91                }
92                !self.hidden(*r)
93            }
94            Object::Dict(dict) => {
95                if is_ocmd(dict) {
96                    return self.ocmd_visible(src, dict).await;
97                }
98                true
99            }
100            _ => true,
101        }
102    }
103
104    /// Whether a `BDC /OC` span is visible: `props` is the operator's
105    /// properties operand — an inline dictionary, or a name looked up in
106    /// the resource chain's `/Properties` category. The lookup keeps the
107    /// value unresolved, because a group's on/off identity is its indirect
108    /// reference; resolving first would read every named group as visible.
109    pub async fn props_visible_with<S: AsyncObjectSource>(
110        &self,
111        src: &S,
112        chain: &[Arc<Dict>],
113        props: &Object,
114    ) -> bool {
115        let named;
116        let value = match props {
117            Object::Name(name) => {
118                named = properties_value(src, chain, &name.0).await;
119                match &named {
120                    Some(value) => value,
121                    None => return true,
122                }
123            }
124            other => other,
125        };
126        self.visible_with(src, value).await
127    }
128
129    /// A membership dictionary's visibility (§8.11.2.2): the `/VE`
130    /// visibility expression when present (taking precedence, malformed
131    /// reading as visible), else the `/OCGs` groups under the `/P` policy —
132    /// `AnyOn` (the default, and the reading of an unrecognized policy),
133    /// `AllOn`, `AnyOff`, or `AllOff`. No usable groups means visible.
134    async fn ocmd_visible<S: AsyncObjectSource>(&self, src: &S, dict: &Dict) -> bool {
135        if let Some(ve) = dict.get("VE") {
136            let Ok(Object::Array(expr)) = src.resolve(ve).await else {
137                return true;
138            };
139            return self.expression_visible(src, expr).await.unwrap_or(true);
140        }
141        let groups: Vec<ObjRef> = match dict.get("OCGs") {
142            None => return true,
143            Some(indirect @ Object::Ref(r)) => match src.resolve(indirect).await {
144                Ok(Object::Array(items)) => items.iter().filter_map(Object::as_ref).collect(),
145                Ok(Object::Dict(_)) => vec![*r],
146                _ => Vec::new(),
147            },
148            Some(Object::Array(items)) => items.iter().filter_map(Object::as_ref).collect(),
149            Some(_) => Vec::new(),
150        };
151        if groups.is_empty() {
152            return true;
153        }
154        let policy = match dict.get("P") {
155            Some(o) => src
156                .resolve(o)
157                .await
158                .ok()
159                .and_then(|o| o.as_name().map(|n| n.0.clone())),
160            None => None,
161        };
162        match policy.as_deref() {
163            Some("AllOn") => groups.iter().all(|g| !self.hidden(*g)),
164            Some("AnyOff") => groups.iter().any(|g| self.hidden(*g)),
165            Some("AllOff") => groups.iter().all(|g| self.hidden(*g)),
166            _ => groups.iter().any(|g| !self.hidden(*g)),
167        }
168    }
169
170    /// Evaluates a `/VE` array (§8.11.2.3): `[/And|/Or|/Not operands…]`,
171    /// each operand a group reference or a nested expression (directly, or
172    /// behind a reference). `None` is malformed — an unknown operator, no
173    /// operands, `/Not` with more than one, an operand that is neither
174    /// group nor expression, or nesting past [`MAX_VE_DEPTH`] — and reads
175    /// as visible at the caller.
176    ///
177    /// An explicit work stack rather than recursion: a recursive `async fn`
178    /// must box itself, and a `Send`-boxed future would demand `S: Sync`,
179    /// which the synchronous `Immediate` source cannot supply — the same
180    /// shape as the content executors' frame stacks.
181    async fn expression_visible<S: AsyncObjectSource>(
182        &self,
183        src: &S,
184        expr: Vec<Object>,
185    ) -> Option<bool> {
186        let mut stack = vec![VeFrame::new(src, expr).await?];
187        loop {
188            let top = stack.len() - 1;
189            let Some(operand) = stack[top].operands.get(stack[top].next).cloned() else {
190                let done = stack.pop()?;
191                let value = match done.operator.as_str() {
192                    "And" => done.all,
193                    "Or" => done.any,
194                    _ => !done.any,
195                };
196                let Some(parent) = stack.last_mut() else {
197                    return Some(value);
198                };
199                parent.fold(value);
200                continue;
201            };
202            stack[top].next += 1;
203            let value = match operand {
204                Object::Array(items) => {
205                    if stack.len() > MAX_VE_DEPTH as usize {
206                        return None;
207                    }
208                    stack.push(VeFrame::new(src, items).await?);
209                    continue;
210                }
211                Object::Ref(group) => match src.resolve(&operand).await.ok()? {
212                    Object::Array(items) => {
213                        if stack.len() > MAX_VE_DEPTH as usize {
214                            return None;
215                        }
216                        stack.push(VeFrame::new(src, items).await?);
217                        continue;
218                    }
219                    Object::Dict(_) => !self.hidden(group),
220                    _ => return None,
221                },
222                _ => return None,
223            };
224            stack[top].fold(value);
225        }
226    }
227}
228
229/// One suspended `/VE` subexpression: its operator, its operands, how far
230/// evaluation has got, and the conjunction/disjunction accumulated so far.
231struct VeFrame {
232    operator: String,
233    operands: Vec<Object>,
234    next: usize,
235    all: bool,
236    any: bool,
237}
238
239impl VeFrame {
240    /// Validates and frames one expression array; `None` is malformed.
241    async fn new<S: AsyncObjectSource>(src: &S, mut expr: Vec<Object>) -> Option<VeFrame> {
242        let operator = match expr.first()? {
243            Object::Name(n) => n.0.clone(),
244            other => src.resolve(other).await.ok()?.as_name()?.0.clone(),
245        };
246        if !matches!(operator.as_str(), "And" | "Or" | "Not") {
247            return None;
248        }
249        let operands = expr.split_off(1);
250        if operands.is_empty() || (operator == "Not" && operands.len() != 1) {
251            return None;
252        }
253        Some(VeFrame {
254            operator,
255            operands,
256            next: 0,
257            all: true,
258            any: false,
259        })
260    }
261
262    /// Accumulates one operand's value.
263    fn fold(&mut self, value: bool) {
264        self.all &= value;
265        self.any |= value;
266    }
267}
268
269/// Whether a dictionary reached through an `/OC`-shaped value is a
270/// membership dictionary rather than a group: `/Type /OCMD` says so, and a
271/// dictionary with no `/Type` carrying `/OCGs` or `/VE` is read as one too —
272/// a group never carries those keys, and files omit `/Type`.
273fn is_ocmd(dict: &Dict) -> bool {
274    match dict.get_name("Type") {
275        Some(n) => n.0 == "OCMD",
276        None => dict.get("OCGs").is_some() || dict.get("VE").is_some(),
277    }
278}
279
280/// The group references in a (possibly indirect) array. Null entries and
281/// non-reference values are ignored (§8.11.2.2); a value that is not an
282/// array yields nothing.
283async fn group_refs<S: AsyncObjectSource>(src: &S, value: Option<&Object>) -> Vec<ObjRef> {
284    let Some(value) = value else {
285        return Vec::new();
286    };
287    let Ok(Object::Array(items)) = src.resolve(value).await else {
288        return Vec::new();
289    };
290    items.iter().filter_map(Object::as_ref).collect()
291}
292
293/// The raw `/Properties` resource value for `name`, innermost dictionary
294/// first — deliberately unresolved, so a reference keeps the identity the
295/// off set is keyed by.
296async fn properties_value<S: AsyncObjectSource>(
297    src: &S,
298    chain: &[Arc<Dict>],
299    name: &str,
300) -> Option<Object> {
301    for res in chain {
302        let Some(cat) = res.get("Properties") else {
303            continue;
304        };
305        let Ok(Object::Dict(dict)) = src.resolve(cat).await else {
306            continue;
307        };
308        let Some(value) = dict.get(name) else {
309            continue;
310        };
311        return Some(value.clone());
312    }
313    None
314}
315
316#[cfg(test)]
317mod tests {
318    use super::*;
319    use crate::{block_on, Document, Immediate, Name};
320    use pdfboss_testkit::PdfBuilder;
321
322    fn gref(num: u32) -> ObjRef {
323        ObjRef { num, gen: 0 }
324    }
325
326    /// A document whose catalog carries `oc_props` as `/OCProperties`
327    /// (skipped entirely when empty) and whose objects 10 and 11 are two
328    /// groups; `extra` adds more objects (membership dictionaries etc.).
329    fn doc_with_oc(oc_props: &str, extra: impl FnOnce(&mut PdfBuilder)) -> Document {
330        let mut b = PdfBuilder::new();
331        let oc = if oc_props.is_empty() {
332            String::new()
333        } else {
334            format!(" /OCProperties {oc_props}")
335        };
336        b.object(1, &format!("<< /Type /Catalog /Pages 2 0 R{oc} >>"));
337        b.object(2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>");
338        b.object(3, "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] >>");
339        b.object(10, "<< /Type /OCG /Name (one) >>");
340        b.object(11, "<< /Type /OCG /Name (two) >>");
341        extra(&mut b);
342        Document::load(b.build(1)).expect("load")
343    }
344
345    fn visible(doc: &Document, state: &OcState, value: &Object) -> bool {
346        block_on(state.visible_with(&Immediate(doc), value))
347    }
348
349    #[test]
350    fn no_ocproperties_means_no_state() {
351        let doc = doc_with_oc("", |_| {});
352        assert_eq!(doc.oc_state(), None);
353    }
354
355    #[test]
356    fn base_state_defaults_on_and_off_hides() {
357        let doc = doc_with_oc("<< /OCGs [10 0 R 11 0 R] /D << /OFF [11 0 R] >> >>", |_| {});
358        let state = doc.oc_state().expect("state");
359        assert!(!state.hidden(gref(10)));
360        assert!(state.hidden(gref(11)));
361    }
362
363    #[test]
364    fn base_state_off_hides_all_but_on() {
365        let doc = doc_with_oc(
366            "<< /OCGs [10 0 R 11 0 R] /D << /BaseState /OFF /ON [10 0 R] >> >>",
367            |_| {},
368        );
369        let state = doc.oc_state().expect("state");
370        assert!(!state.hidden(gref(10)));
371        assert!(state.hidden(gref(11)));
372    }
373
374    /// §8.11.4.3 applies `/BaseState`, then `/ON`, then `/OFF`: a group
375    /// named in both lists ends up off.
376    #[test]
377    fn off_is_applied_after_on() {
378        let doc = doc_with_oc(
379            "<< /OCGs [10 0 R] /D << /ON [10 0 R] /OFF [10 0 R] >> >>",
380            |_| {},
381        );
382        let state = doc.oc_state().expect("state");
383        assert!(state.hidden(gref(10)));
384    }
385
386    /// Indirection at every level: the configuration, its arrays, and the
387    /// base state name all resolve through references.
388    #[test]
389    fn configuration_resolves_indirection() {
390        let doc = doc_with_oc("<< /OCGs 20 0 R /D 21 0 R >>", |b| {
391            b.object(20, "[10 0 R 11 0 R]");
392            b.object(21, "<< /BaseState 22 0 R /ON 23 0 R >>");
393            b.object(22, "/OFF");
394            b.object(23, "[10 0 R]");
395        });
396        let state = doc.oc_state().expect("state");
397        assert!(!state.hidden(gref(10)));
398        assert!(state.hidden(gref(11)));
399    }
400
401    /// Group 10 is on, group 11 off, for every membership test below.
402    fn split_state() -> (Document, OcState) {
403        let doc = doc_with_oc("<< /OCGs [10 0 R 11 0 R] /D << /OFF [11 0 R] >> >>", |b| {
404            b.object(30, "<< /Type /OCMD /OCGs [10 0 R 11 0 R] >>");
405            b.object(31, "<< /Type /OCMD /OCGs [10 0 R 11 0 R] /P /AllOn >>");
406            b.object(32, "<< /Type /OCMD /OCGs [10 0 R 11 0 R] /P /AnyOff >>");
407            b.object(33, "<< /Type /OCMD /OCGs [10 0 R 11 0 R] /P /AllOff >>");
408            b.object(34, "<< /Type /OCMD /OCGs [11 0 R] >>");
409            b.object(35, "<< /Type /OCMD /OCGs [] /P /AllOff >>");
410            b.object(36, "<< /Type /OCMD /OCGs [null null] /P /AllOff >>");
411            b.object(37, "<< /Type /OCMD /OCGs 11 0 R /P /AnyOn >>");
412            b.object(38, "<< /Type /OCMD /OCGs [11 0 R] /P /Bogus >>");
413        });
414        let state = doc.oc_state().expect("state");
415        (doc, state)
416    }
417
418    #[test]
419    fn group_reference_visibility_follows_the_off_set() {
420        let (doc, state) = split_state();
421        assert!(visible(&doc, &state, &Object::Ref(gref(10))));
422        assert!(!visible(&doc, &state, &Object::Ref(gref(11))));
423    }
424
425    #[test]
426    fn membership_policies_follow_the_specification() {
427        let (doc, state) = split_state();
428        let ocmd = |num| Object::Ref(gref(num));
429        assert!(visible(&doc, &state, &ocmd(30)), "AnyOn default: 10 is on");
430        assert!(!visible(&doc, &state, &ocmd(31)), "AllOn: 11 is off");
431        assert!(visible(&doc, &state, &ocmd(32)), "AnyOff: 11 is off");
432        assert!(!visible(&doc, &state, &ocmd(33)), "AllOff: 10 is on");
433        assert!(
434            !visible(&doc, &state, &ocmd(34)),
435            "AnyOn over one off group"
436        );
437        assert!(visible(&doc, &state, &ocmd(35)), "empty /OCGs is visible");
438        assert!(visible(&doc, &state, &ocmd(36)), "nulls are ignored");
439        assert!(
440            !visible(&doc, &state, &ocmd(37)),
441            "single group by reference"
442        );
443        assert!(
444            !visible(&doc, &state, &ocmd(38)),
445            "unknown policy reads AnyOn"
446        );
447    }
448
449    #[test]
450    fn malformed_values_stay_visible() {
451        let (doc, state) = split_state();
452        assert!(visible(&doc, &state, &Object::Null));
453        assert!(visible(&doc, &state, &Object::Int(3)));
454        assert!(visible(&doc, &state, &Object::Ref(gref(999))), "dangling");
455        let direct_group = Object::Dict({
456            let mut d = Dict::new();
457            d.insert(Name("Type".into()), Object::Name(Name("OCG".into())));
458            d
459        });
460        assert!(
461            visible(&doc, &state, &direct_group),
462            "a direct group dictionary has no identity to be off"
463        );
464    }
465
466    fn ve_doc(ve: &str) -> (Document, OcState) {
467        let doc = doc_with_oc("<< /OCGs [10 0 R 11 0 R] /D << /OFF [11 0 R] >> >>", |b| {
468            b.object(40, &format!("<< /Type /OCMD /OCGs [10 0 R] /VE {ve} >>"));
469        });
470        let state = doc.oc_state().expect("state");
471        (doc, state)
472    }
473
474    fn ve_visible(ve: &str) -> bool {
475        let (doc, state) = ve_doc(ve);
476        visible(&doc, &state, &Object::Ref(gref(40)))
477    }
478
479    #[test]
480    fn visibility_expressions_evaluate() {
481        assert!(ve_visible("[/Not 11 0 R]"), "Not of an off group");
482        assert!(!ve_visible("[/Not 10 0 R]"), "Not of an on group");
483        assert!(!ve_visible("[/And 10 0 R 11 0 R]"));
484        assert!(ve_visible("[/Or 10 0 R 11 0 R]"));
485        assert!(!ve_visible("[/Or 11 0 R 11 0 R]"));
486        assert!(
487            ve_visible("[/Or 11 0 R [/Not 11 0 R]]"),
488            "nested expression"
489        );
490        assert!(
491            !ve_visible("[/And 10 0 R [/Not [/Not 11 0 R]]]"),
492            "double negation"
493        );
494    }
495
496    /// `/VE` takes precedence over `/OCGs` and `/P`: object 40 carries
497    /// `/OCGs [10 0 R]` (on, so AnyOn would show it), yet an expression
498    /// naming only the off group hides it.
499    #[test]
500    fn expression_takes_precedence_over_policy() {
501        assert!(!ve_visible("[/And 11 0 R]"));
502    }
503
504    #[test]
505    fn malformed_expressions_are_visible() {
506        assert!(ve_visible("[]"), "no operator");
507        assert!(ve_visible("[/And]"), "no operands");
508        assert!(ve_visible("[/Not 11 0 R 11 0 R]"), "Not takes one operand");
509        assert!(ve_visible("[/Xor 11 0 R]"), "unknown operator");
510        assert!(ve_visible("[/And 11 0 R (text)]"), "non-group operand");
511    }
512
513    #[test]
514    fn expression_depth_is_capped() {
515        let mut ve = "11 0 R".to_string();
516        for _ in 0..(MAX_VE_DEPTH + 2) {
517            ve = format!("[/Not {ve}]");
518        }
519        assert!(ve_visible(&ve), "past the cap reads as visible");
520    }
521
522    /// The properties operand of `BDC /OC` may be a resource name; the
523    /// lookup keeps the reference, so the named group's off state applies.
524    #[test]
525    fn named_properties_keep_group_identity() {
526        let (doc, state) = split_state();
527        let mut properties = Dict::new();
528        properties.insert(Name("On".into()), Object::Ref(gref(10)));
529        properties.insert(Name("Off".into()), Object::Ref(gref(11)));
530        let mut res = Dict::new();
531        res.insert(Name("Properties".into()), Object::Dict(properties));
532        let chain = vec![Arc::new(res)];
533        let src = Immediate(&doc);
534        let named = |name: &str| Object::Name(Name(name.into()));
535        assert!(block_on(state.props_visible_with(
536            &src,
537            &chain,
538            &named("On")
539        )));
540        assert!(!block_on(state.props_visible_with(
541            &src,
542            &chain,
543            &named("Off")
544        )));
545        assert!(
546            block_on(state.props_visible_with(&src, &chain, &named("Nope"))),
547            "an unresolvable name stays visible"
548        );
549    }
550}