Skip to main content

oxideav_pdf/reader/
ocg.rs

1//! Round-95 — Optional Content (OCG / OCMD) reader (ISO 32000-1
2//! §8.11 + §7.7.2 Table 28 catalog `/OCProperties`).
3//!
4//! PDFs declare visibility "layers" (called *Optional Content Groups*
5//! in ISO 32000) the user can toggle on / off at view time — CAD
6//! drawing layers, multi-language alternates, redaction overlays,
7//! watermark-vs-content separations, ….  The catalog's
8//! `/OCProperties` entry (§7.7.2 Table 28; required if any optional
9//! content exists per §8.11.4.2) carries:
10//!
11//! * `/OCGs` — array of every OCG dictionary in the document
12//!   (Table 100).
13//! * `/D` — the document's *default* configuration dictionary
14//!   (Table 101) — `BaseState` / `ON` / `OFF` / `Intent` / `Order` /
15//!   `ListMode` / `RBGroups` / `Locked`.
16//! * `/Configs` — optional array of alternate configurations.
17//!
18//! Each OCG (Table 98) carries `/Type /OCG`, `/Name`, optional
19//! `/Intent` (`View` / `Design` / array of either), optional `/Usage`
20//! dictionary (Tables 102–103 — language / zoom / print / view /
21//! export / user / page-element filters).
22//!
23//! Membership dictionaries (OCMDs — Table 99) reference a *set* of
24//! OCGs via `/OCGs` plus a `/P` visibility policy (`AllOn`, `AnyOn`,
25//! `AnyOff`, `AllOff` — default `AnyOn`) or a `/VE` visibility
26//! expression (`[ /And ocg1 ocg2 ]`, `[ /Or … ]`, `[ /Not ocg ]` —
27//! recursively nested).
28//!
29//! [`DocumentReader::optional_content`] returns a [`OptionalContent`]
30//! summary carrying every group, the resolved default configuration,
31//! and the resolved on/off state per group after applying the
32//! default config's `BaseState` + `ON` + `OFF` arrays per
33//! §8.11.4.5. Callers that just want "is OCG N visible?" call
34//! [`OptionalContent::is_visible`]; callers walking the Order tree
35//! for a UI use [`OptionalContent::groups`] + the config's
36//! [`OcConfig::order`] list.
37//!
38//! Alternate configurations (the `/Configs` array) are surfaced
39//! alongside the default for completeness (e.g. CAD packages that
40//! ship an "engineering" and a "presentation" configuration in the
41//! same PDF). Callers can re-resolve states by passing one of these
42//! to [`OptionalContent::states_for_config`].
43//!
44//! Walker is best-effort — malformed entries are skipped silently
45//! to match the round-26 annotation reader's contract; an unparseable
46//! `/OCProperties` itself surfaces as `Ok(None)` rather than an
47//! error so callers can branch cleanly on "this PDF has no optional
48//! content".
49
50use std::collections::HashMap;
51
52use crate::error::PdfError;
53use crate::objects::{Dict, Object, ObjectId};
54use crate::reader::document::DocumentReader;
55
56/// Visibility policy for an Optional Content Membership Dictionary
57/// (`/P` entry of an OCMD, ISO 32000-1 §8.11.2.2 Table 99).
58#[derive(Debug, Clone, Copy, PartialEq, Eq)]
59pub enum OcVisibilityPolicy {
60    /// Visible only if all referenced OCGs are ON.
61    AllOn,
62    /// Visible if any referenced OCG is ON. The Table 99 default.
63    AnyOn,
64    /// Visible if any referenced OCG is OFF.
65    AnyOff,
66    /// Visible only if all referenced OCGs are OFF.
67    AllOff,
68}
69
70impl OcVisibilityPolicy {
71    /// Resolve the `/P` name into a policy. Unknown names fall back to
72    /// the Table 99 default (`AnyOn`).
73    pub fn from_name(name: &str) -> Self {
74        match name {
75            "AllOn" => OcVisibilityPolicy::AllOn,
76            "AnyOff" => OcVisibilityPolicy::AnyOff,
77            "AllOff" => OcVisibilityPolicy::AllOff,
78            _ => OcVisibilityPolicy::AnyOn,
79        }
80    }
81}
82
83/// `/BaseState` value of a configuration dictionary (Table 101).
84#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
85pub enum OcBaseState {
86    /// All groups initially ON. Table 101 default.
87    #[default]
88    On,
89    /// All groups initially OFF.
90    Off,
91    /// Groups keep their prior state (only valid in non-default
92    /// configurations per Table 101's "If BaseState is present in the
93    /// document's default configuration dictionary, its value shall
94    /// be ON" footnote).
95    Unchanged,
96}
97
98impl OcBaseState {
99    fn from_name(name: &str) -> Self {
100        match name {
101            "OFF" => OcBaseState::Off,
102            "Unchanged" => OcBaseState::Unchanged,
103            // ISO 32000-1 §8.11.4.3 Table 101: default is ON.
104            _ => OcBaseState::On,
105        }
106    }
107}
108
109/// One Optional Content Group (ISO 32000-1 §8.11.2 Table 98).
110///
111/// Surfaced verbatim from the document — the `state` slot on this
112/// struct is *not* filled in by [`optional_content`]; resolved states
113/// live in [`OptionalContent::states`] keyed by group id (the
114/// `/OCProperties /OCGs` array is the source of truth for "which
115/// objects are OCGs", which lets callers cross-walk against e.g.
116/// content-stream `/OC /OCx BDC` resource names).
117#[derive(Debug, Clone)]
118pub struct OptionalContentGroup {
119    /// Indirect object id — the OCG dict's `(n 0 obj)` number. Used
120    /// by content streams + OCMDs to refer to the group.
121    pub id: ObjectId,
122    /// `/Name` — UI label. PDF text string (literal or hex with
123    /// optional UTF-16BE BOM).
124    pub name: String,
125    /// `/Intent` — `View` / `Design` / both. Empty array on input
126    /// surfaces as an empty vec (per §8.11.2.3 "If the configuration's
127    /// Intent is an empty array, no groups shall be used in determining
128    /// visibility").
129    pub intents: Vec<String>,
130    /// `/Usage` subkeys (Table 102). The most-used ones get typed
131    /// slots; everything else stays in the raw dict.
132    pub usage: Option<OcUsage>,
133}
134
135/// Selected `/Usage` subkeys decoded from Table 102 — the categories
136/// usage-application dictionaries (Table 103) consult for `View` /
137/// `Print` / `Export` state derivation.
138#[derive(Debug, Clone, Default)]
139pub struct OcUsage {
140    /// `/Language /Lang` (§8.11.4.4 — IETF BCP 47 language tag, e.g.
141    /// `en-US`, `fr`, `es-MX`).
142    pub language: Option<String>,
143    /// `/Language /Preferred` — `ON` / `OFF` for partial matches.
144    pub language_preferred: Option<bool>,
145    /// `/Zoom /min` — minimum zoom factor at which the group is ON
146    /// (default 0.0 per Table 102).
147    pub zoom_min: Option<f64>,
148    /// `/Zoom /max` — maximum zoom factor at which the group is ON
149    /// (default +inf per Table 102; we surface `None` rather than a
150    /// floating-point sentinel).
151    pub zoom_max: Option<f64>,
152    /// `/Print /Subtype` — `Trapping`, `PrintersMarks`, `Watermark`,
153    /// ….
154    pub print_subtype: Option<String>,
155    /// `/Print /PrintState` — `ON` / `OFF`.
156    pub print_state: Option<bool>,
157    /// `/View /ViewState` — `ON` / `OFF`.
158    pub view_state: Option<bool>,
159    /// `/Export /ExportState` — `ON` / `OFF`.
160    pub export_state: Option<bool>,
161    /// `/PageElement /Subtype` — `HF`, `FG`, `BG`, `L`.
162    pub page_element_subtype: Option<String>,
163}
164
165/// One Optional Content Configuration Dictionary (Table 101).
166///
167/// The catalog's `/OCProperties /D` is one of these; alternate
168/// configurations in `/OCProperties /Configs` are also surfaced
169/// using this same shape.
170#[derive(Debug, Clone, Default)]
171pub struct OcConfig {
172    /// `/Name` — display name for this configuration.
173    pub name: Option<String>,
174    /// `/Creator` — application that created this configuration.
175    pub creator: Option<String>,
176    /// `/BaseState` — initial state ON / OFF / Unchanged.
177    pub base_state: OcBaseState,
178    /// `/ON` — group ids whose state shall be ON after BaseState is
179    /// applied (overrides BaseState=OFF for these).
180    pub on: Vec<ObjectId>,
181    /// `/OFF` — group ids whose state shall be OFF after BaseState is
182    /// applied (overrides BaseState=ON for these).
183    pub off: Vec<ObjectId>,
184    /// `/Intent` — names this configuration's state filter recognises
185    /// (default `[View]`; empty `[]` ⇒ no groups participate).
186    pub intents: Vec<String>,
187    /// `/Order` — UI tree for "Layers"-style listings (Table 101).
188    /// `OcOrderItem::Group(id)` for individual groups,
189    /// `OcOrderItem::Subtree { label, items }` for nested
190    /// collections (with an optional first-element text label).
191    pub order: Vec<OcOrderItem>,
192    /// `/ListMode` — `AllPages` (default) or `VisiblePages`.
193    pub list_mode: OcListMode,
194    /// `/RBGroups` — radio-button group sets (each inner array is a
195    /// mutually-exclusive set; turning one on turns the others off).
196    pub rb_groups: Vec<Vec<ObjectId>>,
197    /// `/Locked` — group ids the UI shall not let the user toggle.
198    pub locked: Vec<ObjectId>,
199}
200
201/// `/ListMode` of a configuration (Table 101).
202#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
203pub enum OcListMode {
204    /// `AllPages` — display every group in `Order`. Table 101 default.
205    #[default]
206    AllPages,
207    /// `VisiblePages` — only display groups referenced by visible pages.
208    VisiblePages,
209}
210
211/// One node in a configuration's `/Order` array (Table 101).
212#[derive(Debug, Clone)]
213pub enum OcOrderItem {
214    /// A leaf OCG reference — the group appears as a toggleable item
215    /// at this position in the UI tree.
216    Group(ObjectId),
217    /// A nested subtree. PDFs use this two ways:
218    ///
219    /// * **Labelled collection** — first element is a text string
220    ///   acting as a non-selectable group heading; remaining elements
221    ///   are nested order items.
222    /// * **Sublayer nesting** — no leading string; the items
223    ///   represent a sub-layer of the immediately-preceding group.
224    Subtree {
225        /// Group label (text string at the start of the nested array)
226        /// or `None` for the sublayer-nesting form.
227        label: Option<String>,
228        /// Nested items.
229        items: Vec<OcOrderItem>,
230    },
231}
232
233/// Full optional-content picture for a document.
234///
235/// Returned by [`DocumentReader::optional_content`].
236#[derive(Debug, Clone)]
237pub struct OptionalContent {
238    /// Every OCG in the document, in `/OCProperties /OCGs` order.
239    pub groups: Vec<OptionalContentGroup>,
240    /// The catalog's `/OCProperties /D` configuration (always
241    /// present per §8.11.4.2 when `/OCProperties` itself is).
242    pub default_config: OcConfig,
243    /// Alternate configurations from `/OCProperties /Configs`.
244    pub alternate_configs: Vec<OcConfig>,
245    /// Resolved state per group, after applying the default
246    /// configuration's `BaseState` / `ON` / `OFF` (§8.11.4.5
247    /// algorithm). `true` ⇒ ON, `false` ⇒ OFF.
248    pub states: HashMap<ObjectId, bool>,
249}
250
251impl OptionalContent {
252    /// Is this OCG visible under the default configuration? Returns
253    /// `false` for unknown ids (a content stream referencing an OCG
254    /// that isn't in `/OCProperties /OCGs` is malformed; treat as
255    /// hidden).
256    pub fn is_visible(&self, group: ObjectId) -> bool {
257        self.states.get(&group).copied().unwrap_or(false)
258    }
259
260    /// Re-resolve states under an alternate configuration. Useful when
261    /// a PDF carries multiple `/Configs` (e.g. an engineering vs.
262    /// presentation layer setup) and the caller wants to switch.
263    pub fn states_for_config(&self, config: &OcConfig) -> HashMap<ObjectId, bool> {
264        resolve_states(&self.groups, config)
265    }
266
267    /// Evaluate an OCMD's visibility under the current default
268    /// configuration states. Surfaces the `AllOn` / `AnyOn` / `AllOff` /
269    /// `AnyOff` policy or the `/VE` visibility expression.
270    ///
271    /// Returns `true` when no groups are referenced (per §8.11.2.2
272    /// "If this entry is not present, is an empty array, or contains
273    /// references only to null or deleted objects, the membership
274    /// dictionary shall have no effect on the visibility of any
275    /// content").
276    pub fn evaluate_membership(&self, mem: &OcMembership) -> bool {
277        evaluate_membership_with_states(mem, &self.states)
278    }
279}
280
281/// One OCMD parsed from an `/OC` / `/Properties` slot.
282///
283/// Membership dicts attach to content via `BDC /OC /Name` operators
284/// (the `/OC` *tag* + a name from the page resources' `/Properties`
285/// dict) or via form / image XObject and annotation `/OC` entries.
286#[derive(Debug, Clone)]
287pub struct OcMembership {
288    /// `/OCGs` — group references this membership composes.
289    pub groups: Vec<ObjectId>,
290    /// `/P` — simple boolean policy. `None` ⇒ `AnyOn` per Table 99,
291    /// or `/VE` was used and policy is irrelevant.
292    pub policy: OcVisibilityPolicy,
293    /// `/VE` — visibility expression. When `Some`, takes precedence
294    /// over `policy` per §8.11.2.2 NOTE 2.
295    pub visibility_expression: Option<OcVisibilityExpression>,
296}
297
298/// Visibility expression (`/VE`, ISO 32000-1 §8.11.2.2 — PDF 1.6).
299///
300/// Three operator forms per §8.11.2.2:
301///
302/// * `[ /And  e1 e2 … ]` — visible iff every sub-expression is true.
303/// * `[ /Or   e1 e2 … ]` — visible iff any sub-expression is true.
304/// * `[ /Not  e        ]` — visible iff `e` is false (exactly one
305///   subexpression).
306///
307/// Leaves are OCG references; `ON` = `true`, `OFF` = `false`.
308#[derive(Debug, Clone)]
309pub enum OcVisibilityExpression {
310    And(Vec<OcVisibilityExpression>),
311    Or(Vec<OcVisibilityExpression>),
312    Not(Box<OcVisibilityExpression>),
313    /// Reference to an OCG (leaf).
314    Group(ObjectId),
315}
316
317/// Parse the catalog `/OCProperties` entry into a structured
318/// [`OptionalContent`]. Returns `Ok(None)` when the catalog has no
319/// `/OCProperties` (the common case — most PDFs are not layered).
320pub fn optional_content(
321    reader: &mut DocumentReader<'_>,
322) -> Result<Option<OptionalContent>, PdfError> {
323    let root_id = reader.xref().root()?;
324    let catalog = reader.resolve(root_id)?;
325    let Object::Dict(catalog_dict) = catalog else {
326        return Err(PdfError::other(format!(
327            "PDF OCG reader: /Root must be a dict (got {catalog:?})"
328        )));
329    };
330    let ocp_obj = catalog_dict
331        .entries()
332        .iter()
333        .find(|(k, _)| k == "OCProperties")
334        .map(|(_, v)| v.clone());
335    let Some(ocp_obj) = ocp_obj else {
336        return Ok(None);
337    };
338    let ocp_dict = match reader.deref(ocp_obj)? {
339        Object::Dict(d) => d,
340        // Malformed /OCProperties — treat as "no optional content".
341        _ => return Ok(None),
342    };
343
344    // /OCGs — the array of every group in the document.
345    let ocgs_array = ocp_dict
346        .entries()
347        .iter()
348        .find(|(k, _)| k == "OCGs")
349        .map(|(_, v)| v.clone());
350    let Some(ocgs_array) = ocgs_array else {
351        return Ok(None);
352    };
353    let ocgs_array = reader.deref(ocgs_array)?;
354    let Object::Array(group_refs) = ocgs_array else {
355        return Ok(None);
356    };
357
358    let mut groups: Vec<OptionalContentGroup> = Vec::with_capacity(group_refs.len());
359    for item in group_refs {
360        let Object::Reference(id) = item else {
361            continue;
362        };
363        let group_obj = match reader.resolve(id) {
364            Ok(o) => o,
365            Err(_) => continue,
366        };
367        let Object::Dict(group_dict) = group_obj else {
368            continue;
369        };
370        // Best-effort: skip dicts that aren't /Type /OCG.
371        let kind = dict_name(&group_dict, "Type");
372        if let Some(k) = kind.as_deref() {
373            if k != "OCG" {
374                continue;
375            }
376        }
377        let name = dict_text(&group_dict, "Name").unwrap_or_default();
378        let intents = decode_intent_array(reader, &group_dict)?;
379        let usage = decode_usage(reader, &group_dict)?;
380        groups.push(OptionalContentGroup {
381            id,
382            name,
383            intents,
384            usage,
385        });
386    }
387
388    // /D — default configuration. Required per Table 100 but treat
389    // missing as an empty default so we don't refuse partially-formed
390    // PDFs.
391    let default_config = match ocp_dict
392        .entries()
393        .iter()
394        .find(|(k, _)| k == "D")
395        .map(|(_, v)| v.clone())
396    {
397        Some(o) => decode_config(reader, o)?.unwrap_or_default(),
398        None => OcConfig::default(),
399    };
400
401    // /Configs — optional alternates.
402    let mut alternate_configs: Vec<OcConfig> = Vec::new();
403    if let Some(arr) = ocp_dict
404        .entries()
405        .iter()
406        .find(|(k, _)| k == "Configs")
407        .map(|(_, v)| v.clone())
408    {
409        let arr = reader.deref(arr)?;
410        if let Object::Array(items) = arr {
411            for it in items {
412                if let Some(c) = decode_config(reader, it)? {
413                    alternate_configs.push(c);
414                }
415            }
416        }
417    }
418
419    let states = resolve_states(&groups, &default_config);
420
421    Ok(Some(OptionalContent {
422        groups,
423        default_config,
424        alternate_configs,
425        states,
426    }))
427}
428
429/// Parse one Optional Content Membership Dictionary (Table 99). Accepts
430/// the dict directly — callers walking content streams or annotation
431/// dictionaries pull the `/OC` slot and dispatch to this helper.
432///
433/// Returns `None` when the supplied dict isn't a valid OCMD (wrong
434/// `/Type`, no `/OCGs` / `/VE`, etc.) — best-effort matching the
435/// rest of the reader's contract.
436pub fn parse_membership(
437    reader: &mut DocumentReader<'_>,
438    dict: &Dict,
439) -> Result<Option<OcMembership>, PdfError> {
440    let kind = dict_name(dict, "Type");
441    if let Some(k) = kind.as_deref() {
442        if k != "OCMD" {
443            return Ok(None);
444        }
445    }
446    let mut groups: Vec<ObjectId> = Vec::new();
447    if let Some(o) = dict
448        .entries()
449        .iter()
450        .find(|(k, _)| k == "OCGs")
451        .map(|(_, v)| v.clone())
452    {
453        let o = reader.deref(o)?;
454        collect_group_refs(reader, o, &mut groups)?;
455    }
456    let policy = dict_name(dict, "P")
457        .map(|s| OcVisibilityPolicy::from_name(&s))
458        .unwrap_or(OcVisibilityPolicy::AnyOn);
459
460    // /VE — PDF 1.6+ visibility expression. Takes precedence.
461    let mut visibility_expression: Option<OcVisibilityExpression> = None;
462    if let Some(ve) = dict
463        .entries()
464        .iter()
465        .find(|(k, _)| k == "VE")
466        .map(|(_, v)| v.clone())
467    {
468        let ve = reader.deref(ve)?;
469        if let Object::Array(items) = ve {
470            visibility_expression = parse_visibility_expression(reader, &items, 0)?;
471        }
472    }
473    Ok(Some(OcMembership {
474        groups,
475        policy,
476        visibility_expression,
477    }))
478}
479
480// ── internals ─────────────────────────────────────────────────────────
481
482/// Apply the §8.11.4.5 state-resolution algorithm to a config:
483///
484/// (a) BaseState applies to every group.
485/// (b) `/ON` array sets ON over the top of BaseState=OFF / Unchanged.
486/// (c) `/OFF` array sets OFF over the top of BaseState=ON / Unchanged.
487///
488/// `Unchanged` is treated as ON for our purposes when applied to the
489/// default configuration (Table 101 mandates BaseState=ON for the
490/// default config; we apply that constraint here rather than at parse
491/// time so an alternate-config caller still sees `Unchanged`).
492fn resolve_states(groups: &[OptionalContentGroup], config: &OcConfig) -> HashMap<ObjectId, bool> {
493    let mut states: HashMap<ObjectId, bool> = HashMap::with_capacity(groups.len());
494    let base = match config.base_state {
495        OcBaseState::On => true,
496        OcBaseState::Off => false,
497        // Unchanged in an alternate config = leave groups at the prior
498        // state. Without a prior state we have to pick something; we
499        // default to ON (the document's default for the spec's hidden
500        // "this is what the doc was last in" assumption).
501        OcBaseState::Unchanged => true,
502    };
503    for g in groups {
504        states.insert(g.id, base);
505    }
506    for id in &config.on {
507        if let Some(s) = states.get_mut(id) {
508            *s = true;
509        } else {
510            states.insert(*id, true);
511        }
512    }
513    for id in &config.off {
514        if let Some(s) = states.get_mut(id) {
515            *s = false;
516        } else {
517            states.insert(*id, false);
518        }
519    }
520    states
521}
522
523/// Decode one configuration dictionary (Table 101). Accepts either an
524/// inline Dict object or a Reference to one.
525fn decode_config(
526    reader: &mut DocumentReader<'_>,
527    obj: Object,
528) -> Result<Option<OcConfig>, PdfError> {
529    let dict = match reader.deref(obj)? {
530        Object::Dict(d) => d,
531        _ => return Ok(None),
532    };
533    let mut cfg = OcConfig {
534        name: dict_text(&dict, "Name"),
535        creator: dict_text(&dict, "Creator"),
536        base_state: dict_name(&dict, "BaseState")
537            .map(|s| OcBaseState::from_name(&s))
538            .unwrap_or(OcBaseState::On),
539        ..OcConfig::default()
540    };
541
542    if let Some(o) = dict
543        .entries()
544        .iter()
545        .find(|(k, _)| k == "ON")
546        .map(|(_, v)| v.clone())
547    {
548        let o = reader.deref(o)?;
549        collect_group_refs(reader, o, &mut cfg.on)?;
550    }
551    if let Some(o) = dict
552        .entries()
553        .iter()
554        .find(|(k, _)| k == "OFF")
555        .map(|(_, v)| v.clone())
556    {
557        let o = reader.deref(o)?;
558        collect_group_refs(reader, o, &mut cfg.off)?;
559    }
560    cfg.intents = decode_intent_array(reader, &dict)?;
561    if cfg.intents.is_empty() {
562        // §8.11.2.3: default is [/View] for the default configuration.
563        cfg.intents.push("View".to_owned());
564    }
565
566    if let Some(o) = dict
567        .entries()
568        .iter()
569        .find(|(k, _)| k == "Order")
570        .map(|(_, v)| v.clone())
571    {
572        let o = reader.deref(o)?;
573        if let Object::Array(items) = o {
574            cfg.order = decode_order_items(reader, &items, 0)?;
575        }
576    }
577
578    cfg.list_mode = match dict_name(&dict, "ListMode").as_deref() {
579        Some("VisiblePages") => OcListMode::VisiblePages,
580        _ => OcListMode::AllPages,
581    };
582
583    if let Some(o) = dict
584        .entries()
585        .iter()
586        .find(|(k, _)| k == "RBGroups")
587        .map(|(_, v)| v.clone())
588    {
589        let o = reader.deref(o)?;
590        if let Object::Array(outer) = o {
591            for inner in outer {
592                let inner = reader.deref(inner)?;
593                if let Object::Array(ids) = inner {
594                    let mut group = Vec::with_capacity(ids.len());
595                    for it in ids {
596                        if let Object::Reference(id) = it {
597                            group.push(id);
598                        }
599                    }
600                    if !group.is_empty() {
601                        cfg.rb_groups.push(group);
602                    }
603                }
604            }
605        }
606    }
607
608    if let Some(o) = dict
609        .entries()
610        .iter()
611        .find(|(k, _)| k == "Locked")
612        .map(|(_, v)| v.clone())
613    {
614        let o = reader.deref(o)?;
615        collect_group_refs(reader, o, &mut cfg.locked)?;
616    }
617
618    Ok(Some(cfg))
619}
620
621/// Decode an `/Intent` entry (Table 98 + Table 101). The spec allows
622/// either a single Name or an array of Names; we normalise to a Vec.
623fn decode_intent_array(
624    reader: &mut DocumentReader<'_>,
625    dict: &Dict,
626) -> Result<Vec<String>, PdfError> {
627    let Some(o) = dict
628        .entries()
629        .iter()
630        .find(|(k, _)| k == "Intent")
631        .map(|(_, v)| v.clone())
632    else {
633        return Ok(Vec::new());
634    };
635    let o = reader.deref(o)?;
636    Ok(match o {
637        Object::Name(s) => vec![s],
638        Object::Array(items) => items
639            .into_iter()
640            .filter_map(|it| match it {
641                Object::Name(s) => Some(s),
642                _ => None,
643            })
644            .collect(),
645        _ => Vec::new(),
646    })
647}
648
649/// Decode an OCG's `/Usage` subdictionary (Table 102).
650fn decode_usage(
651    reader: &mut DocumentReader<'_>,
652    group_dict: &Dict,
653) -> Result<Option<OcUsage>, PdfError> {
654    let Some(o) = group_dict
655        .entries()
656        .iter()
657        .find(|(k, _)| k == "Usage")
658        .map(|(_, v)| v.clone())
659    else {
660        return Ok(None);
661    };
662    let usage_dict = match reader.deref(o)? {
663        Object::Dict(d) => d,
664        _ => return Ok(None),
665    };
666    let mut out = OcUsage::default();
667    // /Language { /Lang text, /Preferred /ON|/OFF }
668    if let Some(o) = usage_dict
669        .entries()
670        .iter()
671        .find(|(k, _)| k == "Language")
672        .map(|(_, v)| v.clone())
673    {
674        if let Object::Dict(d) = reader.deref(o)? {
675            out.language = dict_text(&d, "Lang");
676            out.language_preferred = dict_name(&d, "Preferred").map(|n| n == "ON");
677        }
678    }
679    // /Zoom { /min n, /max n }
680    if let Some(o) = usage_dict
681        .entries()
682        .iter()
683        .find(|(k, _)| k == "Zoom")
684        .map(|(_, v)| v.clone())
685    {
686        if let Object::Dict(d) = reader.deref(o)? {
687            out.zoom_min = d
688                .entries()
689                .iter()
690                .find(|(k, _)| k == "min")
691                .and_then(|(_, v)| number_to_f64(v));
692            out.zoom_max = d
693                .entries()
694                .iter()
695                .find(|(k, _)| k == "max")
696                .and_then(|(_, v)| number_to_f64(v));
697        }
698    }
699    // /Print { /Subtype, /PrintState }
700    if let Some(o) = usage_dict
701        .entries()
702        .iter()
703        .find(|(k, _)| k == "Print")
704        .map(|(_, v)| v.clone())
705    {
706        if let Object::Dict(d) = reader.deref(o)? {
707            out.print_subtype = dict_name(&d, "Subtype");
708            out.print_state = dict_name(&d, "PrintState").map(|n| n == "ON");
709        }
710    }
711    // /View { /ViewState }
712    if let Some(o) = usage_dict
713        .entries()
714        .iter()
715        .find(|(k, _)| k == "View")
716        .map(|(_, v)| v.clone())
717    {
718        if let Object::Dict(d) = reader.deref(o)? {
719            out.view_state = dict_name(&d, "ViewState").map(|n| n == "ON");
720        }
721    }
722    // /Export { /ExportState }
723    if let Some(o) = usage_dict
724        .entries()
725        .iter()
726        .find(|(k, _)| k == "Export")
727        .map(|(_, v)| v.clone())
728    {
729        if let Object::Dict(d) = reader.deref(o)? {
730            out.export_state = dict_name(&d, "ExportState").map(|n| n == "ON");
731        }
732    }
733    // /PageElement { /Subtype HF|FG|BG|L }
734    if let Some(o) = usage_dict
735        .entries()
736        .iter()
737        .find(|(k, _)| k == "PageElement")
738        .map(|(_, v)| v.clone())
739    {
740        if let Object::Dict(d) = reader.deref(o)? {
741            out.page_element_subtype = dict_name(&d, "Subtype");
742        }
743    }
744    Ok(Some(out))
745}
746
747/// Decode the configuration `/Order` array (Table 101). Items may be
748/// OCG references or nested arrays.
749///
750/// The `_reader` parameter is unused at the moment — the spec only
751/// requires references / arrays / strings here, all of which are
752/// direct values in the array. Kept on the signature so a future
753/// extension that resolves intermediate references (a malformed
754/// producer could theoretically point at an array via a reference)
755/// has a place to hook in without churn.
756#[allow(clippy::only_used_in_recursion)]
757fn decode_order_items(
758    reader: &mut DocumentReader<'_>,
759    items: &[Object],
760    depth: usize,
761) -> Result<Vec<OcOrderItem>, PdfError> {
762    // §8.11.4.3's Order array forms a tree — bound the recursion so a
763    // malformed PDF can't OOM us.
764    if depth > 32 {
765        return Ok(Vec::new());
766    }
767    let mut out = Vec::with_capacity(items.len());
768    for it in items {
769        match it.clone() {
770            Object::Reference(id) => out.push(OcOrderItem::Group(id)),
771            Object::Array(nested) => {
772                // First element may be a label string per Table 101.
773                let mut iter = nested.into_iter();
774                let mut label: Option<String> = None;
775                let mut sub_items: Vec<Object> = Vec::new();
776                let first = iter.next();
777                match first {
778                    Some(Object::LiteralString(b)) | Some(Object::HexString(b)) => {
779                        label = Some(decode_text_string(&b));
780                        sub_items.extend(iter);
781                    }
782                    Some(other) => {
783                        sub_items.push(other);
784                        sub_items.extend(iter);
785                    }
786                    None => {}
787                }
788                let sub = decode_order_items(reader, &sub_items, depth + 1)?;
789                out.push(OcOrderItem::Subtree { label, items: sub });
790            }
791            _ => {} // Skip non-ref / non-array entries.
792        }
793    }
794    Ok(out)
795}
796
797/// Decode a `/VE` visibility expression array per §8.11.2.2:
798///
799/// ```text
800/// [ /And  e1 e2 …  ]
801/// [ /Or   e1 e2 …  ]
802/// [ /Not  e        ]
803/// ```
804///
805/// Returns `Ok(None)` for malformed arrays (best-effort).
806fn parse_visibility_expression(
807    reader: &mut DocumentReader<'_>,
808    items: &[Object],
809    depth: usize,
810) -> Result<Option<OcVisibilityExpression>, PdfError> {
811    if depth > 32 || items.is_empty() {
812        return Ok(None);
813    }
814    let op = match &items[0] {
815        Object::Name(s) => s.as_str(),
816        _ => return Ok(None),
817    };
818    let mut subs: Vec<OcVisibilityExpression> = Vec::new();
819    for it in &items[1..] {
820        let resolved = reader.deref(it.clone())?;
821        match resolved {
822            Object::Reference(id) => subs.push(OcVisibilityExpression::Group(id)),
823            Object::Array(inner) => {
824                if let Some(e) = parse_visibility_expression(reader, &inner, depth + 1)? {
825                    subs.push(e);
826                }
827            }
828            // A direct OCG dict in the VE position is unusual but the
829            // spec doesn't forbid it; track the host id via the dict's
830            // /Type check.
831            _ => {} // Skip atypical leaves.
832        }
833    }
834    match op {
835        "And" => Ok(Some(OcVisibilityExpression::And(subs))),
836        "Or" => Ok(Some(OcVisibilityExpression::Or(subs))),
837        "Not" => {
838            // §8.11.2.2: "If the first element is Not, it shall have
839            // only one subsequent element."  We're lenient — take the
840            // first one.
841            if let Some(first) = subs.into_iter().next() {
842                Ok(Some(OcVisibilityExpression::Not(Box::new(first))))
843            } else {
844                Ok(None)
845            }
846        }
847        _ => Ok(None),
848    }
849}
850
851/// Evaluate a membership dict against a state map. Visibility
852/// expressions override the simple policy per §8.11.2.2 NOTE 2.
853fn evaluate_membership_with_states(mem: &OcMembership, states: &HashMap<ObjectId, bool>) -> bool {
854    if let Some(ve) = &mem.visibility_expression {
855        return evaluate_visibility_expression(ve, states);
856    }
857    // §8.11.2.2: if OCGs is empty / null / all-deleted, the
858    // membership dict has no effect (visible).
859    if mem.groups.is_empty() {
860        return true;
861    }
862    match mem.policy {
863        OcVisibilityPolicy::AllOn => mem
864            .groups
865            .iter()
866            .all(|id| states.get(id).copied().unwrap_or(false)),
867        OcVisibilityPolicy::AnyOn => mem
868            .groups
869            .iter()
870            .any(|id| states.get(id).copied().unwrap_or(false)),
871        OcVisibilityPolicy::AllOff => mem
872            .groups
873            .iter()
874            .all(|id| !states.get(id).copied().unwrap_or(false)),
875        OcVisibilityPolicy::AnyOff => mem
876            .groups
877            .iter()
878            .any(|id| !states.get(id).copied().unwrap_or(false)),
879    }
880}
881
882fn evaluate_visibility_expression(
883    expr: &OcVisibilityExpression,
884    states: &HashMap<ObjectId, bool>,
885) -> bool {
886    match expr {
887        OcVisibilityExpression::And(subs) => subs
888            .iter()
889            .all(|e| evaluate_visibility_expression(e, states)),
890        OcVisibilityExpression::Or(subs) => subs
891            .iter()
892            .any(|e| evaluate_visibility_expression(e, states)),
893        OcVisibilityExpression::Not(inner) => !evaluate_visibility_expression(inner, states),
894        OcVisibilityExpression::Group(id) => states.get(id).copied().unwrap_or(false),
895    }
896}
897
898/// Pull every `Object::Reference` out of `obj` (which may be a single
899/// reference or an array of references) and append to `out`.
900///
901/// The caller is expected to have already `reader.deref`-ed the outer
902/// object — but the *items* inside the array must be left as references
903/// rather than dereferenced (otherwise we'd resolve each reference to
904/// the OCG dict itself and lose the indirect-object id we need to
905/// cross-reference the global `/OCGs` list).
906fn collect_group_refs(
907    _reader: &mut DocumentReader<'_>,
908    obj: Object,
909    out: &mut Vec<ObjectId>,
910) -> Result<(), PdfError> {
911    match obj {
912        Object::Reference(id) => out.push(id),
913        Object::Array(items) => {
914            for it in items {
915                if let Object::Reference(id) = it {
916                    out.push(id);
917                }
918            }
919        }
920        _ => {}
921    }
922    Ok(())
923}
924
925/// Decode a PDF text-string entry — literal or hex; UTF-16BE-with-BOM
926/// recognised. Mirrors the existing reader-side text decoder.
927fn dict_text(d: &Dict, key: &str) -> Option<String> {
928    d.entries()
929        .iter()
930        .find(|(k, _)| k == key)
931        .and_then(|(_, v)| match v {
932            Object::LiteralString(b) | Object::HexString(b) => Some(decode_text_string(b)),
933            Object::Name(s) => Some(s.clone()),
934            _ => None,
935        })
936}
937
938fn decode_text_string(b: &[u8]) -> String {
939    if b.len() >= 2 && b[0] == 0xFE && b[1] == 0xFF {
940        let utf16: Vec<u16> = b[2..]
941            .chunks_exact(2)
942            .map(|c| u16::from_be_bytes([c[0], c[1]]))
943            .collect();
944        String::from_utf16_lossy(&utf16)
945    } else {
946        String::from_utf8_lossy(b).into_owned()
947    }
948}
949
950fn dict_name(d: &Dict, key: &str) -> Option<String> {
951    d.entries()
952        .iter()
953        .find(|(k, _)| k == key)
954        .and_then(|(_, v)| match v {
955            Object::Name(s) => Some(s.clone()),
956            _ => None,
957        })
958}
959
960fn number_to_f64(o: &Object) -> Option<f64> {
961    match o {
962        Object::Integer(n) => Some(*n as f64),
963        Object::Real(f) => Some(*f),
964        _ => None,
965    }
966}
967
968#[cfg(test)]
969mod tests {
970    use super::*;
971    use crate::objects::ObjectId;
972    use std::collections::HashMap;
973
974    fn id(n: u32) -> ObjectId {
975        ObjectId::new(n)
976    }
977
978    fn make_states(pairs: &[(u32, bool)]) -> HashMap<ObjectId, bool> {
979        let mut m = HashMap::new();
980        for (n, s) in pairs {
981            m.insert(id(*n), *s);
982        }
983        m
984    }
985
986    #[test]
987    fn visibility_policy_from_name_defaults_anyon() {
988        assert_eq!(
989            OcVisibilityPolicy::from_name("garbage"),
990            OcVisibilityPolicy::AnyOn
991        );
992        assert_eq!(OcVisibilityPolicy::from_name(""), OcVisibilityPolicy::AnyOn);
993    }
994
995    #[test]
996    fn visibility_policy_recognises_all_four_names() {
997        assert_eq!(
998            OcVisibilityPolicy::from_name("AllOn"),
999            OcVisibilityPolicy::AllOn
1000        );
1001        assert_eq!(
1002            OcVisibilityPolicy::from_name("AnyOn"),
1003            OcVisibilityPolicy::AnyOn
1004        );
1005        assert_eq!(
1006            OcVisibilityPolicy::from_name("AnyOff"),
1007            OcVisibilityPolicy::AnyOff
1008        );
1009        assert_eq!(
1010            OcVisibilityPolicy::from_name("AllOff"),
1011            OcVisibilityPolicy::AllOff
1012        );
1013    }
1014
1015    #[test]
1016    fn base_state_defaults_to_on() {
1017        assert!(matches!(OcBaseState::from_name("garbage"), OcBaseState::On));
1018        assert!(matches!(OcBaseState::from_name("ON"), OcBaseState::On));
1019        assert!(matches!(OcBaseState::from_name("OFF"), OcBaseState::Off));
1020        assert!(matches!(
1021            OcBaseState::from_name("Unchanged"),
1022            OcBaseState::Unchanged
1023        ));
1024    }
1025
1026    #[test]
1027    fn resolve_states_basestate_on_sets_all_on() {
1028        let groups = vec![
1029            OptionalContentGroup {
1030                id: id(10),
1031                name: "L1".into(),
1032                intents: vec!["View".into()],
1033                usage: None,
1034            },
1035            OptionalContentGroup {
1036                id: id(11),
1037                name: "L2".into(),
1038                intents: vec!["View".into()],
1039                usage: None,
1040            },
1041        ];
1042        let cfg = OcConfig {
1043            base_state: OcBaseState::On,
1044            ..OcConfig::default()
1045        };
1046        let s = resolve_states(&groups, &cfg);
1047        assert_eq!(s.get(&id(10)), Some(&true));
1048        assert_eq!(s.get(&id(11)), Some(&true));
1049    }
1050
1051    #[test]
1052    fn resolve_states_basestate_off_sets_all_off() {
1053        let groups = vec![OptionalContentGroup {
1054            id: id(10),
1055            name: "L1".into(),
1056            intents: vec!["View".into()],
1057            usage: None,
1058        }];
1059        let cfg = OcConfig {
1060            base_state: OcBaseState::Off,
1061            ..OcConfig::default()
1062        };
1063        let s = resolve_states(&groups, &cfg);
1064        assert_eq!(s.get(&id(10)), Some(&false));
1065    }
1066
1067    #[test]
1068    fn resolve_states_on_overrides_off_basestate() {
1069        let groups = vec![
1070            OptionalContentGroup {
1071                id: id(10),
1072                name: "L1".into(),
1073                intents: vec![],
1074                usage: None,
1075            },
1076            OptionalContentGroup {
1077                id: id(11),
1078                name: "L2".into(),
1079                intents: vec![],
1080                usage: None,
1081            },
1082            OptionalContentGroup {
1083                id: id(12),
1084                name: "L3".into(),
1085                intents: vec![],
1086                usage: None,
1087            },
1088        ];
1089        let cfg = OcConfig {
1090            base_state: OcBaseState::Off,
1091            on: vec![id(11)],
1092            ..OcConfig::default()
1093        };
1094        let s = resolve_states(&groups, &cfg);
1095        assert_eq!(s.get(&id(10)), Some(&false));
1096        assert_eq!(s.get(&id(11)), Some(&true));
1097        assert_eq!(s.get(&id(12)), Some(&false));
1098    }
1099
1100    #[test]
1101    fn resolve_states_off_overrides_on_basestate() {
1102        let groups = vec![
1103            OptionalContentGroup {
1104                id: id(10),
1105                name: "L1".into(),
1106                intents: vec![],
1107                usage: None,
1108            },
1109            OptionalContentGroup {
1110                id: id(11),
1111                name: "L2".into(),
1112                intents: vec![],
1113                usage: None,
1114            },
1115        ];
1116        let cfg = OcConfig {
1117            base_state: OcBaseState::On,
1118            off: vec![id(10)],
1119            ..OcConfig::default()
1120        };
1121        let s = resolve_states(&groups, &cfg);
1122        assert_eq!(s.get(&id(10)), Some(&false));
1123        assert_eq!(s.get(&id(11)), Some(&true));
1124    }
1125
1126    #[test]
1127    fn evaluate_membership_all_on() {
1128        let states = make_states(&[(10, true), (11, true), (12, false)]);
1129        let mem = OcMembership {
1130            groups: vec![id(10), id(11)],
1131            policy: OcVisibilityPolicy::AllOn,
1132            visibility_expression: None,
1133        };
1134        assert!(evaluate_membership_with_states(&mem, &states));
1135        let mem_with_off = OcMembership {
1136            groups: vec![id(10), id(12)],
1137            policy: OcVisibilityPolicy::AllOn,
1138            visibility_expression: None,
1139        };
1140        assert!(!evaluate_membership_with_states(&mem_with_off, &states));
1141    }
1142
1143    #[test]
1144    fn evaluate_membership_any_on() {
1145        let states = make_states(&[(10, false), (11, false), (12, true)]);
1146        let mem = OcMembership {
1147            groups: vec![id(10), id(11)],
1148            policy: OcVisibilityPolicy::AnyOn,
1149            visibility_expression: None,
1150        };
1151        assert!(!evaluate_membership_with_states(&mem, &states));
1152        let mem_with_on = OcMembership {
1153            groups: vec![id(10), id(12)],
1154            policy: OcVisibilityPolicy::AnyOn,
1155            visibility_expression: None,
1156        };
1157        assert!(evaluate_membership_with_states(&mem_with_on, &states));
1158    }
1159
1160    #[test]
1161    fn evaluate_membership_all_off() {
1162        let states = make_states(&[(10, false), (11, false), (12, true)]);
1163        let mem = OcMembership {
1164            groups: vec![id(10), id(11)],
1165            policy: OcVisibilityPolicy::AllOff,
1166            visibility_expression: None,
1167        };
1168        assert!(evaluate_membership_with_states(&mem, &states));
1169        let mem_with_on = OcMembership {
1170            groups: vec![id(10), id(12)],
1171            policy: OcVisibilityPolicy::AllOff,
1172            visibility_expression: None,
1173        };
1174        assert!(!evaluate_membership_with_states(&mem_with_on, &states));
1175    }
1176
1177    #[test]
1178    fn evaluate_membership_any_off() {
1179        let states = make_states(&[(10, true), (11, true), (12, false)]);
1180        let mem = OcMembership {
1181            groups: vec![id(10), id(11)],
1182            policy: OcVisibilityPolicy::AnyOff,
1183            visibility_expression: None,
1184        };
1185        assert!(!evaluate_membership_with_states(&mem, &states));
1186        let mem_with_off = OcMembership {
1187            groups: vec![id(10), id(12)],
1188            policy: OcVisibilityPolicy::AnyOff,
1189            visibility_expression: None,
1190        };
1191        assert!(evaluate_membership_with_states(&mem_with_off, &states));
1192    }
1193
1194    #[test]
1195    fn evaluate_membership_empty_groups_visible() {
1196        let states = make_states(&[]);
1197        let mem = OcMembership {
1198            groups: vec![],
1199            policy: OcVisibilityPolicy::AllOn,
1200            visibility_expression: None,
1201        };
1202        assert!(evaluate_membership_with_states(&mem, &states));
1203    }
1204
1205    #[test]
1206    fn evaluate_visibility_expression_simple_and() {
1207        let states = make_states(&[(10, true), (11, true), (12, false)]);
1208        let ve = OcVisibilityExpression::And(vec![
1209            OcVisibilityExpression::Group(id(10)),
1210            OcVisibilityExpression::Group(id(11)),
1211        ]);
1212        assert!(evaluate_visibility_expression(&ve, &states));
1213
1214        let ve_fail = OcVisibilityExpression::And(vec![
1215            OcVisibilityExpression::Group(id(10)),
1216            OcVisibilityExpression::Group(id(12)),
1217        ]);
1218        assert!(!evaluate_visibility_expression(&ve_fail, &states));
1219    }
1220
1221    #[test]
1222    fn evaluate_visibility_expression_simple_or() {
1223        let states = make_states(&[(10, false), (11, true), (12, false)]);
1224        let ve = OcVisibilityExpression::Or(vec![
1225            OcVisibilityExpression::Group(id(10)),
1226            OcVisibilityExpression::Group(id(11)),
1227        ]);
1228        assert!(evaluate_visibility_expression(&ve, &states));
1229
1230        let ve_fail = OcVisibilityExpression::Or(vec![
1231            OcVisibilityExpression::Group(id(10)),
1232            OcVisibilityExpression::Group(id(12)),
1233        ]);
1234        assert!(!evaluate_visibility_expression(&ve_fail, &states));
1235    }
1236
1237    #[test]
1238    fn evaluate_visibility_expression_not() {
1239        let states = make_states(&[(10, true)]);
1240        let ve = OcVisibilityExpression::Not(Box::new(OcVisibilityExpression::Group(id(10))));
1241        assert!(!evaluate_visibility_expression(&ve, &states));
1242        let ve_inv = OcVisibilityExpression::Not(Box::new(OcVisibilityExpression::Group(id(11))));
1243        assert!(evaluate_visibility_expression(&ve_inv, &states));
1244    }
1245
1246    #[test]
1247    fn evaluate_visibility_expression_nested() {
1248        // §8.11.2.2 EXAMPLE 3: "(OCG 1) OR (NOT OCG 2) OR (OCG 3 AND OCG 4 AND OCG 5)"
1249        let states = make_states(&[
1250            (1, false),
1251            (2, true), // NOT OCG 2 = false
1252            (3, true),
1253            (4, true),
1254            (5, true), // AND chain = true
1255        ]);
1256        let ve = OcVisibilityExpression::Or(vec![
1257            OcVisibilityExpression::Group(id(1)),
1258            OcVisibilityExpression::Not(Box::new(OcVisibilityExpression::Group(id(2)))),
1259            OcVisibilityExpression::And(vec![
1260                OcVisibilityExpression::Group(id(3)),
1261                OcVisibilityExpression::Group(id(4)),
1262                OcVisibilityExpression::Group(id(5)),
1263            ]),
1264        ]);
1265        // The AND-chain branch is true so the whole Or evaluates true.
1266        assert!(evaluate_visibility_expression(&ve, &states));
1267    }
1268
1269    #[test]
1270    fn unknown_group_id_treated_as_off() {
1271        let states = make_states(&[]);
1272        let mem = OcMembership {
1273            groups: vec![id(99)],
1274            policy: OcVisibilityPolicy::AllOn,
1275            visibility_expression: None,
1276        };
1277        assert!(!evaluate_membership_with_states(&mem, &states));
1278    }
1279}