Skip to main content

folio_nav/
bookmark.rs

1//! PDF bookmarks (document outline).
2
3use folio_core::{FolioError, Result};
4use folio_cos::{CosDoc, ObjectId, PdfObject};
5
6/// A PDF bookmark (outline item).
7#[derive(Debug, Clone)]
8pub struct Bookmark {
9    /// Object ID of this bookmark.
10    id: ObjectId,
11    /// The bookmark's title.
12    title: String,
13    /// Reference to the destination or action.
14    dest: Option<PdfObject>,
15    action: Option<PdfObject>,
16    /// Child/sibling/parent references.
17    first_child: Option<ObjectId>,
18    last_child: Option<ObjectId>,
19    next: Option<ObjectId>,
20    prev: Option<ObjectId>,
21    parent: Option<ObjectId>,
22    /// Number of visible descendants (negative = closed).
23    count: i64,
24    /// Display flags: 1=italic, 2=bold.
25    flags: i32,
26    /// Color (RGB, 0-1 range).
27    color: Option<[f64; 3]>,
28}
29
30impl Bookmark {
31    /// Load a bookmark from a document.
32    pub fn load(obj_num: u32, doc: &mut CosDoc) -> Result<Self> {
33        let obj = doc
34            .get_object(obj_num)?
35            .ok_or_else(|| FolioError::InvalidObject(format!("Bookmark {} not found", obj_num)))?
36            .clone();
37
38        let dict = obj
39            .as_dict()
40            .ok_or_else(|| FolioError::InvalidObject("Bookmark is not a dict".into()))?;
41
42        let title = dict
43            .get(b"Title".as_slice())
44            .and_then(|o| o.as_str())
45            .map(decode_text)
46            .unwrap_or_default();
47
48        Ok(Self {
49            id: ObjectId::new(obj_num, 0),
50            title,
51            dest: dict.get(b"Dest".as_slice()).cloned(),
52            action: dict.get(b"A".as_slice()).cloned(),
53            first_child: dict.get(b"First".as_slice()).and_then(|o| o.as_reference()),
54            last_child: dict.get(b"Last".as_slice()).and_then(|o| o.as_reference()),
55            next: dict.get(b"Next".as_slice()).and_then(|o| o.as_reference()),
56            prev: dict.get(b"Prev".as_slice()).and_then(|o| o.as_reference()),
57            parent: dict
58                .get(b"Parent".as_slice())
59                .and_then(|o| o.as_reference()),
60            count: dict
61                .get(b"Count".as_slice())
62                .and_then(|o| o.as_i64())
63                .unwrap_or(0),
64            flags: dict
65                .get(b"F".as_slice())
66                .and_then(|o| o.as_i64())
67                .unwrap_or(0) as i32,
68            color: dict.get(b"C".as_slice()).and_then(|o| {
69                let arr = o.as_array()?;
70                if arr.len() >= 3 {
71                    Some([arr[0].as_f64()?, arr[1].as_f64()?, arr[2].as_f64()?])
72                } else {
73                    None
74                }
75            }),
76        })
77    }
78
79    /// Get the bookmark title.
80    pub fn title(&self) -> &str {
81        &self.title
82    }
83
84    /// Get the object ID.
85    pub fn id(&self) -> ObjectId {
86        self.id
87    }
88
89    /// Whether this bookmark is open (children visible).
90    pub fn is_open(&self) -> bool {
91        self.count > 0
92    }
93
94    /// Whether this bookmark is italic.
95    pub fn is_italic(&self) -> bool {
96        self.flags & 1 != 0
97    }
98
99    /// Whether this bookmark is bold.
100    pub fn is_bold(&self) -> bool {
101        self.flags & 2 != 0
102    }
103
104    /// Get the bookmark color (RGB).
105    pub fn color(&self) -> Option<[f64; 3]> {
106        self.color
107    }
108
109    /// Get the destination object (if any).
110    pub fn destination(&self) -> Option<&PdfObject> {
111        self.dest.as_ref()
112    }
113
114    /// Get the action object (if any).
115    pub fn action(&self) -> Option<&PdfObject> {
116        self.action.as_ref()
117    }
118
119    /// Get the first child bookmark ID.
120    pub fn first_child(&self) -> Option<ObjectId> {
121        self.first_child
122    }
123
124    /// Get the next sibling bookmark ID.
125    pub fn next(&self) -> Option<ObjectId> {
126        self.next
127    }
128
129    /// Get the previous sibling bookmark ID.
130    pub fn prev(&self) -> Option<ObjectId> {
131        self.prev
132    }
133
134    /// Get the parent bookmark ID.
135    pub fn parent(&self) -> Option<ObjectId> {
136        self.parent
137    }
138
139    /// Check if this bookmark has children.
140    pub fn has_children(&self) -> bool {
141        self.first_child.is_some()
142    }
143
144    /// Get all bookmarks in the document as a flat list (depth-first).
145    pub fn get_all(doc: &mut CosDoc) -> Result<Vec<(Bookmark, u32)>> {
146        let catalog_ref = doc
147            .trailer()
148            .get(b"Root".as_slice())
149            .and_then(|o| o.as_reference())
150            .ok_or_else(|| FolioError::InvalidObject("No /Root".into()))?;
151
152        let catalog = doc
153            .get_object(catalog_ref.num)?
154            .ok_or_else(|| FolioError::InvalidObject("Catalog not found".into()))?
155            .clone();
156
157        let outlines_ref = match catalog.dict_get(b"Outlines") {
158            Some(PdfObject::Reference(id)) => *id,
159            _ => return Ok(Vec::new()),
160        };
161
162        let outlines = doc
163            .get_object(outlines_ref.num)?
164            .ok_or_else(|| FolioError::InvalidObject("Outlines not found".into()))?
165            .clone();
166
167        let first = match outlines.dict_get(b"First") {
168            Some(PdfObject::Reference(id)) => *id,
169            _ => return Ok(Vec::new()),
170        };
171
172        let mut result = Vec::new();
173        Self::collect_bookmarks(first, doc, 0, &mut result)?;
174        Ok(result)
175    }
176
177    fn collect_bookmarks(
178        id: ObjectId,
179        doc: &mut CosDoc,
180        depth: u32,
181        result: &mut Vec<(Bookmark, u32)>,
182    ) -> Result<()> {
183        let bm = Bookmark::load(id.num, doc)?;
184        let next = bm.next;
185        let first_child = bm.first_child;
186        result.push((bm, depth));
187
188        // Recurse into children
189        if let Some(child_id) = first_child {
190            Self::collect_bookmarks(child_id, doc, depth + 1, result)?;
191        }
192
193        // Continue to next sibling
194        if let Some(next_id) = next {
195            Self::collect_bookmarks(next_id, doc, depth, result)?;
196        }
197
198        Ok(())
199    }
200}
201
202fn decode_text(data: &[u8]) -> String {
203    if data.len() >= 2 && data[0] == 0xFE && data[1] == 0xFF {
204        let mut chars = Vec::new();
205        let mut i = 2;
206        while i + 1 < data.len() {
207            chars.push(((data[i] as u16) << 8) | (data[i + 1] as u16));
208            i += 2;
209        }
210        String::from_utf16_lossy(&chars)
211    } else {
212        String::from_utf8_lossy(data).into_owned()
213    }
214}