Skip to main content

rpdfium_doc/
link.rs

1// Derived from PDFium's cpdf_link.h/cpp
2// Original: Copyright 2014 The PDFium Authors
3// Licensed under BSD-3-Clause / Apache-2.0
4// See pdfium-upstream/LICENSE for the original license.
5
6//! Link annotation accessor — `CPDF_Link`.
7//!
8//! Provides a typed view of a Link annotation dictionary, exposing its
9//! rectangle, destination, and action (ISO 32000-2 section 12.5.6.5).
10//!
11//! Corresponds to upstream `CPDF_Link` which wraps a link annotation
12//! dictionary and provides `GetRect()`, `GetDest()`, and `GetAction()`.
13
14use std::collections::HashMap;
15
16use rpdfium_core::{Name, PdfSource};
17use rpdfium_parser::{Object, ObjectStore};
18
19use crate::action::{Action, parse_action};
20use crate::destination::{Destination, parse_destination};
21use crate::error::DocResult;
22
23/// A typed view of a Link annotation dictionary.
24///
25/// Provides access to the annotation's rectangle, optional destination,
26/// and optional action. Corresponds to upstream `CPDF_Link`.
27#[derive(Debug, Clone)]
28pub struct Link {
29    /// Annotation rectangle in user space: `[x1, y1, x2, y2]`.
30    pub rect: [f32; 4],
31    /// Destination, if specified via `/Dest`.
32    pub destination: Option<Destination>,
33    /// Action, if specified via `/A`.
34    pub action: Option<Action>,
35}
36
37/// Parse a `Link` from a link annotation dictionary.
38///
39/// Corresponds to upstream `CPDF_Link` construction from an annotation dict.
40pub fn parse_link<S: PdfSource>(
41    dict: &HashMap<Name, Object>,
42    store: &ObjectStore<S>,
43) -> DocResult<Link> {
44    // /Rect
45    let rect_obj = dict
46        .get(&Name::rect())
47        .ok_or_else(|| crate::error::DocError::MissingKey("/Rect".into()))?;
48    let resolved = store
49        .deep_resolve(rect_obj)
50        .map_err(|e| crate::error::DocError::Parser(e.to_string()))?;
51    let arr = resolved
52        .as_array()
53        .ok_or(crate::error::DocError::UnexpectedType)?;
54    let get = |idx: usize| -> f32 {
55        arr.get(idx)
56            .and_then(|o| o.as_f64())
57            .map(|f| f as f32)
58            .unwrap_or(0.0)
59    };
60    let rect = [get(0), get(1), get(2), get(3)];
61
62    // /Dest
63    let destination = dict
64        .get(&Name::dest())
65        .and_then(|o| parse_destination(o, store).ok());
66
67    // /A
68    let action = dict
69        .get(&Name::a())
70        .and_then(|o| parse_action(o, store).ok());
71
72    Ok(Link {
73        rect,
74        destination,
75        action,
76    })
77}
78
79#[cfg(test)]
80mod tests {
81    use super::*;
82    use crate::action::Action;
83    use crate::destination::Destination;
84    use rpdfium_core::PdfString;
85
86    fn build_store() -> ObjectStore<Vec<u8>> {
87        let pdf = build_minimal_pdf();
88        ObjectStore::open(pdf, rpdfium_core::ParsingMode::Lenient).unwrap()
89    }
90
91    fn build_minimal_pdf() -> Vec<u8> {
92        let mut pdf = Vec::new();
93        pdf.extend_from_slice(b"%PDF-1.4\n");
94        let obj1_offset = pdf.len();
95        pdf.extend_from_slice(b"1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n");
96        let obj2_offset = pdf.len();
97        pdf.extend_from_slice(b"2 0 obj\n<< /Type /Pages /Kids [] /Count 0 >>\nendobj\n");
98        let xref_offset = pdf.len();
99        pdf.extend_from_slice(b"xref\n0 3\n");
100        pdf.extend_from_slice(b"0000000000 65535 f \r\n");
101        pdf.extend_from_slice(format!("{:010} 00000 n \r\n", obj1_offset).as_bytes());
102        pdf.extend_from_slice(format!("{:010} 00000 n \r\n", obj2_offset).as_bytes());
103        pdf.extend_from_slice(b"trailer\n<< /Size 3 /Root 1 0 R >>\n");
104        pdf.extend_from_slice(format!("startxref\n{}\n%%EOF", xref_offset).as_bytes());
105        pdf
106    }
107
108    #[test]
109    fn test_parse_link_with_rect_only() {
110        let store = build_store();
111        let mut dict = HashMap::new();
112        dict.insert(
113            Name::rect(),
114            Object::Array(vec![
115                Object::Real(10.0),
116                Object::Real(20.0),
117                Object::Real(100.0),
118                Object::Real(50.0),
119            ]),
120        );
121        let link = parse_link(&dict, &store).unwrap();
122        assert_eq!(link.rect, [10.0, 20.0, 100.0, 50.0]);
123        assert!(link.destination.is_none());
124        assert!(link.action.is_none());
125    }
126
127    #[test]
128    fn test_parse_link_with_dest() {
129        let store = build_store();
130        let mut dict = HashMap::new();
131        dict.insert(
132            Name::rect(),
133            Object::Array(vec![
134                Object::Real(0.0),
135                Object::Real(0.0),
136                Object::Real(200.0),
137                Object::Real(20.0),
138            ]),
139        );
140        dict.insert(
141            Name::dest(),
142            Object::String(PdfString::from_bytes(b"page2".to_vec())),
143        );
144        let link = parse_link(&dict, &store).unwrap();
145        match &link.destination {
146            Some(Destination::Named(n)) => assert_eq!(n, "page2"),
147            _ => panic!("expected named destination"),
148        }
149        assert!(link.action.is_none());
150    }
151
152    #[test]
153    fn test_parse_link_with_uri_action() {
154        let store = build_store();
155        let mut action_dict = HashMap::new();
156        action_dict.insert(Name::s(), Object::Name(Name::from("URI")));
157        action_dict.insert(
158            Name::uri(),
159            Object::String(PdfString::from_bytes(b"https://example.com".to_vec())),
160        );
161
162        let mut dict = HashMap::new();
163        dict.insert(
164            Name::rect(),
165            Object::Array(vec![
166                Object::Real(0.0),
167                Object::Real(0.0),
168                Object::Real(100.0),
169                Object::Real(20.0),
170            ]),
171        );
172        dict.insert(Name::a(), Object::Dictionary(action_dict));
173
174        let link = parse_link(&dict, &store).unwrap();
175        assert!(link.destination.is_none());
176        match &link.action {
177            Some(Action::Uri(uri)) => assert_eq!(uri, "https://example.com"),
178            _ => panic!("expected URI action"),
179        }
180    }
181
182    #[test]
183    fn test_parse_link_missing_rect_returns_error() {
184        let store = build_store();
185        let dict = HashMap::new();
186        let result = parse_link(&dict, &store);
187        assert!(result.is_err());
188    }
189}