Skip to main content

oxideav_pdf/reader/
link.rs

1//! Round-25 — PDF Link annotation reader (ISO 32000-1 §12.5.6.5).
2//!
3//! Walks every page's `/Annots` array, filters down to entries whose
4//! `/Subtype` is `/Link`, and surfaces them as [`PdfLink`] values.
5//! Each link's destination decodes to either an
6//! [`crate::outline::OutlineDestination`] (internal go-to) or a URI
7//! (`/A << /S /URI /URI (...) >>`).
8//!
9//! Pages without annotations or without any Link entries return an
10//! empty Vec. Malformed annotation dicts are skipped (best-effort
11//! enumeration matches the round-21 `/Sig` reader's contract).
12
13use std::collections::HashMap;
14
15use crate::error::PdfError;
16use crate::objects::{Dict, Object, ObjectId};
17use crate::outline::OutlineDestination;
18use crate::reader::document::DocumentReader;
19use crate::reader::outline::build_page_index_map;
20
21/// One Link annotation, ready for a caller to follow.
22#[derive(Debug, Clone)]
23pub struct PdfLink {
24    /// 0-based page index — which page in DFS order carries this
25    /// annotation in its `/Annots` array.
26    pub source_page_index: usize,
27    /// `/Rect` — clickable bounding rectangle in default user space
28    /// (PDF coordinates, origin bottom-left).
29    pub rect: [f32; 4],
30    /// Where the link points. `None` when the annotation has neither
31    /// a `/Dest` nor an `/A` action this reader recognises (rare —
32    /// most PDFs in the wild populate at least one).
33    pub target: Option<PdfLinkTarget>,
34}
35
36/// What a [`PdfLink`] points to.
37#[derive(Debug, Clone)]
38pub enum PdfLinkTarget {
39    /// In-document jump.
40    Internal(OutlineDestination),
41    /// External URI (HTTP, mailto, etc.).
42    Uri(String),
43    /// A named destination (`/Dest` was a Name or string instead of
44    /// the explicit array form). The catalog's `/Dests` name tree
45    /// would resolve this — round-25 surfaces it untouched.
46    Named(String),
47}
48
49/// Walk every page in DFS order, collecting every Link annotation.
50/// The result keeps the source page-index baked in so callers don't
51/// have to re-thread it.
52pub fn links(reader: &mut DocumentReader<'_>) -> Result<Vec<PdfLink>, PdfError> {
53    let page_index_map = build_page_index_map(reader)?;
54    // Inverse: position-in-DFS → ObjectId.
55    let mut pages_by_index: Vec<ObjectId> = Vec::with_capacity(page_index_map.len());
56    pages_by_index.resize(page_index_map.len(), ObjectId::new(0));
57    for (n, idx) in &page_index_map {
58        pages_by_index[*idx] = ObjectId::new(*n);
59    }
60
61    let mut out = Vec::new();
62    for (idx, page_id) in pages_by_index.iter().enumerate() {
63        // page-id 0 is impossible (PDF allocates from 1), so it's a
64        // sentinel for "this slot was never populated" — skip.
65        if page_id.number == 0 {
66            continue;
67        }
68        let page = match reader.resolve(*page_id)? {
69            Object::Dict(d) => d,
70            _ => continue,
71        };
72        let annots_obj = page
73            .entries()
74            .iter()
75            .find(|(k, _)| k == "Annots")
76            .map(|(_, v)| v.clone());
77        let Some(annots_obj) = annots_obj else {
78            continue;
79        };
80        // /Annots may be inline array or indirect reference to one.
81        let annots_obj = reader.deref(annots_obj)?;
82        let Object::Array(items) = annots_obj else {
83            continue;
84        };
85        for item in items {
86            let annot = match reader.deref(item)? {
87                Object::Dict(d) => d,
88                _ => continue,
89            };
90            let is_link = matches!(
91                annot.entries().iter().find(|(k, _)| k == "Subtype").map(|(_, v)| v),
92                Some(Object::Name(s)) if s == "Link"
93            );
94            if !is_link {
95                continue;
96            }
97            if let Some(link) = decode_link(reader, &annot, idx, &page_index_map)? {
98                out.push(link);
99            }
100        }
101    }
102    Ok(out)
103}
104
105fn decode_link(
106    reader: &mut DocumentReader<'_>,
107    annot: &Dict,
108    page_index: usize,
109    page_index_map: &HashMap<u32, usize>,
110) -> Result<Option<PdfLink>, PdfError> {
111    let rect = match annot
112        .entries()
113        .iter()
114        .find(|(k, _)| k == "Rect")
115        .map(|(_, v)| v)
116    {
117        Some(Object::Array(items)) if items.len() == 4 => {
118            let mut out = [0f32; 4];
119            for (i, it) in items.iter().enumerate() {
120                out[i] = match it {
121                    Object::Real(f) => *f as f32,
122                    Object::Integer(n) => *n as f32,
123                    _ => return Ok(None),
124                };
125            }
126            out
127        }
128        _ => return Ok(None),
129    };
130
131    let target = decode_link_target(reader, annot, page_index_map)?;
132
133    Ok(Some(PdfLink {
134        source_page_index: page_index,
135        rect,
136        target,
137    }))
138}
139
140fn decode_link_target(
141    reader: &mut DocumentReader<'_>,
142    annot: &Dict,
143    page_index_map: &HashMap<u32, usize>,
144) -> Result<Option<PdfLinkTarget>, PdfError> {
145    // /Dest takes precedence over /A per Table 173.
146    if let Some(dest) = annot
147        .entries()
148        .iter()
149        .find(|(k, _)| k == "Dest")
150        .map(|(_, v)| v.clone())
151    {
152        let dest = reader.deref(dest)?;
153        return Ok(decode_dest_value(dest, page_index_map));
154    }
155    if let Some(action) = annot
156        .entries()
157        .iter()
158        .find(|(k, _)| k == "A")
159        .map(|(_, v)| v.clone())
160    {
161        let action = reader.deref(action)?;
162        if let Object::Dict(adict) = action {
163            let s_kind =
164                adict
165                    .entries()
166                    .iter()
167                    .find(|(k, _)| k == "S")
168                    .and_then(|(_, v)| match v {
169                        Object::Name(s) => Some(s.clone()),
170                        _ => None,
171                    });
172            match s_kind.as_deref() {
173                Some("URI") => {
174                    let uri = adict.entries().iter().find(|(k, _)| k == "URI").and_then(
175                        |(_, v)| match v {
176                            Object::LiteralString(b) | Object::HexString(b) => {
177                                Some(String::from_utf8_lossy(b).into_owned())
178                            }
179                            _ => None,
180                        },
181                    );
182                    return Ok(uri.map(PdfLinkTarget::Uri));
183                }
184                Some("GoTo") => {
185                    if let Some(d) = adict
186                        .entries()
187                        .iter()
188                        .find(|(k, _)| k == "D")
189                        .map(|(_, v)| v.clone())
190                    {
191                        let d = reader.deref(d)?;
192                        return Ok(decode_dest_value(d, page_index_map));
193                    }
194                }
195                _ => {}
196            }
197        }
198    }
199    Ok(None)
200}
201
202fn decode_dest_value(dest: Object, page_index_map: &HashMap<u32, usize>) -> Option<PdfLinkTarget> {
203    match dest {
204        Object::Array(items) => {
205            decode_explicit_dest(&items, page_index_map).map(PdfLinkTarget::Internal)
206        }
207        Object::Name(s) => Some(PdfLinkTarget::Named(s)),
208        Object::LiteralString(b) | Object::HexString(b) => Some(PdfLinkTarget::Named(
209            String::from_utf8_lossy(&b).into_owned(),
210        )),
211        _ => None,
212    }
213}
214
215fn decode_explicit_dest(
216    items: &[Object],
217    page_index_map: &HashMap<u32, usize>,
218) -> Option<OutlineDestination> {
219    if items.len() < 2 {
220        return None;
221    }
222    let page_index = match &items[0] {
223        Object::Reference(id) => *page_index_map.get(&id.number)?,
224        _ => return None,
225    };
226    let mode = match &items[1] {
227        Object::Name(n) => n.as_str(),
228        _ => return None,
229    };
230    let opt = |o: Option<&Object>| match o {
231        Some(Object::Real(f)) => Some(*f as f32),
232        Some(Object::Integer(n)) => Some(*n as f32),
233        Some(Object::Null) | None => None,
234        _ => None,
235    };
236    let req = |o: Option<&Object>| -> Option<f32> {
237        match o {
238            Some(Object::Real(f)) => Some(*f as f32),
239            Some(Object::Integer(n)) => Some(*n as f32),
240            _ => None,
241        }
242    };
243    match mode {
244        "XYZ" => Some(OutlineDestination::Xyz {
245            page_index,
246            left: opt(items.get(2)),
247            top: opt(items.get(3)),
248            zoom: opt(items.get(4)).filter(|z| *z != 0.0),
249        }),
250        "Fit" => Some(OutlineDestination::Fit { page_index }),
251        "FitH" => Some(OutlineDestination::FitH {
252            page_index,
253            top: opt(items.get(2)),
254        }),
255        "FitV" => Some(OutlineDestination::FitV {
256            page_index,
257            left: opt(items.get(2)),
258        }),
259        "FitR" => Some(OutlineDestination::FitR {
260            page_index,
261            left: req(items.get(2))?,
262            bottom: req(items.get(3))?,
263            right: req(items.get(4))?,
264            top: req(items.get(5))?,
265        }),
266        "FitB" => Some(OutlineDestination::FitB { page_index }),
267        "FitBH" => Some(OutlineDestination::FitBH {
268            page_index,
269            top: opt(items.get(2)),
270        }),
271        "FitBV" => Some(OutlineDestination::FitBV {
272            page_index,
273            left: opt(items.get(2)),
274        }),
275        _ => None,
276    }
277}
278
279#[cfg(test)]
280mod tests {
281    use super::*;
282
283    #[test]
284    fn decode_dest_value_named_byte_string() {
285        let v = decode_dest_value(
286            Object::LiteralString(b"Chap6.begin".to_vec()),
287            &HashMap::new(),
288        );
289        match v {
290            Some(PdfLinkTarget::Named(s)) => assert_eq!(s, "Chap6.begin"),
291            other => panic!("expected Named, got {other:?}"),
292        }
293    }
294
295    #[test]
296    fn decode_dest_value_array_fit_resolves_page_index() {
297        let mut map = HashMap::new();
298        map.insert(7u32, 3usize);
299        let v = decode_dest_value(
300            Object::Array(vec![
301                Object::Reference(ObjectId::new(7)),
302                Object::Name("Fit".into()),
303            ]),
304            &map,
305        );
306        match v {
307            Some(PdfLinkTarget::Internal(OutlineDestination::Fit { page_index })) => {
308                assert_eq!(page_index, 3);
309            }
310            other => panic!("expected Internal Fit, got {other:?}"),
311        }
312    }
313
314    #[test]
315    fn decode_explicit_dest_xyz_with_zoom_zero_is_none() {
316        let mut map = HashMap::new();
317        map.insert(2u32, 0usize);
318        let arr = vec![
319            Object::Reference(ObjectId::new(2)),
320            Object::Name("XYZ".into()),
321            Object::Real(10.0),
322            Object::Real(20.0),
323            Object::Real(0.0),
324        ];
325        let d = decode_explicit_dest(&arr, &map).unwrap();
326        assert_eq!(
327            d,
328            OutlineDestination::Xyz {
329                page_index: 0,
330                left: Some(10.0),
331                top: Some(20.0),
332                zoom: None,
333            }
334        );
335    }
336}