1use crate::metadata::pdf_string_to_rust_pub;
19use crate::objects::{PdfDict, PdfObj};
20use crate::page_tree::PageInfo;
21use crate::resolver::Resolver;
22
23#[derive(Debug, Clone, PartialEq)]
25#[non_exhaustive]
26pub enum Destination {
27 PageView {
30 page: Option<usize>,
34 view: ViewSpec,
35 },
36 NamedDest(String),
40}
41
42#[derive(Debug, Clone, Copy, PartialEq)]
47#[non_exhaustive]
48pub enum ViewSpec {
49 Xyz {
53 x: Option<f64>,
54 y: Option<f64>,
55 zoom: Option<f64>,
56 },
57 Fit,
59 FitH { y: Option<f64> },
61 FitV { x: Option<f64> },
63 FitR {
65 left: f64,
66 bottom: f64,
67 right: f64,
68 top: f64,
69 },
70 FitB,
72 FitBH { y: Option<f64> },
74 FitBV { x: Option<f64> },
76}
77
78impl Default for ViewSpec {
79 fn default() -> Self {
80 ViewSpec::Xyz {
81 x: None,
82 y: None,
83 zoom: None,
84 }
85 }
86}
87
88#[derive(Debug, Clone, PartialEq)]
95#[non_exhaustive]
96pub enum Action {
97 GoTo(Destination),
99 GoToR {
101 filename: String,
102 dest: Destination,
103 new_window: Option<bool>,
104 },
105 GoToE {
107 target: String,
108 dest: Destination,
109 new_window: Option<bool>,
110 },
111 Launch {
113 filename: String,
114 new_window: Option<bool>,
115 },
116 Uri { uri: String, is_map: bool },
118 Named(String),
121 JavaScript(String),
124 SubmitForm {
126 url: String,
127 fields: Vec<String>,
128 flags: u32,
129 },
130 ResetForm { fields: Vec<String>, flags: u32 },
132 Hide { targets: Vec<String>, hide: bool },
134 Sound,
136 Movie,
138 Thread { target: Option<String> },
140 Other { subtype: String },
143}
144
145pub fn parse_destination(
155 resolver: &Resolver,
156 pages: &[PageInfo],
157 obj: &PdfObj,
158) -> Option<Destination> {
159 let resolved = resolver.deref(obj).ok()?;
160
161 if let Some(dict) = resolved.as_dict()
163 && let Some(d) = dict.get(b"D")
164 {
165 return parse_destination(resolver, pages, d);
166 }
167
168 if let Some(name) = resolved.as_name() {
170 return Some(Destination::NamedDest(
171 String::from_utf8_lossy(name).into_owned(),
172 ));
173 }
174 if let Some(s) = resolved.as_str() {
175 return Some(Destination::NamedDest(
176 String::from_utf8_lossy(s).into_owned(),
177 ));
178 }
179
180 if let Some(arr) = resolved.as_array() {
182 return Some(parse_explicit_destination(pages, arr));
183 }
184
185 None
186}
187
188pub fn parse_explicit_destination(pages: &[PageInfo], arr: &[PdfObj]) -> Destination {
194 let page = arr.first().and_then(|p| {
195 if let Some((num, _)) = p.as_ref() {
196 pages.iter().position(|info| info.obj_num == num)
197 } else {
198 p.as_int().map(|i| i as usize)
199 }
200 });
201 let mode = arr.get(1).and_then(|m| m.as_name()).unwrap_or(b"XYZ");
202 let view = parse_view_spec(mode, &arr[2.min(arr.len())..]);
203 Destination::PageView { page, view }
204}
205
206pub fn parse_view_spec(mode: &[u8], args: &[PdfObj]) -> ViewSpec {
208 let num = |i: usize| args.get(i).and_then(|o| o.as_f64());
209 match mode {
210 b"XYZ" => ViewSpec::Xyz {
211 x: num(0),
212 y: num(1),
213 zoom: num(2).filter(|&z| z > 0.0),
214 },
215 b"Fit" => ViewSpec::Fit,
216 b"FitH" => ViewSpec::FitH { y: num(0) },
217 b"FitV" => ViewSpec::FitV { x: num(0) },
218 b"FitR" => ViewSpec::FitR {
219 left: num(0).unwrap_or(0.0),
220 bottom: num(1).unwrap_or(0.0),
221 right: num(2).unwrap_or(0.0),
222 top: num(3).unwrap_or(0.0),
223 },
224 b"FitB" => ViewSpec::FitB,
225 b"FitBH" => ViewSpec::FitBH { y: num(0) },
226 b"FitBV" => ViewSpec::FitBV { x: num(0) },
227 _ => ViewSpec::default(),
228 }
229}
230
231pub fn parse_action(resolver: &Resolver, pages: &[PageInfo], obj: &PdfObj) -> Option<Action> {
237 let resolved = resolver.deref(obj).ok()?;
238 let dict = resolved.as_dict()?;
239 let subtype = dict.get_name(b"S").unwrap_or(b"");
240 Some(match subtype {
241 b"GoTo" => {
242 let dest = dict
243 .get(b"D")
244 .and_then(|d| parse_destination(resolver, pages, d))
245 .unwrap_or(Destination::NamedDest(String::new()));
246 Action::GoTo(dest)
247 }
248 b"GoToR" => Action::GoToR {
249 filename: parse_file_spec(resolver, dict.get(b"F")).unwrap_or_default(),
250 dest: dict
251 .get(b"D")
252 .and_then(|d| parse_destination(resolver, &[], d))
253 .unwrap_or(Destination::NamedDest(String::new())),
254 new_window: dict.get(b"NewWindow").and_then(as_bool),
255 },
256 b"GoToE" => Action::GoToE {
257 target: dict
258 .get(b"T")
259 .and_then(pdf_string_to_rust_pub)
260 .unwrap_or_default(),
261 dest: dict
262 .get(b"D")
263 .and_then(|d| parse_destination(resolver, &[], d))
264 .unwrap_or(Destination::NamedDest(String::new())),
265 new_window: dict.get(b"NewWindow").and_then(as_bool),
266 },
267 b"Launch" => Action::Launch {
268 filename: parse_file_spec(resolver, dict.get(b"F")).unwrap_or_default(),
269 new_window: dict.get(b"NewWindow").and_then(as_bool),
270 },
271 b"URI" => Action::Uri {
272 uri: dict
273 .get(b"URI")
274 .and_then(pdf_string_to_rust_pub)
275 .unwrap_or_default(),
276 is_map: dict.get(b"IsMap").and_then(as_bool).unwrap_or(false),
277 },
278 b"Named" => Action::Named(
279 dict.get_name(b"N")
280 .map(|n| String::from_utf8_lossy(n).into_owned())
281 .unwrap_or_default(),
282 ),
283 b"JavaScript" => {
284 Action::JavaScript(dict.get(b"JS").and_then(read_js_source).unwrap_or_default())
285 }
286 b"SubmitForm" => Action::SubmitForm {
287 url: parse_file_spec(resolver, dict.get(b"F")).unwrap_or_default(),
288 fields: parse_field_name_list(resolver, dict.get(b"Fields")),
289 flags: dict.get_int(b"Flags").unwrap_or(0) as u32,
290 },
291 b"ResetForm" => Action::ResetForm {
292 fields: parse_field_name_list(resolver, dict.get(b"Fields")),
293 flags: dict.get_int(b"Flags").unwrap_or(0) as u32,
294 },
295 b"Hide" => Action::Hide {
296 targets: parse_field_name_list(resolver, dict.get(b"T")),
297 hide: dict.get(b"H").and_then(as_bool).unwrap_or(true),
298 },
299 b"Sound" => Action::Sound,
300 b"Movie" => Action::Movie,
301 b"Thread" => Action::Thread {
302 target: dict.get(b"T").and_then(pdf_string_to_rust_pub),
303 },
304 other => Action::Other {
305 subtype: String::from_utf8_lossy(other).into_owned(),
306 },
307 })
308}
309
310fn as_bool(obj: &PdfObj) -> Option<bool> {
311 match obj {
312 PdfObj::Bool(b) => Some(*b),
313 _ => None,
314 }
315}
316
317fn read_js_source(obj: &PdfObj) -> Option<String> {
321 if let Some(s) = obj.as_str() {
322 return Some(crate::metadata::decode_pdf_text_string_pub(s));
323 }
324 None
325}
326
327fn parse_file_spec(resolver: &Resolver, obj: Option<&PdfObj>) -> Option<String> {
330 let obj = obj?;
331 let resolved = resolver.deref(obj).ok()?;
332 if let Some(s) = resolved.as_str() {
333 return Some(crate::metadata::decode_pdf_text_string_pub(s));
334 }
335 if let Some(d) = resolved.as_dict() {
336 if let Some(uf) = d.get(b"UF").and_then(pdf_string_to_rust_pub) {
338 return Some(uf);
339 }
340 if let Some(f) = d.get(b"F").and_then(pdf_string_to_rust_pub) {
341 return Some(f);
342 }
343 }
344 None
345}
346
347fn parse_field_name_list(resolver: &Resolver, obj: Option<&PdfObj>) -> Vec<String> {
348 let Some(obj) = obj else {
349 return Vec::new();
350 };
351 let Ok(resolved) = resolver.deref(obj) else {
352 return Vec::new();
353 };
354 let Some(arr) = resolved.as_array() else {
355 return Vec::new();
356 };
357 arr.iter()
358 .filter_map(|o| o.as_str().map(crate::metadata::decode_pdf_text_string_pub))
359 .collect()
360}
361
362pub fn parse_named_destinations(
373 resolver: &Resolver,
374 pages: &[PageInfo],
375) -> std::collections::HashMap<String, Destination> {
376 use std::collections::HashMap;
377
378 let mut map: HashMap<String, Destination> = HashMap::new();
379
380 let catalog = match catalog_dict_for_dests(resolver) {
381 Some(c) => c,
382 None => return map,
383 };
384
385 if let Some(names_obj) = catalog.get(b"Names")
387 && let Ok(names_dict_obj) = resolver.deref(names_obj)
388 && let Some(names_dict) = names_dict_obj.as_dict()
389 && let Some(dests_root) = names_dict.get(b"Dests")
390 {
391 let tree = crate::name_tree::walk_name_tree(resolver, dests_root, |r, val| {
392 parse_destination(r, pages, val)
393 });
394 map.extend(tree);
395 }
396
397 if let Some(dests_obj) = catalog.get(b"Dests")
399 && let Ok(dests) = resolver.deref(dests_obj)
400 && let Some(dests_dict) = dests.as_dict()
401 {
402 for (key, val) in dests_dict.entries() {
403 if let Some(d) = parse_destination(resolver, pages, val) {
404 let k = String::from_utf8_lossy(key).into_owned();
405 map.insert(k, d);
406 }
407 }
408 }
409
410 map
411}
412
413fn catalog_dict_for_dests(resolver: &Resolver) -> Option<PdfDict> {
414 if let Some((num, gen_num)) = resolver.trailer().get_ref(b"Root")
415 && let Ok(obj) = resolver.resolve(num, gen_num)
416 && let Some(dict) = obj.as_dict()
417 {
418 return Some(dict.clone());
419 }
420 crate::find_catalog(resolver).and_then(|obj| obj.as_dict().cloned())
421}
422
423#[cfg(test)]
424mod tests {
425 use super::*;
426
427 fn page_info(obj_num: u32) -> PageInfo {
428 PageInfo {
429 obj_num,
430 media_box: [0.0, 0.0, 612.0, 792.0],
431 crop_box: [0.0, 0.0, 612.0, 792.0],
432 rotate: 0,
433 resources: PdfDict::new(),
434 contents: vec![],
435 annots: vec![],
436 }
437 }
438
439 #[test]
440 fn view_spec_xyz_with_some_nones() {
441 let v = parse_view_spec(
442 b"XYZ",
443 &[PdfObj::Real(72.0), PdfObj::Null, PdfObj::Real(1.5)],
444 );
445 match v {
446 ViewSpec::Xyz { x, y, zoom } => {
447 assert_eq!(x, Some(72.0));
448 assert_eq!(y, None);
449 assert_eq!(zoom, Some(1.5));
450 }
451 _ => panic!("expected XYZ"),
452 }
453 }
454
455 #[test]
456 fn view_spec_xyz_zero_zoom_becomes_none() {
457 let v = parse_view_spec(
458 b"XYZ",
459 &[PdfObj::Real(0.0), PdfObj::Real(0.0), PdfObj::Real(0.0)],
460 );
461 match v {
462 ViewSpec::Xyz { zoom, .. } => assert_eq!(zoom, None),
463 _ => panic!("expected XYZ"),
464 }
465 }
466
467 #[test]
468 fn view_spec_fit_variants() {
469 assert_eq!(parse_view_spec(b"Fit", &[]), ViewSpec::Fit);
470 assert_eq!(parse_view_spec(b"FitB", &[]), ViewSpec::FitB);
471 assert_eq!(
472 parse_view_spec(b"FitH", &[PdfObj::Real(700.0)]),
473 ViewSpec::FitH { y: Some(700.0) }
474 );
475 assert_eq!(
476 parse_view_spec(
477 b"FitR",
478 &[
479 PdfObj::Real(0.0),
480 PdfObj::Real(0.0),
481 PdfObj::Real(100.0),
482 PdfObj::Real(200.0),
483 ]
484 ),
485 ViewSpec::FitR {
486 left: 0.0,
487 bottom: 0.0,
488 right: 100.0,
489 top: 200.0,
490 }
491 );
492 }
493
494 #[test]
495 fn view_spec_unknown_falls_back_to_xyz_default() {
496 let v = parse_view_spec(b"Bogus", &[]);
497 assert_eq!(v, ViewSpec::default());
498 }
499
500 #[test]
501 fn explicit_destination_with_page_ref_resolves_index() {
502 let pages = vec![page_info(10), page_info(20), page_info(30)];
503 let arr = vec![PdfObj::Ref(20, 0), PdfObj::Name(b"Fit".to_vec())];
504 let d = parse_explicit_destination(&pages, &arr);
505 match d {
506 Destination::PageView { page, view } => {
507 assert_eq!(page, Some(1));
508 assert_eq!(view, ViewSpec::Fit);
509 }
510 _ => panic!("expected PageView"),
511 }
512 }
513
514 #[test]
515 fn explicit_destination_with_unknown_page_ref() {
516 let pages = vec![page_info(10)];
517 let arr = vec![PdfObj::Ref(99, 0), PdfObj::Name(b"Fit".to_vec())];
518 let d = parse_explicit_destination(&pages, &arr);
519 assert!(matches!(d, Destination::PageView { page: None, .. }));
520 }
521}