Skip to main content

edgeparse_core/pdf/
ocg.rs

1//! Optional Content Groups (OCG) — default visibility from the catalog.
2//!
3//! ISO 32000-1 §8.11: `/OCProperties` / `/D` (default configuration) lists
4//! which OCGs are ON/OFF. Extraction should omit text whose OCG is off.
5
6use std::collections::HashSet;
7
8use lopdf::{Document, Object};
9
10/// Default OCG visibility derived from `/Catalog` → `/OCProperties` → `/D`.
11#[derive(Debug, Clone, Default)]
12pub struct OcgVisibility {
13    /// Object IDs of OCGs that are ON by default.
14    pub on: HashSet<(u32, u16)>,
15    /// Object IDs of OCGs that are OFF by default.
16    pub off: HashSet<(u32, u16)>,
17    /// When true, any OCG not listed in `on`/`off` is treated as visible
18    /// (BaseState = ON, the PDF default).
19    pub default_on: bool,
20}
21
22impl OcgVisibility {
23    /// Whether an OCG object id is visible under the catalog defaults.
24    pub fn is_visible(&self, id: (u32, u16)) -> bool {
25        if self.off.contains(&id) {
26            return false;
27        }
28        if self.on.contains(&id) {
29            return true;
30        }
31        self.default_on
32    }
33
34    /// True when the document declares no OCG config (everything visible).
35    pub fn is_empty(&self) -> bool {
36        self.on.is_empty() && self.off.is_empty()
37    }
38}
39
40/// Load OCG default visibility from the document catalog.
41pub fn load_ocg_visibility(doc: &Document) -> OcgVisibility {
42    let mut vis = OcgVisibility {
43        default_on: true,
44        ..OcgVisibility::default()
45    };
46
47    let Ok(catalog) = doc.catalog() else {
48        return vis;
49    };
50    let Ok(oc_props) = catalog.get(b"OCProperties") else {
51        return vis;
52    };
53    let oc_dict = match resolve(doc, oc_props) {
54        Object::Dictionary(d) => d,
55        _ => return vis,
56    };
57
58    // Collect all OCGs from /OCGs array for BaseState handling.
59    let mut all_ocgs: HashSet<(u32, u16)> = HashSet::new();
60    if let Ok(ocgs) = oc_dict.get(b"OCGs") {
61        collect_refs(doc, ocgs, &mut all_ocgs);
62    }
63
64    let Ok(d_obj) = oc_dict.get(b"D") else {
65        // No default config — all ON.
66        vis.on = all_ocgs;
67        return vis;
68    };
69    let d_dict = match resolve(doc, d_obj) {
70        Object::Dictionary(d) => d,
71        _ => {
72            vis.on = all_ocgs;
73            return vis;
74        }
75    };
76
77    // BaseState: ON (default) | OFF | Unchanged
78    if let Ok(Object::Name(name)) = d_dict.get(b"BaseState") {
79        match name.as_slice() {
80            b"OFF" => vis.default_on = false,
81            _ => vis.default_on = true,
82        }
83    }
84
85    if let Ok(on_arr) = d_dict.get(b"ON") {
86        collect_refs(doc, on_arr, &mut vis.on);
87    }
88    if let Ok(off_arr) = d_dict.get(b"OFF") {
89        collect_refs(doc, off_arr, &mut vis.off);
90    }
91
92    // When BaseState is ON and ON array empty, all OCGs are on unless in OFF.
93    if vis.default_on && vis.on.is_empty() {
94        for id in &all_ocgs {
95            if !vis.off.contains(id) {
96                vis.on.insert(*id);
97            }
98        }
99    }
100
101    vis
102}
103
104fn resolve(doc: &Document, obj: &Object) -> Object {
105    match obj {
106        Object::Reference(id) => doc.get_object(*id).cloned().unwrap_or(Object::Null),
107        other => other.clone(),
108    }
109}
110
111fn collect_refs(doc: &Document, obj: &Object, out: &mut HashSet<(u32, u16)>) {
112    match obj {
113        Object::Array(arr) => {
114            for item in arr {
115                collect_refs(doc, item, out);
116            }
117        }
118        Object::Reference(id) => {
119            out.insert(*id);
120        }
121        other => {
122            // Dereference only for nested arrays; inline OCG dicts have no stable id.
123            match resolve(doc, other) {
124                Object::Array(arr) => {
125                    for item in arr {
126                        collect_refs(doc, &item, out);
127                    }
128                }
129                Object::Reference(id) => {
130                    out.insert(id);
131                }
132                _ => {}
133            }
134        }
135    }
136}
137
138#[cfg(test)]
139mod tests {
140    use super::*;
141    use lopdf::dictionary;
142
143    #[test]
144    fn empty_doc_all_visible() {
145        let doc = Document::new();
146        let vis = load_ocg_visibility(&doc);
147        assert!(vis.is_visible((1, 0)));
148        assert!(vis.default_on);
149    }
150
151    #[test]
152    fn off_list_hides_ocg() {
153        let mut doc = Document::with_version("1.5");
154        let ocg_off = doc.add_object(dictionary! {
155            "Type" => "OCG",
156            "Name" => Object::string_literal("Hidden"),
157        });
158        let ocg_on = doc.add_object(dictionary! {
159            "Type" => "OCG",
160            "Name" => Object::string_literal("Shown"),
161        });
162        let pages_id = doc.new_object_id();
163        let pages = dictionary! {
164            "Type" => "Pages",
165            "Kids" => Vec::<Object>::new(),
166            "Count" => 0,
167        };
168        doc.objects.insert(pages_id, Object::Dictionary(pages));
169
170        let catalog_id = doc.add_object(dictionary! {
171            "Type" => "Catalog",
172            "Pages" => pages_id,
173            "OCProperties" => dictionary! {
174                "OCGs" => vec![ocg_off.into(), ocg_on.into()],
175                "D" => dictionary! {
176                    "BaseState" => "ON",
177                    "OFF" => vec![ocg_off.into()],
178                    "ON" => vec![ocg_on.into()],
179                },
180            },
181        });
182        doc.trailer.set("Root", catalog_id);
183
184        let vis = load_ocg_visibility(&doc);
185        assert!(!vis.is_visible(ocg_off));
186        assert!(vis.is_visible(ocg_on));
187    }
188}