1use std::collections::{HashMap, HashSet};
20
21use zpdf_core::{ObjectId, PdfDict, PdfObject};
22use zpdf_parser::PdfFile;
23
24use crate::forms::pdf_string_to_unicode;
25use crate::obj_util::{
26 catalog_dict, resolve_array, resolve_dict, resolve_name, resolve_number, text,
27};
28use crate::Catalog;
29
30const MAX_NAME_TREE_DEPTH: usize = 64;
32const MAX_NAME_TREE_NODES: usize = 100_000;
35const MAX_DEST_INDIRECTION: usize = 8;
38const MAX_NAMED_DEST_ENTRIES: usize = 200_000;
44
45#[derive(Debug, Clone, Copy, PartialEq)]
49pub enum DestView {
50 Xyz {
53 left: Option<f32>,
54 top: Option<f32>,
55 zoom: Option<f32>,
56 },
57 Fit,
59 FitH { top: Option<f32> },
61 FitV { left: Option<f32> },
63 FitR {
65 left: f32,
66 bottom: f32,
67 right: f32,
68 top: f32,
69 },
70 FitB,
72 FitBH { top: Option<f32> },
74 FitBV { left: Option<f32> },
76 Unknown,
78}
79
80#[derive(Debug, Clone, PartialEq)]
82pub struct Destination {
83 pub page: Option<usize>,
87 pub page_ref: Option<ObjectId>,
91 pub view: DestView,
93}
94
95pub fn resolve_named(file: &PdfFile, catalog: &Catalog, name: &[u8]) -> Option<Destination> {
99 let value = lookup_named_value(file, name)?;
100 resolve_dest_value(file, catalog, &value, 0, None)
101}
102
103pub fn resolve_explicit(file: &PdfFile, catalog: &Catalog, obj: &PdfObject) -> Option<Destination> {
107 resolve_dest_value(file, catalog, obj, 0, None)
108}
109
110pub(crate) fn resolve_link_target(
128 file: &PdfFile,
129 catalog: &Catalog,
130 dict: &PdfDict,
131 named: Option<&HashMap<Vec<u8>, PdfObject>>,
132) -> (Option<Destination>, Option<String>) {
133 if let Some(dest_obj) = dict.get("Dest") {
135 if let Some(d) = resolve_dest_value(file, catalog, dest_obj, 0, named) {
136 return (Some(d), None);
137 }
138 }
139
140 if let Some(action) = resolve_dict(file, dict.get("A")) {
143 match resolve_name(file, action.get("S")).as_deref() {
144 Some("GoTo") => {
145 if let Some(d) = action
146 .get("D")
147 .and_then(|d| resolve_dest_value(file, catalog, d, 0, named))
148 {
149 return (Some(d), None);
150 }
151 }
152 Some("URI") => {
153 if let Some(uri) = uri_string(file, &action) {
154 return (None, Some(uri));
155 }
156 }
157 Some("GoToR") => {
158 if let Some(name) = remote_file_name(file, &action) {
159 return (None, Some(name));
160 }
161 }
162 _ => {}
163 }
164 }
165
166 (None, None)
167}
168
169fn uri_string(file: &PdfFile, action: &PdfDict) -> Option<String> {
171 let value = match action.get("URI")? {
172 PdfObject::String(s) => return Some(decode_ascii(s.as_bytes())),
173 PdfObject::Ref(r) => file.resolve(*r).ok()?,
174 _ => return None,
175 };
176 match value {
177 PdfObject::String(s) => Some(decode_ascii(s.as_bytes())),
178 _ => None,
179 }
180}
181
182fn remote_file_name(file: &PdfFile, action: &PdfDict) -> Option<String> {
189 match action.get("F")? {
190 PdfObject::String(s) => Some(pdf_string_to_unicode(s.as_bytes())),
191 PdfObject::Dict(d) => filespec_name(file, d),
192 PdfObject::Ref(r) => match file.resolve(*r).ok()? {
193 PdfObject::String(s) => Some(pdf_string_to_unicode(s.as_bytes())),
194 PdfObject::Dict(d) => filespec_name(file, &d),
195 _ => None,
196 },
197 _ => None,
198 }
199}
200
201fn filespec_name(file: &PdfFile, dict: &PdfDict) -> Option<String> {
203 text(file, dict, "UF").or_else(|| text(file, dict, "F"))
204}
205
206fn decode_ascii(bytes: &[u8]) -> String {
209 bytes.iter().map(|&b| b as char).collect()
210}
211
212fn resolve_dest_value(
218 file: &PdfFile,
219 catalog: &Catalog,
220 obj: &PdfObject,
221 depth: usize,
222 named: Option<&HashMap<Vec<u8>, PdfObject>>,
223) -> Option<Destination> {
224 if depth > MAX_DEST_INDIRECTION {
225 return None;
226 }
227 match obj {
228 PdfObject::Array(arr) => parse_explicit_array(file, catalog, arr),
229 PdfObject::Ref(r) => {
230 let resolved = file.resolve(*r).ok()?;
231 resolve_dest_value(file, catalog, &resolved, depth + 1, named)
232 }
233 PdfObject::Name(n) => {
235 let v = lookup_in(file, named, n.as_str().as_bytes())?;
236 resolve_dest_value(file, catalog, &v, depth + 1, named)
237 }
238 PdfObject::String(s) => {
239 let v = lookup_in(file, named, s.as_bytes())?;
240 resolve_dest_value(file, catalog, &v, depth + 1, named)
241 }
242 PdfObject::Dict(d) => {
245 let inner = d.get("D")?;
246 resolve_dest_value(file, catalog, inner, depth + 1, named)
247 }
248 _ => None,
249 }
250}
251
252fn lookup_in(
256 file: &PdfFile,
257 named: Option<&HashMap<Vec<u8>, PdfObject>>,
258 name: &[u8],
259) -> Option<PdfObject> {
260 match named {
261 Some(map) => map.get(name).cloned(),
262 None => lookup_named_value(file, name),
263 }
264}
265
266fn parse_explicit_array(
270 file: &PdfFile,
271 catalog: &Catalog,
272 arr: &[PdfObject],
273) -> Option<Destination> {
274 if arr.is_empty() {
275 return None;
276 }
277 let (page, page_ref) = match &arr[0] {
278 PdfObject::Ref(r) => match catalog.page_index_of(*r) {
279 Some(idx) => (Some(idx), Some(*r)),
280 None => match file.resolve(*r).ok().and_then(|o| page_number(&o, catalog)) {
284 Some(idx) => (Some(idx), None),
285 None => (None, Some(*r)),
286 },
287 },
288 other => (page_number(other, catalog), None),
291 };
292
293 let view = parse_view(file, arr);
294 Some(Destination {
295 page,
296 page_ref,
297 view,
298 })
299}
300
301fn page_number(obj: &PdfObject, catalog: &Catalog) -> Option<usize> {
306 let n = match obj {
307 PdfObject::Integer(n) if *n >= 0 => *n as usize,
308 PdfObject::Real(f) if f.is_finite() && *f >= 0.0 && f.fract() == 0.0 => *f as usize,
309 _ => return None,
310 };
311 (n < catalog.page_count).then_some(n)
312}
313
314fn parse_view(file: &PdfFile, arr: &[PdfObject]) -> DestView {
316 let num = |i: usize| resolve_number(file, arr.get(i));
318 match resolve_name(file, arr.get(1)).as_deref() {
319 Some("XYZ") => DestView::Xyz {
320 left: num(2),
321 top: num(3),
322 zoom: num(4).filter(|&z| z != 0.0),
324 },
325 Some("Fit") => DestView::Fit,
326 Some("FitH") => DestView::FitH { top: num(2) },
327 Some("FitV") => DestView::FitV { left: num(2) },
328 Some("FitR") => DestView::FitR {
329 left: num(2).unwrap_or(0.0),
330 bottom: num(3).unwrap_or(0.0),
331 right: num(4).unwrap_or(0.0),
332 top: num(5).unwrap_or(0.0),
333 },
334 Some("FitB") => DestView::FitB,
335 Some("FitBH") => DestView::FitBH { top: num(2) },
336 Some("FitBV") => DestView::FitBV { left: num(2) },
337 _ => DestView::Unknown,
338 }
339}
340
341fn lookup_named_value(file: &PdfFile, name: &[u8]) -> Option<PdfObject> {
344 let root = catalog_dict(file)?;
345
346 if let Some(names) = resolve_dict(file, root.get("Names")) {
348 if let Some(tree) = resolve_dict(file, names.get("Dests")) {
349 let mut visited = HashSet::new();
350 if let Some(PdfObject::Ref(id)) = names.get("Dests") {
352 visited.insert(*id);
353 }
354 let mut budget = MAX_NAME_TREE_NODES;
355 if let Some(v) = name_tree_lookup(file, &tree, name, 0, &mut visited, &mut budget) {
356 return Some(v);
357 }
358 }
359 }
360
361 if let Some(dests) = resolve_dict(file, root.get("Dests")) {
363 if let Ok(key) = std::str::from_utf8(name) {
364 if let Some(v) = dests.get(key) {
365 return Some(v.clone());
366 }
367 }
368 }
369
370 None
371}
372
373fn name_tree_lookup(
378 file: &PdfFile,
379 node: &PdfDict,
380 key: &[u8],
381 depth: usize,
382 visited: &mut HashSet<ObjectId>,
383 budget: &mut usize,
384) -> Option<PdfObject> {
385 if depth > MAX_NAME_TREE_DEPTH || *budget == 0 {
386 return None;
387 }
388 *budget -= 1;
389
390 if let Some(names) = resolve_array(file, node.get("Names")) {
392 let mut i = 0;
393 while i + 1 < names.len() {
394 if let PdfObject::String(s) = &names[i] {
395 if s.as_bytes() == key {
396 return Some(names[i + 1].clone());
397 }
398 }
399 i += 2;
400 }
401 }
402
403 if let Some(kids) = resolve_array(file, node.get("Kids")) {
405 for kid in &kids {
406 if *budget == 0 {
407 return None;
408 }
409 let kid_dict = match kid {
410 PdfObject::Ref(r) => {
411 if !visited.insert(*r) {
412 continue;
413 }
414 resolve_dict(file, Some(kid))
415 }
416 PdfObject::Dict(_) => resolve_dict(file, Some(kid)),
417 _ => None,
418 };
419 let Some(d) = kid_dict else { continue };
420 if !limits_may_contain(file, &d, key) {
421 continue;
422 }
423 if let Some(v) = name_tree_lookup(file, &d, key, depth + 1, visited, budget) {
424 return Some(v);
425 }
426 }
427 }
428
429 None
430}
431
432fn limits_may_contain(file: &PdfFile, node: &PdfDict, key: &[u8]) -> bool {
436 let Some(limits) = resolve_array(file, node.get("Limits")) else {
437 return true;
438 };
439 let (Some(PdfObject::String(lo)), Some(PdfObject::String(hi))) =
440 (limits.first(), limits.get(1))
441 else {
442 return true;
443 };
444 key >= lo.as_bytes() && key <= hi.as_bytes()
446}
447
448pub(crate) fn collect_named_dests(file: &PdfFile) -> HashMap<Vec<u8>, PdfObject> {
457 let mut map = HashMap::new();
458 let Some(root) = catalog_dict(file) else {
459 return map;
460 };
461 let mut budget = MAX_NAMED_DEST_ENTRIES;
462
463 if let Some(names) = resolve_dict(file, root.get("Names")) {
465 if let Some(tree) = resolve_dict(file, names.get("Dests")) {
466 let mut visited = HashSet::new();
467 if let Some(PdfObject::Ref(id)) = names.get("Dests") {
469 visited.insert(*id);
470 }
471 collect_name_tree(file, &tree, 0, &mut visited, &mut budget, &mut map);
472 }
473 }
474
475 if let Some(dests) = resolve_dict(file, root.get("Dests")) {
478 for (k, v) in &dests.0 {
479 if budget == 0 {
480 break;
481 }
482 budget -= 1;
483 map.entry(k.as_str().as_bytes().to_vec())
484 .or_insert_with(|| v.clone());
485 }
486 }
487
488 map
489}
490
491fn collect_name_tree(
497 file: &PdfFile,
498 node: &PdfDict,
499 depth: usize,
500 visited: &mut HashSet<ObjectId>,
501 budget: &mut usize,
502 map: &mut HashMap<Vec<u8>, PdfObject>,
503) {
504 if depth > MAX_NAME_TREE_DEPTH || *budget == 0 {
505 return;
506 }
507 *budget -= 1;
508
509 if let Some(names) = resolve_array(file, node.get("Names")) {
511 let mut i = 0;
512 while i + 1 < names.len() {
513 if *budget == 0 {
514 return;
515 }
516 if let PdfObject::String(s) = &names[i] {
517 map.entry(s.as_bytes().to_vec())
518 .or_insert_with(|| names[i + 1].clone());
519 *budget -= 1;
520 }
521 i += 2;
522 }
523 }
524
525 if let Some(kids) = resolve_array(file, node.get("Kids")) {
527 for kid in &kids {
528 if *budget == 0 {
529 return;
530 }
531 let kid_dict = match kid {
532 PdfObject::Ref(r) => {
533 if !visited.insert(*r) {
534 continue;
535 }
536 resolve_dict(file, Some(kid))
537 }
538 PdfObject::Dict(_) => resolve_dict(file, Some(kid)),
539 _ => None,
540 };
541 let Some(d) = kid_dict else { continue };
542 collect_name_tree(file, &d, depth + 1, visited, budget, map);
543 }
544 }
545}
546
547#[cfg(test)]
548mod tests {
549 use super::*;
550 use crate::test_util::build_pdf;
551 use crate::PdfDocument;
552
553 fn open(objects: &[&str]) -> PdfDocument {
554 PdfDocument::open(build_pdf(objects)).expect("open pdf")
555 }
556
557 const PAGES2: &str = "<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 >>";
559 const PAGE_A: &str = "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] >>";
560 const PAGE_B: &str = "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] >>";
561
562 #[test]
563 fn named_dest_via_name_tree_xyz() {
564 let doc = open(&[
565 "<< /Type /Catalog /Pages 2 0 R /Names << /Dests 5 0 R >> >>",
566 PAGES2,
567 PAGE_A,
568 PAGE_B,
569 "<< /Names [ (chap2) << /D [4 0 R /XYZ 0 800 0] >> ] >>",
570 ]);
571 let d = doc.named_destination(b"chap2").expect("resolve");
572 assert_eq!(d.page, Some(1)); assert_eq!(d.page_ref, Some(zpdf_core::ObjectId(4, 0)));
574 assert_eq!(
575 d.view,
576 DestView::Xyz {
577 left: Some(0.0),
578 top: Some(800.0),
579 zoom: None, }
581 );
582 }
583
584 #[test]
585 fn named_dest_via_legacy_root_dests_dict() {
586 let doc = open(&[
588 "<< /Type /Catalog /Pages 2 0 R /Dests 5 0 R >>",
589 PAGES2,
590 PAGE_A,
591 PAGE_B,
592 "<< /intro [3 0 R /Fit] >>",
593 ]);
594 let d = doc.named_destination(b"intro").expect("resolve");
595 assert_eq!(d.page, Some(0));
596 assert_eq!(d.view, DestView::Fit);
597 }
598
599 #[test]
600 fn named_dest_bare_array_value() {
601 let doc = open(&[
603 "<< /Type /Catalog /Pages 2 0 R /Names << /Dests 5 0 R >> >>",
604 PAGES2,
605 PAGE_A,
606 PAGE_B,
607 "<< /Names [ (x) [4 0 R /FitH 750] ] >>",
608 ]);
609 let d = doc.named_destination(b"x").expect("resolve");
610 assert_eq!(d.page, Some(1));
611 assert_eq!(d.view, DestView::FitH { top: Some(750.0) });
612 }
613
614 #[test]
615 fn name_tree_interior_kids_with_limits() {
616 let doc = open(&[
617 "<< /Type /Catalog /Pages 2 0 R /Names << /Dests 5 0 R >> >>",
618 PAGES2,
619 PAGE_A,
620 PAGE_B,
621 "<< /Kids [6 0 R 7 0 R] >>",
622 "<< /Limits [(a) (m)] /Names [ (b) [3 0 R /Fit] ] >>",
623 "<< /Limits [(n) (z)] /Names [ (y) [4 0 R /Fit] ] >>",
624 ]);
625 let d = doc.named_destination(b"y").expect("resolve");
627 assert_eq!(d.page, Some(1));
628 }
629
630 #[test]
631 fn explicit_fitr_all_coords() {
632 let doc = open(&["<< /Type /Catalog /Pages 2 0 R >>", PAGES2, PAGE_A, PAGE_B]);
633 let arr = PdfObject::Array(vec![
634 PdfObject::Ref(zpdf_core::ObjectId(3, 0)),
635 PdfObject::Name(zpdf_core::PdfName("FitR".into())),
636 PdfObject::Integer(10),
637 PdfObject::Integer(20),
638 PdfObject::Integer(30),
639 PdfObject::Integer(40),
640 ]);
641 let d = doc.resolve_destination(&arr).expect("resolve");
642 assert_eq!(d.page, Some(0));
643 assert_eq!(
644 d.view,
645 DestView::FitR {
646 left: 10.0,
647 bottom: 20.0,
648 right: 30.0,
649 top: 40.0,
650 }
651 );
652 }
653
654 #[test]
655 fn explicit_page_number_for_remote_dest() {
656 let doc = open(&["<< /Type /Catalog /Pages 2 0 R >>", PAGES2, PAGE_A, PAGE_B]);
659 let arr = PdfObject::Array(vec![
660 PdfObject::Integer(1),
661 PdfObject::Name(zpdf_core::PdfName("Fit".into())),
662 ]);
663 let d = doc.resolve_destination(&arr).expect("resolve");
664 assert_eq!(d.page, Some(1));
665 assert_eq!(d.page_ref, None);
666 }
667
668 #[test]
669 fn out_of_range_page_number_is_none() {
670 let doc = open(&["<< /Type /Catalog /Pages 2 0 R >>", PAGES2, PAGE_A, PAGE_B]);
673 let arr = PdfObject::Array(vec![
675 PdfObject::Integer(500),
676 PdfObject::Name(zpdf_core::PdfName("Fit".into())),
677 ]);
678 assert_eq!(doc.resolve_destination(&arr).unwrap().page, None);
679 let big = PdfObject::Array(vec![
681 PdfObject::Real(1e20),
682 PdfObject::Name(zpdf_core::PdfName("Fit".into())),
683 ]);
684 assert_eq!(doc.resolve_destination(&big).unwrap().page, None);
685 }
686
687 #[test]
688 fn indirectly_encoded_page_number_resolves() {
689 let doc = open(&[
692 "<< /Type /Catalog /Pages 2 0 R >>",
693 PAGES2,
694 PAGE_A,
695 PAGE_B,
696 "1", ]);
698 let arr = PdfObject::Array(vec![
699 PdfObject::Ref(zpdf_core::ObjectId(5, 0)),
700 PdfObject::Name(zpdf_core::PdfName("Fit".into())),
701 ]);
702 let d = doc.resolve_destination(&arr).expect("resolve");
703 assert_eq!(d.page, Some(1));
704 assert_eq!(d.page_ref, None);
705 }
706
707 #[test]
708 fn page_ref_not_in_tree_yields_none_page_but_keeps_view() {
709 let doc = open(&["<< /Type /Catalog /Pages 2 0 R >>", PAGES2, PAGE_A, PAGE_B]);
710 let arr = PdfObject::Array(vec![
711 PdfObject::Ref(zpdf_core::ObjectId(999, 0)), PdfObject::Name(zpdf_core::PdfName("Fit".into())),
713 ]);
714 let d = doc.resolve_destination(&arr).expect("resolve");
715 assert_eq!(d.page, None);
716 assert_eq!(d.page_ref, Some(zpdf_core::ObjectId(999, 0)));
717 assert_eq!(d.view, DestView::Fit);
718 }
719
720 #[test]
721 fn unknown_name_resolves_to_none() {
722 let doc = open(&[
723 "<< /Type /Catalog /Pages 2 0 R /Names << /Dests 5 0 R >> >>",
724 PAGES2,
725 PAGE_A,
726 PAGE_B,
727 "<< /Names [ (real) [3 0 R /Fit] ] >>",
728 ]);
729 assert!(doc.named_destination(b"missing").is_none());
730 }
731
732 #[test]
733 fn self_referential_named_dest_terminates() {
734 let doc = open(&[
736 "<< /Type /Catalog /Pages 2 0 R /Names << /Dests 5 0 R >> >>",
737 PAGES2,
738 PAGE_A,
739 PAGE_B,
740 "<< /Names [ (loop) (loop) ] >>",
741 ]);
742 assert!(doc.named_destination(b"loop").is_none());
743 }
744}