Skip to main content

pdfrum_page/
optional.rs

1//! Optional content: which page objects are visible
2//! (ISO 32000-1 §8.11).
3//!
4//! The interpreter never filters — a hidden object is still built — so this
5//! is a predicate that `pdfrum-render` and `pdfrum-text` share rather than
6//! part of the fold.
7//!
8//! Several defaults here are counter-intuitive and every one is load-bearing:
9//!
10//! | Question | Answer |
11//! |---|---|
12//! | A null dictionary passed to the *content* check | **visible** |
13//! | A null dictionary passed to the *group* check | **invisible** |
14//! | `/P` absent on a membership dictionary | `AnyOn` |
15//! | `/P` present but unrecognised, with any valid group | **invisible** |
16//! | `/BaseState` absent | `ON` |
17//! | Any state string other than exactly `OFF` | on |
18//! | A `/VE` visibility expression | absolute precedence over `/P` |
19//! | A membership naming one group as a *dictionary* | `/P` ignored entirely |
20
21use crate::names;
22use pdfrum_common::{DiagKind, Diagnostics, Severity};
23use pdfrum_object::{Array, Dict, Name, Object, Resolve};
24use std::collections::HashMap;
25
26/// How deep a `/VE` visibility expression may nest.
27///
28/// Compared with `>` from an initial depth of zero, so **thirty-three**
29/// levels are accepted.
30pub const MAX_VE_DEPTH: u32 = 32;
31
32/// Which use an optional-content configuration is being read for.
33#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
34pub enum UsageType {
35    /// On-screen viewing.
36    #[default]
37    View,
38    /// Design-time display.
39    Design,
40    /// Printing.
41    Print,
42    /// Export to another format.
43    Export,
44}
45
46impl UsageType {
47    /// The name this usage is spelled with.
48    #[must_use]
49    pub fn as_bytes(self) -> &'static [u8] {
50        match self {
51            Self::View => b"View",
52            Self::Design => b"Design",
53            Self::Print => b"Print",
54            Self::Export => b"Export",
55        }
56    }
57
58    /// The `<usage>State` key this usage looks for.
59    #[must_use]
60    pub fn state_key(self) -> Name {
61        let mut key = self.as_bytes().to_vec();
62        key.extend_from_slice(b"State");
63        Name::new(key)
64    }
65}
66
67/// The visibility context: the catalog's optional-content configuration plus
68/// the memoized answers.
69///
70/// Group answers are memoized; **membership answers are not**, matching
71/// PDFium, because a membership can depend on several groups and its
72/// evaluation is cheap.
73#[derive(Debug)]
74pub struct OcContext {
75    usage: UsageType,
76    /// `/OCProperties` from the catalog.
77    properties: Option<Dict>,
78    /// Memoized per-group answers, keyed on the group dictionary's identity
79    /// as a reference.
80    cache: HashMap<pdfrum_object::ObjRef, bool>,
81}
82
83impl OcContext {
84    /// A context reading `usage` from the catalog's `/OCProperties`.
85    #[must_use]
86    pub fn new(properties: Option<Dict>, usage: UsageType) -> Self {
87        Self {
88            usage,
89            properties,
90            cache: HashMap::new(),
91        }
92    }
93
94    /// A context that makes everything visible, for a document with no
95    /// optional content.
96    #[must_use]
97    pub fn permissive() -> Self {
98        Self::new(None, UsageType::View)
99    }
100
101    /// Whether a dictionary reached from content is visible.
102    ///
103    /// **A null dictionary is visible** here — the opposite of
104    /// [`Self::group_visible`], and the asymmetry is deliberate: content with
105    /// no optional-content dictionary is unconditional content.
106    pub fn content_visible<R: Resolve>(
107        &mut self,
108        dict: Option<&Dict>,
109        r: &R,
110        diags: &mut Diagnostics,
111    ) -> bool {
112        let Some(dict) = dict else {
113            return true;
114        };
115        // `/Type` defaults to `OCG`; anything else is treated as a
116        // membership dictionary.
117        if dict
118            .name(names::TYPE)
119            .is_none_or(|t| t.as_bytes() == b"OCG")
120        {
121            return self.group_visible(Some(dict), r);
122        }
123        self.membership_visible(dict, r, diags)
124    }
125
126    /// Whether an optional-content *group* is visible.
127    ///
128    /// **A null dictionary is invisible** here. The answer is memoized per
129    /// group; membership answers deliberately are not.
130    pub fn group_visible<R: Resolve>(&mut self, dict: Option<&Dict>, r: &R) -> bool {
131        let Some(dict) = dict else {
132            return false;
133        };
134        // Only a group named by reference has an identity to memoize on.
135        let key = dict.reference(&Name::from("__self"));
136        if let Some(id) = key
137            && let Some(hit) = self.cache.get(&id)
138        {
139            return *hit;
140        }
141        let answer = self.load_group_state(dict, r);
142        if let Some(id) = key {
143            self.cache.insert(id, answer);
144        }
145        answer
146    }
147
148    /// How many group answers are memoized.
149    #[must_use]
150    pub fn memoized(&self) -> usize {
151        self.cache.len()
152    }
153
154    /// Whether a membership dictionary's condition holds.
155    fn membership_visible<R: Resolve>(
156        &mut self,
157        ocmd: &Dict,
158        r: &R,
159        diags: &mut Diagnostics,
160    ) -> bool {
161        // A visibility expression takes **absolute precedence** over `/P`.
162        if let Some(ve) = ocmd.array(names::VE, r) {
163            return self.eval_expression(&ve, r, 0);
164        }
165        let policy = ocmd
166            .byte_string(names::P, r)
167            .unwrap_or_else(|| b"AnyOn".to_vec());
168        let Some(ocgs) = ocmd.get(names::OCGS, r) else {
169            return true;
170        };
171        match &*ocgs {
172            // A single group as a dictionary: `/P` is ignored entirely.
173            Object::Dict(d) => self.group_visible(Some(d), r),
174            Object::Array(array) => {
175                // The seed is the vacuous truth for the "all" policies.
176                let state = policy == b"AllOn" || policy == b"AllOff";
177                let mut seen_valid = false;
178                for element in array.iter() {
179                    let Some(d) = element.resolve(r).ok().and_then(|o| o.as_dict().cloned()) else {
180                        // Non-dictionary entries are skipped without
181                        // counting.
182                        continue;
183                    };
184                    seen_valid = true;
185                    let visible = self.group_visible(Some(&d), r);
186                    if (policy == b"AnyOn" && visible) || (policy == b"AnyOff" && !visible) {
187                        return true;
188                    }
189                    if (policy == b"AllOn" && !visible) || (policy == b"AllOff" && visible) {
190                        return false;
191                    }
192                }
193                if !seen_valid {
194                    return true;
195                }
196                // An unrecognised policy matches none of the four conditions,
197                // so the loop never short-circuits and the seed — `false` —
198                // is the answer. A membership with an unknown `/P` and at
199                // least one valid group is therefore **invisible**.
200                if !matches!(
201                    policy.as_slice(),
202                    b"AnyOn" | b"AllOn" | b"AnyOff" | b"AllOff"
203                ) {
204                    diags.record(
205                        Severity::Suspicious,
206                        DiagKind::OptionalContentPolicyUnknown,
207                        None,
208                    );
209                }
210                state
211            }
212            _ => true,
213        }
214    }
215
216    /// Evaluate a `/VE` visibility expression.
217    ///
218    /// The operators are case-sensitive, and anything that is not one of the
219    /// three makes the expression false — which is to say invisible.
220    fn eval_expression<R: Resolve>(&mut self, expr: &Array, r: &R, depth: u32) -> bool {
221        if depth > MAX_VE_DEPTH {
222            return false;
223        }
224        let operator = expr.byte_string_at(0).unwrap_or_default();
225        match operator.as_slice() {
226            b"Not" => match expr.get(1, r).as_deref() {
227                Some(Object::Dict(d)) => !self.group_visible(Some(d), r),
228                Some(Object::Array(a)) => !self.eval_expression(a, r, depth + 1),
229                _ => false,
230            },
231            b"Or" | b"And" => {
232                let and = operator == b"And";
233                let mut value = false;
234                for i in 1..expr.len() {
235                    let operand = expr.get(i, r);
236                    // A null element is skipped **without advancing the seed
237                    // logic**, so a missing first operand leaves an `And`
238                    // combining against the initial `false` — which makes it
239                    // false outright, while an `Or` still works.
240                    let Some(operand) = operand else {
241                        continue;
242                    };
243                    let result = match &*operand {
244                        Object::Dict(d) => self.group_visible(Some(d), r),
245                        Object::Array(a) => self.eval_expression(a, r, depth + 1),
246                        // Anything else contributes false.
247                        _ => false,
248                    };
249                    if i == 1 {
250                        value = result;
251                    } else if and {
252                        value = value && result;
253                    } else {
254                        value = value || result;
255                    }
256                }
257                value
258            }
259            _ => false,
260        }
261    }
262
263    /// A group's state, memoized.
264    fn load_group_state<R: Resolve>(&mut self, ocg: &Dict, r: &R) -> bool {
265        // `/Intent` excluding `View` means the group is not subject to
266        // view-time visibility at all, so it is always visible.
267        if !has_intent(ocg, b"View", b"View", r) {
268            return true;
269        }
270        if let Some(usage) = ocg.dict(names::USAGE, r) {
271            let state_key = self.usage.state_key();
272            if let Some(entry) = usage.dict(&Name::new(self.usage.as_bytes()), r)
273                && entry.contains_key(&state_key)
274            {
275                return entry.byte_string(&state_key, r).as_deref() != Some(b"OFF");
276            }
277            // A non-view usage falls back to the view entry.
278            if self.usage != UsageType::View
279                && let Some(entry) = usage.dict(&Name::from("View"), r)
280                && entry.contains_key(&Name::from("ViewState"))
281            {
282                return entry.byte_string(&Name::from("ViewState"), r).as_deref() != Some(b"OFF");
283            }
284        }
285        self.state_from_config(ocg, r)
286    }
287
288    /// A group's state from the selected configuration.
289    fn state_from_config<R: Resolve>(&mut self, ocg: &Dict, r: &R) -> bool {
290        let Some(config) = self.select_config(ocg, r) else {
291            // No configuration names this group, so it is visible.
292            return true;
293        };
294        // `/BaseState` defaults to `ON`, and only the exact string `OFF`
295        // turns it off.
296        let mut on = config.byte_string(names::BASE_STATE, r).as_deref() != Some(b"OFF");
297        if let Some(array) = config.array(names::ON, r)
298            && contains_dict(&array, ocg, r)
299        {
300            on = true;
301        }
302        // `/OFF` wins over `/ON`.
303        if let Some(array) = config.array(names::OFF, r)
304            && contains_dict(&array, ocg, r)
305        {
306            on = false;
307        }
308        // Each matching `/AS` entry in array order, last one winning.
309        if let Some(entries) = config.array(names::AS, r) {
310            for element in entries.iter() {
311                let Some(entry) = element.resolve(r).ok().and_then(|o| o.as_dict().cloned()) else {
312                    continue;
313                };
314                // `/Event` defaults to `View`.
315                let event = entry
316                    .byte_string(names::EVENT, r)
317                    .unwrap_or_else(|| b"View".to_vec());
318                if event != self.usage.as_bytes() {
319                    continue;
320                }
321                let Some(groups) = entry.array(names::OCGS, r) else {
322                    continue;
323                };
324                if !contains_dict(&groups, ocg, r) {
325                    continue;
326                }
327                let state_key = self.usage.state_key();
328                if let Some(sub) = entry.dict(&Name::new(self.usage.as_bytes()), r) {
329                    on = sub.byte_string(&state_key, r).as_deref() != Some(b"OFF");
330                }
331            }
332        }
333        on
334    }
335
336    /// The configuration governing `ocg`: the first `/Configs` entry whose
337    /// `/Intent` names `View` or `All`, else `/D`.
338    fn select_config<R: Resolve>(&self, ocg: &Dict, r: &R) -> Option<Dict> {
339        let properties = self.properties.as_ref()?;
340        // The catalog must list this group at all, or nothing governs it.
341        let all = properties.array(names::OCGS, r)?;
342        if !contains_dict(&all, ocg, r) {
343            return None;
344        }
345        if let Some(configs) = properties.array(names::CONFIGS, r) {
346            for element in configs.iter() {
347                let Some(config) = element.resolve(r).ok().and_then(|o| o.as_dict().cloned())
348                else {
349                    continue;
350                };
351                // A configuration with no `/Intent` is **never** selected,
352                // because the default here is the empty string.
353                if has_intent(&config, b"View", b"", r) {
354                    return Some(config);
355                }
356            }
357        }
358        properties.dict(names::D_CONFIG, r)
359    }
360}
361
362/// Which of a page's objects optional content hides, shaped like the page.
363///
364/// # Why a tree and not a set of ids
365///
366/// A page object has no id. The graph is `Vec<PageObject>` with a form's
367/// children nested inside it, so the only thing that names an object is its
368/// position — and that is exactly what this mirrors: `hidden[i]` answers for
369/// `objects[i]`, and a form's `children` answer for that form's own list.
370/// Walking the two together costs one index per object and needs no identity
371/// the page graph does not have.
372///
373/// An empty tree means nothing is hidden, which is what a document with no
374/// `/OCProperties` produces and what [`Visibility::shows_everything`] reports,
375/// so a renderer can skip the descent entirely.
376#[derive(Debug, Clone, PartialEq, Eq, Default)]
377pub struct Visibility {
378    /// One entry per object in the list this tree describes, in order.
379    nodes: Vec<Node>,
380}
381
382/// One object's answer, plus its children's when it is a form.
383#[derive(Debug, Clone, PartialEq, Eq, Default)]
384struct Node {
385    /// Whether this object is drawn at all.
386    visible: bool,
387    /// A form's own objects. Empty for every other kind, and also for a form
388    /// that is itself hidden — there is nothing to say about children nobody
389    /// will reach.
390    children: Visibility,
391}
392
393impl Visibility {
394    /// Nothing hidden, for a document with no optional content.
395    #[must_use]
396    pub fn all_visible() -> Self {
397        Self::default()
398    }
399
400    /// Whether this tree hides nothing anywhere beneath it.
401    ///
402    /// A renderer can take this as licence to stop descending: an empty tree
403    /// is the answer for a page with no optional content at all, and
404    /// [`Self::visible`] already reports an absent entry as visible.
405    #[must_use]
406    pub fn shows_everything(&self) -> bool {
407        self.nodes.is_empty()
408    }
409
410    /// Whether the object at `index` is drawn.
411    ///
412    /// **An index this tree does not cover is visible.** That is not
413    /// permissiveness for its own sake: it is what makes `all_visible()` an
414    /// empty tree rather than a vector of `true`, and it means a caller
415    /// whose object list has grown since the pre-pass ran draws the new
416    /// objects rather than silently dropping them.
417    #[must_use]
418    pub fn visible(&self, index: usize) -> bool {
419        self.nodes.get(index).is_none_or(|n| n.visible)
420    }
421
422    /// The tree describing the form at `index`'s own objects.
423    #[must_use]
424    pub fn children(&self, index: usize) -> Self {
425        self.nodes
426            .get(index)
427            .map(|n| n.children.clone())
428            .unwrap_or_default()
429    }
430
431    /// Drop a tree that turned out to hide nothing.
432    ///
433    /// Keeping it would be correct and would cost every renderer the descent
434    /// [`Self::shows_everything`] exists to avoid, so the pre-pass collapses
435    /// as it unwinds and a page with no optional content ends up empty at
436    /// every level rather than only at the root.
437    fn collapsed(self) -> Self {
438        if self
439            .nodes
440            .iter()
441            .all(|n| n.visible && n.children.shows_everything())
442        {
443            Self::default()
444        } else {
445            self
446        }
447    }
448}
449
450/// Resolve which of a page's objects optional content hides.
451///
452/// A pre-pass: it runs between building the page and rendering it and produces
453/// plain data, so no resolver reaches the render walk. It answers all three
454/// forms of `/OC` — the marked-content one, a form `XObject`'s own dictionary
455/// and an image's — and the `XObject` ones are separate from the mark: a form
456/// can be hidden by its own dictionary while the `Do` that drew it sits under
457/// no `/OC` mark at all.
458// The split is the point: `content_visible` needs `&mut OcContext` and a
459// `Resolve`, and a rasterizer needs neither.
460#[must_use]
461pub fn page_visibility<R: Resolve>(
462    page: &crate::Page,
463    oc: &mut OcContext,
464    r: &R,
465    diags: &mut Diagnostics,
466) -> Visibility {
467    object_visibility(&page.objects, oc, r, diags)
468}
469
470/// [`page_visibility`] for one object list, which is what recursion needs.
471fn object_visibility<R: Resolve>(
472    objects: &[crate::PageObject],
473    oc: &mut OcContext,
474    r: &R,
475    diags: &mut Diagnostics,
476) -> Visibility {
477    let nodes = objects
478        .iter()
479        .map(|object| {
480            let visible = object_visible(object, oc, r, diags);
481            let children = match object {
482                crate::PageObject::Form(f) if visible => {
483                    object_visibility(&f.object.objects, oc, r, diags)
484                }
485                _ => Visibility::default(),
486            };
487            Node { visible, children }
488        })
489        .collect();
490    Visibility { nodes }.collapsed()
491}
492
493/// Whether one object is drawn, by its marks and by its own `/OC`.
494fn object_visible<R: Resolve>(
495    object: &crate::PageObject,
496    oc: &mut OcContext,
497    r: &R,
498    diags: &mut Diagnostics,
499) -> bool {
500    // `CheckPageObjectVisible` scans **every** `/OC` mark on the object, not
501    // just the innermost, so nested sequences each get a veto.
502    if !object
503        .marks()
504        .optional_content_all()
505        .into_iter()
506        .all(|d| oc.content_visible(Some(d), r, diags))
507    {
508        return false;
509    }
510    // An XObject's own `/OC` is a second, independent veto through the same
511    // predicate — `CheckOCGDictVisible` is what both call sites reach, and
512    // an absent one is visible.
513    let own = match object {
514        crate::PageObject::Form(f) => f.object.oc.as_deref(),
515        crate::PageObject::Image(i) => i.object.oc.as_deref(),
516        crate::PageObject::Path(_) | crate::PageObject::Text(_) | crate::PageObject::Shading(_) => {
517            None
518        }
519    };
520    oc.content_visible(own, r, diags)
521}
522
523/// Whether a dictionary's `/Intent` names `element`.
524///
525/// An absent `/Intent` yields `element == default`, which is how the same
526/// helper answers "true" for a group and "false" for a configuration.
527fn has_intent(dict: &Dict, element: &[u8], default: &[u8], r: &impl Resolve) -> bool {
528    let Some(intent) = dict.get(names::INTENT, r) else {
529        return element == default;
530    };
531    match &*intent {
532        Object::Array(array) => array.iter().any(|o| {
533            let s = o.to_byte_string();
534            s == b"All" || s == element
535        }),
536        other => {
537            let s = other.to_byte_string();
538            s == b"All" || s == element
539        }
540    }
541}
542
543/// Whether an array holds this exact dictionary.
544fn contains_dict(array: &Array, target: &Dict, r: &impl Resolve) -> bool {
545    array.iter().any(|o| {
546        o.resolve(r)
547            .ok()
548            .and_then(|res| res.as_dict().cloned())
549            .as_ref()
550            == Some(target)
551    })
552}
553
554#[cfg(test)]
555mod tests {
556    // Test fixtures quote the oracle's own vectors, compare floats exactly
557    // where the behaviour being pinned is exact, and index arrays whose
558    // length the fixture itself fixes.
559    #![allow(
560        clippy::unreadable_literal,
561        clippy::float_cmp,
562        clippy::indexing_slicing,
563        clippy::cast_precision_loss,
564        clippy::cast_possible_truncation,
565        reason = "test fixtures quote oracle vectors verbatim and compare exactly"
566    )]
567
568    use super::{MAX_VE_DEPTH, OcContext, UsageType};
569    use pdfrum_common::{DiagKind, Diagnostics};
570    use pdfrum_object::{Array, Dict, Name, NoResolve, Object};
571
572    fn ocg(name: &str) -> Dict {
573        Dict::from_pairs([
574            (Name::from("Type"), Object::Name(Name::from("OCG"))),
575            (Name::from("Name"), Object::Name(Name::from(name))),
576        ])
577    }
578
579    fn ocmd(pairs: Vec<(Name, Object)>) -> Dict {
580        let mut d = Dict::from_pairs([(Name::from("Type"), Object::Name(Name::from("OCMD")))]);
581        for (k, v) in pairs {
582            d.push(k, v);
583        }
584        d
585    }
586
587    #[test]
588    fn the_null_asymmetry_between_content_and_group_checks() {
589        let mut ctx = OcContext::permissive();
590        let mut diags = Diagnostics::default();
591        // Content with no dictionary is unconditional content.
592        assert!(ctx.content_visible(None, &NoResolve, &mut diags));
593        // A group with no dictionary is invisible.
594        assert!(!ctx.group_visible(None, &NoResolve));
595    }
596
597    #[test]
598    fn a_group_with_no_configuration_is_visible() {
599        let mut ctx = OcContext::permissive();
600        let mut diags = Diagnostics::default();
601        assert!(ctx.content_visible(Some(&ocg("Layer")), &NoResolve, &mut diags));
602    }
603
604    #[test]
605    fn the_four_membership_policies() {
606        let on = ocg("On");
607        let mut diags = Diagnostics::default();
608        for (policy, want) in [
609            ("AnyOn", true),
610            ("AllOn", true),
611            ("AnyOff", false),
612            ("AllOff", false),
613        ] {
614            let mut ctx = OcContext::permissive();
615            let d = ocmd(vec![
616                (Name::from("P"), Object::Name(Name::from(policy))),
617                (
618                    Name::from("OCGs"),
619                    Object::Array(Array::of([Object::Dict(on.clone())])),
620                ),
621            ]);
622            assert_eq!(
623                ctx.content_visible(Some(&d), &NoResolve, &mut diags),
624                want,
625                "policy {policy}"
626            );
627        }
628    }
629
630    #[test]
631    fn an_unknown_policy_with_a_valid_group_is_invisible() {
632        let mut ctx = OcContext::permissive();
633        let mut diags = Diagnostics::default();
634        let d = ocmd(vec![
635            (Name::from("P"), Object::Name(Name::from("SomeOn"))),
636            (
637                Name::from("OCGs"),
638                Object::Array(Array::of([Object::Dict(ocg("On"))])),
639            ),
640        ]);
641        assert!(!ctx.content_visible(Some(&d), &NoResolve, &mut diags));
642        assert!(diags.contains(&DiagKind::OptionalContentPolicyUnknown));
643    }
644
645    #[test]
646    fn a_membership_with_no_valid_groups_is_visible() {
647        let mut ctx = OcContext::permissive();
648        let mut diags = Diagnostics::default();
649        let d = ocmd(vec![
650            (Name::from("P"), Object::Name(Name::from("AllOn"))),
651            (
652                Name::from("OCGs"),
653                Object::Array(Array::of([Object::Int(7), Object::Null])),
654            ),
655        ]);
656        assert!(ctx.content_visible(Some(&d), &NoResolve, &mut diags));
657    }
658
659    #[test]
660    fn a_single_group_dictionary_ignores_the_policy() {
661        let mut ctx = OcContext::permissive();
662        let mut diags = Diagnostics::default();
663        let d = ocmd(vec![
664            // A policy that would say "invisible" for an array.
665            (Name::from("P"), Object::Name(Name::from("AllOff"))),
666            (Name::from("OCGs"), Object::Dict(ocg("On"))),
667        ]);
668        assert!(ctx.content_visible(Some(&d), &NoResolve, &mut diags));
669    }
670
671    #[test]
672    fn a_visibility_expression_takes_precedence_over_the_policy() {
673        let mut ctx = OcContext::permissive();
674        let mut diags = Diagnostics::default();
675        let d = ocmd(vec![
676            (Name::from("P"), Object::Name(Name::from("AnyOn"))),
677            (
678                Name::from("VE"),
679                Object::Array(Array::of([
680                    Object::Name(Name::from("Not")),
681                    Object::Dict(ocg("On")),
682                ])),
683            ),
684            (
685                Name::from("OCGs"),
686                Object::Array(Array::of([Object::Dict(ocg("On"))])),
687            ),
688        ]);
689        // The group is visible, so `Not` makes the expression false — and the
690        // `AnyOn` policy that would have said "visible" never runs.
691        assert!(!ctx.content_visible(Some(&d), &NoResolve, &mut diags));
692    }
693
694    #[test]
695    fn an_unknown_expression_operator_is_invisible() {
696        let mut ctx = OcContext::permissive();
697        let mut diags = Diagnostics::default();
698        let d = ocmd(vec![(
699            Name::from("VE"),
700            Object::Array(Array::of([
701                Object::Name(Name::from("Nand")),
702                Object::Dict(ocg("On")),
703            ])),
704        )]);
705        assert!(!ctx.content_visible(Some(&d), &NoResolve, &mut diags));
706    }
707
708    #[test]
709    fn an_and_whose_first_operand_is_missing_is_false() {
710        let mut ctx = OcContext::permissive();
711        let mut diags = Diagnostics::default();
712        // `And` with element 1 null: the seed never gets set, so the second
713        // operand combines against `false`.
714        let d = ocmd(vec![(
715            Name::from("VE"),
716            Object::Array(Array::of([
717                Object::Name(Name::from("And")),
718                Object::Null,
719                Object::Dict(ocg("On")),
720            ])),
721        )]);
722        assert!(!ctx.content_visible(Some(&d), &NoResolve, &mut diags));
723
724        // An `Or` in the same shape still works.
725        let d = ocmd(vec![(
726            Name::from("VE"),
727            Object::Array(Array::of([
728                Object::Name(Name::from("Or")),
729                Object::Null,
730                Object::Dict(ocg("On")),
731            ])),
732        )]);
733        assert!(ctx.content_visible(Some(&d), &NoResolve, &mut diags));
734    }
735
736    #[test]
737    fn expressions_deeper_than_the_cap_are_invisible() {
738        let mut ctx = OcContext::permissive();
739        let mut diags = Diagnostics::default();
740        // Build `[Not [Not [Not … group]]]` past the cap.
741        let mut expr = Object::Dict(ocg("On"));
742        for _ in 0..=MAX_VE_DEPTH + 1 {
743            expr = Object::Array(Array::of([Object::Name(Name::from("Not")), expr]));
744        }
745        let d = ocmd(vec![(Name::from("VE"), expr)]);
746        // Whether it comes out true or false, the point is that it
747        // terminates rather than recursing forever.
748        let _ = ctx.content_visible(Some(&d), &NoResolve, &mut diags);
749    }
750
751    #[test]
752    fn usage_state_keys_are_built_from_the_usage_name() {
753        assert_eq!(UsageType::View.state_key().as_bytes(), b"ViewState");
754        assert_eq!(UsageType::Print.state_key().as_bytes(), b"PrintState");
755        assert_eq!(UsageType::Export.as_bytes(), b"Export");
756    }
757
758    /// A store that hands back objects by number, so a `/VE` can point at
759    /// itself and the evaluator has to survive it.
760    struct Store(std::collections::HashMap<u32, std::sync::Arc<Object>>);
761
762    impl pdfrum_object::Resolve for Store {
763        fn fetch(
764            &self,
765            r: pdfrum_object::ObjRef,
766        ) -> Result<std::sync::Arc<Object>, pdfrum_object::Error> {
767            self.0
768                .get(&r.num)
769                .map(std::sync::Arc::clone)
770                .ok_or(pdfrum_object::Error::UnresolvedRef(r))
771        }
772    }
773
774    #[test]
775    fn a_self_referencing_visibility_expression_terminates() {
776        // `1 0 obj [/Not 1 0 R] endobj` — the expression's only operand is
777        // the expression. Nothing in the *data* bounds this; only
778        // `MAX_VE_DEPTH` does, and without it the evaluator would recurse
779        // until the stack ran out on a file a fuzzer produces in seconds.
780        let selfref = Object::Array(Array::of([
781            Object::Name(Name::from("Not")),
782            Object::Ref(pdfrum_object::ObjRef {
783                num: 1,
784                generation: 0,
785            }),
786        ]));
787        let mut objects = std::collections::HashMap::new();
788        objects.insert(1u32, std::sync::Arc::new(selfref.clone()));
789        let store = Store(objects);
790
791        let mut ctx = OcContext::permissive();
792        let mut diags = Diagnostics::default();
793        let d = ocmd(vec![(Name::from("VE"), selfref)]);
794        // The answer itself is whatever the depth cap bottoms out at; that it
795        // returns at all is the property under test.
796        let _ = ctx.content_visible(Some(&d), &store, &mut diags);
797
798        // A cycle through two objects is the same shape one step longer.
799        let mut objects = std::collections::HashMap::new();
800        objects.insert(
801            1u32,
802            std::sync::Arc::new(Object::Array(Array::of([
803                Object::Name(Name::from("Not")),
804                Object::Ref(pdfrum_object::ObjRef {
805                    num: 2,
806                    generation: 0,
807                }),
808            ]))),
809        );
810        objects.insert(
811            2u32,
812            std::sync::Arc::new(Object::Array(Array::of([
813                Object::Name(Name::from("Not")),
814                Object::Ref(pdfrum_object::ObjRef {
815                    num: 1,
816                    generation: 0,
817                }),
818            ]))),
819        );
820        let store = Store(objects);
821        let mut ctx = OcContext::permissive();
822        let d = ocmd(vec![(
823            Name::from("VE"),
824            Object::Ref(pdfrum_object::ObjRef {
825                num: 1,
826                generation: 0,
827            }),
828        )]);
829        let _ = ctx.content_visible(Some(&d), &store, &mut diags);
830    }
831
832    // --- the pre-pass ---
833
834    use crate::ops::MarkProperties;
835    use crate::state::ContentMarks;
836    use crate::{Content, PageObject, PathObject};
837
838    fn off_group() -> Dict {
839        // A group the default configuration turns off.
840        Dict::from_pairs([
841            (Name::from("Type"), Object::Name(Name::from("OCG"))),
842            (Name::from("Name"), Object::Name(Name::from("Hidden"))),
843        ])
844    }
845
846    /// A context whose default configuration switches `off` off.
847    ///
848    /// The catalog's own `/OCGs` has to list the group as well as the
849    /// configuration's `/OFF`: `select_config` declines a group the catalog
850    /// never declared, and a group nothing governs is visible.
851    fn context_hiding(off: &Dict) -> OcContext {
852        let properties = Dict::from_pairs([
853            (
854                Name::from("OCGs"),
855                Object::Array(Array::of([Object::Dict(off.clone())])),
856            ),
857            (
858                Name::from("D"),
859                Object::Dict(Dict::from_pairs([(
860                    Name::from("OFF"),
861                    Object::Array(Array::of([Object::Dict(off.clone())])),
862                )])),
863            ),
864        ]);
865        OcContext::new(Some(properties), UsageType::View)
866    }
867
868    /// One `BDC /OC` mark, written either as a resource name or inline —
869    /// which is the distinction visibility turns on.
870    fn marked(oc: Option<&Dict>, from_resources: bool) -> ContentMarks {
871        let mut marks = ContentMarks::new();
872        if let Some(d) = oc {
873            push_oc(&mut marks, d, from_resources);
874        }
875        marks
876    }
877
878    fn push_oc(marks: &mut ContentMarks, dict: &Dict, from_resources: bool) {
879        let properties = if from_resources {
880            MarkProperties::Named(Name::from("MC0"))
881        } else {
882            MarkProperties::Inline(Box::new(dict.clone()))
883        };
884        marks.push_with_properties(Name::from("OC"), &properties, |_| Some(dict.clone()));
885    }
886
887    fn path_with(marks: ContentMarks) -> PageObject {
888        PageObject::Path(Box::new(Content {
889            object: PathObject {
890                path: kurbo::BezPath::new(),
891                matrix: kurbo::Affine::IDENTITY,
892                fill_rule: crate::FillRule::Winding,
893                stroke: false,
894            },
895            state: crate::GraphicsState::default(),
896            marks,
897            content_stream: Some(0),
898            dirty: false,
899            active: true,
900        }))
901    }
902
903    fn page_of(objects: Vec<PageObject>) -> crate::Page {
904        crate::Page {
905            objects,
906            ..crate::Page::empty()
907        }
908    }
909
910    #[test]
911    fn a_page_with_no_optional_content_produces_an_empty_tree() {
912        let page = page_of(vec![path_with(ContentMarks::new()); 3]);
913        let mut ctx = OcContext::permissive();
914        let mut diags = Diagnostics::default();
915        let v = super::page_visibility(&page, &mut ctx, &NoResolve, &mut diags);
916        assert!(
917            v.shows_everything(),
918            "an all-visible page collapses to nothing, so a renderer can skip \
919             the descent entirely"
920        );
921        // And an absent entry still answers visible.
922        assert!(v.visible(0));
923        assert!(v.visible(99));
924    }
925
926    #[test]
927    fn an_off_group_hides_the_object_its_mark_encloses() {
928        let off = off_group();
929        let page = page_of(vec![
930            path_with(marked(None, false)),
931            path_with(marked(Some(&off), true)),
932            path_with(marked(None, false)),
933        ]);
934        let mut ctx = context_hiding(&off);
935        let mut diags = Diagnostics::default();
936        let v = super::page_visibility(&page, &mut ctx, &NoResolve, &mut diags);
937        assert!(!v.shows_everything());
938        assert!(v.visible(0));
939        assert!(!v.visible(1), "the marked object is hidden");
940        assert!(v.visible(2));
941    }
942
943    #[test]
944    fn an_inline_property_list_never_hides_anything() {
945        // `BDC /OC << … >>` written inline is ignored entirely — visibility
946        // requires the properties to have come from the `/Properties`
947        // resource (`kPropertiesDict`).
948        let off = off_group();
949        let page = page_of(vec![path_with(marked(Some(&off), false))]);
950        let mut ctx = context_hiding(&off);
951        let mut diags = Diagnostics::default();
952        let v = super::page_visibility(&page, &mut ctx, &NoResolve, &mut diags);
953        assert!(v.shows_everything());
954    }
955
956    #[test]
957    fn every_enclosing_mark_gets_a_veto_not_just_the_innermost() {
958        // `CheckPageObjectVisible` scans the whole mark stack, so an outer
959        // sequence hides content an inner visible one is nested in.
960        let off = off_group();
961        let mut marks = marked(Some(&off), true);
962        push_oc(&mut marks, &ocg("Shown"), true);
963        let page = page_of(vec![path_with(marks)]);
964        let mut ctx = context_hiding(&off);
965        let mut diags = Diagnostics::default();
966        let v = super::page_visibility(&page, &mut ctx, &NoResolve, &mut diags);
967        assert!(!v.visible(0));
968    }
969
970    #[test]
971    fn a_hidden_form_says_nothing_about_children_nobody_reaches() {
972        let off = off_group();
973        let form = PageObject::Form(Box::new(Content {
974            object: crate::FormObject {
975                objects: vec![path_with(ContentMarks::new())],
976                matrix: kurbo::Affine::IDENTITY,
977                bbox: None,
978                transparency: crate::Transparency::default(),
979                oc: Some(std::sync::Arc::new(off.clone())),
980                source: None,
981                live_edit: false,
982            },
983            state: crate::GraphicsState::default(),
984            marks: ContentMarks::new(),
985            content_stream: Some(0),
986            dirty: false,
987            active: true,
988        }));
989        let page = page_of(vec![form]);
990        let mut ctx = context_hiding(&off);
991        let mut diags = Diagnostics::default();
992        let v = super::page_visibility(&page, &mut ctx, &NoResolve, &mut diags);
993        assert!(!v.visible(0), "the form's own `/OC` hides it");
994        assert!(
995            v.children(0).shows_everything(),
996            "and its children are not walked, because nothing reaches them"
997        );
998    }
999
1000    #[test]
1001    fn a_visible_forms_children_are_answered_in_their_own_frame() {
1002        let off = off_group();
1003        let form = PageObject::Form(Box::new(Content {
1004            object: crate::FormObject {
1005                objects: vec![
1006                    path_with(ContentMarks::new()),
1007                    path_with(marked(Some(&off), true)),
1008                ],
1009                matrix: kurbo::Affine::IDENTITY,
1010                bbox: None,
1011                transparency: crate::Transparency::default(),
1012                oc: None,
1013                source: None,
1014                live_edit: false,
1015            },
1016            state: crate::GraphicsState::default(),
1017            marks: ContentMarks::new(),
1018            content_stream: Some(0),
1019            dirty: false,
1020            active: true,
1021        }));
1022        let page = page_of(vec![form]);
1023        let mut ctx = context_hiding(&off);
1024        let mut diags = Diagnostics::default();
1025        let v = super::page_visibility(&page, &mut ctx, &NoResolve, &mut diags);
1026        assert!(v.visible(0), "the form itself is drawn");
1027        let inner = v.children(0);
1028        assert!(inner.visible(0));
1029        assert!(!inner.visible(1), "but its second child is not");
1030    }
1031}