1use 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#[derive(Debug, Clone)]
23pub struct PdfLink {
24 pub source_page_index: usize,
27 pub rect: [f32; 4],
30 pub target: Option<PdfLinkTarget>,
34}
35
36#[derive(Debug, Clone)]
38pub enum PdfLinkTarget {
39 Internal(OutlineDestination),
41 Uri(String),
43 Named(String),
47}
48
49pub fn links(reader: &mut DocumentReader<'_>) -> Result<Vec<PdfLink>, PdfError> {
53 let page_index_map = build_page_index_map(reader)?;
54 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 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 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 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}