Skip to main content

gpui_pdf/
outline.rs

1//! PDF outline (bookmarks / "table of contents") extraction.
2//!
3//! Walks the document's `/Outlines` tree through hayro-syntax's low-level object
4//! API and flattens it to `(title, nesting depth, target page index)`. Pure Rust,
5//! no extra deps. Destinations given as an explicit `[pageRef /XYZ …]` array (and
6//! `/A` GoTo actions wrapping one) resolve to a page index; named destinations are
7//! left unresolved for now (the title still shows). Malformed trees are bounded by
8//! a visited-set + item/-depth caps so a cyclic or huge outline can't hang us.
9
10use std::collections::{HashMap, HashSet};
11
12use hayro::hayro_syntax::Pdf;
13use hayro::hayro_syntax::object::{Array, Dict, MaybeRef, Name, ObjRef, Rect, String as PdfString};
14use hayro::hayro_syntax::page::Rotation;
15
16/// One entry in a PDF's outline, flattened depth-first.
17#[derive(Clone, Debug, PartialEq, Eq)]
18pub struct OutlineItem {
19    /// The bookmark label.
20    pub title: String,
21    /// Nesting depth, 0 = top level.
22    pub level: usize,
23    /// 0-based target page index, or `None` if the destination couldn't be resolved.
24    pub page: Option<usize>,
25}
26
27/// Hard caps so a malformed/hostile outline can't hang or OOM us.
28const MAX_ITEMS: usize = 10_000;
29const MAX_DEPTH: usize = 32;
30
31/// Extract the document outline (bookmarks), flattened depth-first. Returns an
32/// empty vec when the PDF has no `/Outlines`.
33pub fn outline(doc: &Pdf) -> Vec<OutlineItem> {
34    let xref = doc.xref();
35    let Some(catalog) = xref.get::<Dict>(xref.root_id()) else {
36        return Vec::new();
37    };
38    let Some(outlines) = catalog.get::<Dict>("Outlines") else {
39        return Vec::new();
40    };
41    let Some(first) = outlines.get_ref("First") else {
42        return Vec::new();
43    };
44
45    let page_index = build_page_index(doc);
46    let mut out = Vec::new();
47    let mut visited = HashSet::new();
48    walk_items(doc, first, 0, &page_index, &mut visited, &mut out);
49    out
50}
51
52/// Follow `/Next` siblings, recursing into `/First` children one level deeper.
53fn walk_items(
54    doc: &Pdf,
55    start: ObjRef,
56    level: usize,
57    page_index: &HashMap<ObjRef, usize>,
58    visited: &mut HashSet<ObjRef>,
59    out: &mut Vec<OutlineItem>,
60) {
61    if level > MAX_DEPTH {
62        return;
63    }
64    let xref = doc.xref();
65    let mut cur = Some(start);
66    while let Some(r) = cur {
67        if out.len() >= MAX_ITEMS || !visited.insert(r) {
68            return;
69        }
70        let Some(item) = xref.get::<Dict>(r.into()) else {
71            return;
72        };
73        if let Some(title) = item.get::<PdfString>("Title") {
74            out.push(OutlineItem {
75                title: decode_pdf_string(title.as_bytes()),
76                level,
77                page: resolve_dest_page(&item, page_index),
78            });
79        }
80        if let Some(child) = item.get_ref("First") {
81            walk_items(doc, child, level + 1, page_index, visited, out);
82        }
83        cur = item.get_ref("Next");
84    }
85}
86
87/// Resolve an outline item's destination to a page index. Handles `/Dest` and
88/// `/A` (GoTo action) `/D` when they're an explicit `[pageRef …]` array.
89fn resolve_dest_page(item: &Dict, page_index: &HashMap<ObjRef, usize>) -> Option<usize> {
90    let dest = item
91        .get::<Array>("Dest")
92        .or_else(|| item.get::<Dict>("A").and_then(|a| a.get::<Array>("D")))?;
93    dest_array_page(&dest, page_index)
94}
95
96/// The page index an explicit destination array (`[pageRef /XYZ …]`) points to —
97/// its first element is an indirect reference to the page object.
98fn dest_array_page(dest: &Array, page_index: &HashMap<ObjRef, usize>) -> Option<usize> {
99    match dest.raw_iter().next()? {
100        MaybeRef::Ref(page_ref) => page_index.get(&page_ref).copied(),
101        MaybeRef::NotRef(_) => None,
102    }
103}
104
105/// Where a clickable PDF link points.
106#[derive(Clone, Debug, PartialEq, Eq)]
107pub enum LinkTarget {
108    /// A 0-based page index within this document.
109    Page(usize),
110    /// An external URI.
111    Uri(String),
112}
113
114/// A clickable `/Link` annotation: its rectangle in normalized page coordinates
115/// (0..1 of the crop box, top-left origin, matching the rendered image) and target.
116#[derive(Clone, Debug, PartialEq)]
117pub struct PdfLink {
118    pub x: f32,
119    pub y: f32,
120    pub w: f32,
121    pub h: f32,
122    pub target: LinkTarget,
123}
124
125/// Extract the clickable `/Link` annotations for every page, indexed by page;
126/// pages with none get an empty vec. Rotated pages are skipped for now (their
127/// annotation rectangles would need rotating to line up with the render).
128pub fn page_links(doc: &Pdf) -> Vec<Vec<PdfLink>> {
129    let page_index = build_page_index(doc);
130    let mut out = Vec::with_capacity(doc.pages().len());
131    for page in doc.pages().iter() {
132        let mut links = Vec::new();
133        let cb = page.crop_box();
134        let (pw, ph) = (cb.width(), cb.height());
135        if !matches!(page.rotation(), Rotation::None) || pw <= 0.0 || ph <= 0.0 {
136            out.push(links);
137            continue;
138        }
139        if let Some(annots) = page.raw().get::<Array>("Annots") {
140            for annot in annots.iter::<Dict>() {
141                if annot
142                    .get::<Name>("Subtype")
143                    .is_none_or(|n| n.as_str() != "Link")
144                {
145                    continue;
146                }
147                let (Some(target), Some(r)) =
148                    (link_target(&annot, &page_index), annot.get::<Rect>("Rect"))
149                else {
150                    continue;
151                };
152                // `/Rect` is in PDF user space (bottom-left origin); normalize to the
153                // crop box with a top-left origin so it overlays the rendered page.
154                let (ax0, ax1) = (r.x0.min(r.x1), r.x0.max(r.x1));
155                let (ay0, ay1) = (r.y0.min(r.y1), r.y0.max(r.y1));
156                links.push(PdfLink {
157                    x: (((ax0 - cb.x0) / pw) as f32).clamp(0.0, 1.0),
158                    y: (((cb.y1 - ay1) / ph) as f32).clamp(0.0, 1.0),
159                    w: (((ax1 - ax0) / pw) as f32).clamp(0.0, 1.0),
160                    h: (((ay1 - ay0) / ph) as f32).clamp(0.0, 1.0),
161                    target,
162                });
163            }
164        }
165        out.push(links);
166    }
167    out
168}
169
170/// Whether a `/URI` action may reach the OS URL opener (`NSWorkspace openURL:`
171/// / `ShellExecute`). A PDF is entirely attacker-authored — including the
172/// clickable overlay's position and size — so this is an allowlist: only
173/// `http://` and `https://`, schemes compared case-insensitively per RFC 3986.
174/// Everything else is rejected (`file:` launches local content, `smb:` leaks
175/// NTLM hashes on Windows, `javascript:`/`data:`/app-registered schemes like
176/// `ms-msdt:` run handlers). Whitespace and control characters anywhere are a
177/// rejection, not something to trim: openers strip them, so ` javascript:…`
178/// would otherwise walk past the prefix check.
179///
180/// Deliberately duplicated from `zorite_markdown::syntax::is_safe_external_url`
181/// — five lines is cheaper than the cross-crate dependency the crates rule
182/// forbids. Keep the two in step.
183fn is_safe_external_uri(uri: &str) -> bool {
184    !uri.chars().any(|c| c.is_whitespace() || c.is_control())
185        && ["http://", "https://"].iter().any(|scheme| {
186            uri.as_bytes()
187                .get(..scheme.len())
188                .is_some_and(|got| got.eq_ignore_ascii_case(scheme.as_bytes()))
189        })
190}
191
192/// Resolve a `/Link` annotation's target: `/Dest`, or an `/A` action (`/URI` for
193/// external links, `/GoTo` for internal jumps).
194fn link_target(annot: &Dict, page_index: &HashMap<ObjRef, usize>) -> Option<LinkTarget> {
195    if let Some(dest) = annot.get::<Array>("Dest") {
196        return dest_array_page(&dest, page_index).map(LinkTarget::Page);
197    }
198    let action = annot.get::<Dict>("A")?;
199    match action.get::<Name>("S").as_ref().map(|n| n.as_str()) {
200        Some("URI") => {
201            let uri = decode_pdf_string(action.get::<PdfString>("URI")?.as_bytes());
202            if !is_safe_external_uri(&uri) {
203                // Dropped here rather than at the click: no annotation, no
204                // overlay, so a hostile URI never even reads as clickable.
205                log::warn!("pdf: dropped link annotation with unsupported URI scheme");
206                return None;
207            }
208            Some(LinkTarget::Uri(uri))
209        }
210        Some("GoTo") => {
211            dest_array_page(&action.get::<Array>("D")?, page_index).map(LinkTarget::Page)
212        }
213        _ => None,
214    }
215}
216
217/// Map every page's object reference to its 0-based index by walking the page
218/// tree, so destinations (which reference a page by object ref) can be resolved.
219fn build_page_index(doc: &Pdf) -> HashMap<ObjRef, usize> {
220    let xref = doc.xref();
221    let mut map = HashMap::new();
222    let Some(catalog) = xref.get::<Dict>(xref.root_id()) else {
223        return map;
224    };
225    let Some(root) = catalog.get_ref("Pages") else {
226        return map;
227    };
228    let mut idx = 0;
229    let mut visited = HashSet::new();
230    walk_pages(doc, root, 0, &mut idx, &mut visited, &mut map);
231    map
232}
233
234fn walk_pages(
235    doc: &Pdf,
236    r: ObjRef,
237    depth: usize,
238    idx: &mut usize,
239    visited: &mut HashSet<ObjRef>,
240    map: &mut HashMap<ObjRef, usize>,
241) {
242    if depth > MAX_DEPTH || !visited.insert(r) {
243        return;
244    }
245    let xref = doc.xref();
246    let Some(dict) = xref.get::<Dict>(r.into()) else {
247        return;
248    };
249    // An internal node has `/Kids`; a leaf is a `/Page`.
250    if let Some(kids) = dict.get::<Array>("Kids") {
251        for kid in kids.raw_iter() {
252            if let MaybeRef::Ref(kr) = kid {
253                walk_pages(doc, kr, depth + 1, idx, visited, map);
254            }
255        }
256    } else {
257        map.insert(r, *idx);
258        *idx += 1;
259    }
260}
261
262/// Decode a PDF text string: UTF-16BE when it carries the BOM, otherwise treated
263/// as Latin-1 (a close-enough stand-in for PDFDocEncoding for titles).
264fn decode_pdf_string(bytes: &[u8]) -> String {
265    if let Some(rest) = bytes.strip_prefix(&[0xFE, 0xFF]) {
266        let units: Vec<u16> = rest
267            .as_chunks::<2>()
268            .0
269            .iter()
270            .map(|c| u16::from_be_bytes(*c))
271            .collect();
272        String::from_utf16_lossy(&units).trim().to_string()
273    } else {
274        bytes
275            .iter()
276            .map(|&b| b as char)
277            .collect::<String>()
278            .trim()
279            .to_string()
280    }
281}
282
283#[cfg(test)]
284mod tests {
285    use super::*;
286
287    #[test]
288    fn utf16be_bom_decodes() {
289        // "Hi" in UTF-16BE with BOM.
290        let b = [0xFE, 0xFF, 0x00, b'H', 0x00, b'i'];
291        assert_eq!(decode_pdf_string(&b), "Hi");
292    }
293
294    #[test]
295    fn latin1_decodes_and_trims() {
296        assert_eq!(decode_pdf_string(b"  Intro  "), "Intro");
297        assert_eq!(decode_pdf_string(&[b'C', 0xE9]), "Cé"); // 0xE9 = é in Latin-1
298    }
299
300    #[test]
301    fn only_http_uris_survive() {
302        assert!(is_safe_external_uri("https://example.com"));
303        assert!(is_safe_external_uri("HTTP://EXAMPLE.COM"));
304        for bad in [
305            "file:///etc/passwd",
306            "smb://evil/share",
307            "javascript:alert(1)",
308            "mailto:a@b.c",
309            "//evil.com",
310            r"\\evil\share",
311            " javascript:alert(1)",
312            "java\tscript:x",
313            "",
314        ] {
315            assert!(!is_safe_external_uri(bad), "should reject {bad:?}");
316        }
317    }
318}