//! Internal navigation: links that go somewhere. //! //! `destinations.rs` sat at 0% coverage because nothing in the crate called //! it — the module was exported and dead, while the annotation layer read //! only a *name* `/Dest` and ignored `/A << /S /GoTo >>` entirely. The //! corpus contained exactly one internal link and no test asserted where it //! pointed, so a reader that resolved no destination at all passed: a link //! that does nothing looks the same as a link to nowhere. //! //! These tests assert page indices, coordinates and refusals by value. //! //! See `REVIEWS/adr/0018-pdf-destinations.md`. use std::path::PathBuf; use nigig_pdf_document::{ AnnotationAction, AnnotationType, DestinationKind, DestinationTarget, PdfDocument, }; fn corpus_dir() -> PathBuf { PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../tests/corpus") } fn load(relative: &str) -> Vec { let path = corpus_dir().join(relative); std::fs::read(&path).unwrap_or_else(|e| panic!("missing fixture {}: {e}", path.display())) } /// The destination kinds of every link on page 0, in document order. fn link_kinds(data: &[u8]) -> Vec { let mut doc = PdfDocument::parse(data).expect("parses"); doc.page_annotations(0) .expect("annotations") .into_iter() .filter_map(|a| match a.annot_type { AnnotationType::Link { destination, .. } => destination.map(|d| d.kind), _ => None, }) .collect() } /// The action of every link on page 0. fn link_actions(data: &[u8]) -> Vec> { let mut doc = PdfDocument::parse(data).expect("parses"); doc.page_annotations(0) .expect("annotations") .into_iter() .filter(|a| matches!(a.annot_type, AnnotationType::Link { .. })) .map(|a| a.action()) .collect() } // ------------------------------------------------------ the original bug #[test] fn the_internal_link_in_the_links_fixture_actually_navigates() { // This link has existed in the corpus since Phase 6 and did nothing. // `/A << /S /GoTo /D [4 0 R /Fit] >>` where object 4 is page index 1. let actions = link_actions(&load("annotations/links.pdf")); assert_eq!( actions, vec![ Some(AnnotationAction::OpenUri( "https://example.org/docs".to_string() )), Some(AnnotationAction::GoToPage { page_index: 1 }), ], "the GoTo link must resolve to page 1, not to None" ); } // --------------------------------------------------------- explicit fits #[test] fn every_explicit_fit_variant_survives_the_document_layer() { let kinds = link_kinds(&load("destinations/explicit.pdf")); assert_eq!(kinds.len(), 11, "one link per fit, plus the /Dest form"); assert_eq!(kinds[0], DestinationKind::Fit); assert_eq!(kinds[1], DestinationKind::FitB); assert_eq!(kinds[2], DestinationKind::FitH { top: Some(700.0) }); assert_eq!(kinds[3], DestinationKind::FitV { left: Some(50.0) }); assert_eq!(kinds[4], DestinationKind::FitBH { top: Some(700.0) }); assert_eq!(kinds[5], DestinationKind::FitBV { left: Some(50.0) }); assert_eq!( kinds[6], DestinationKind::FitR { left: 10.0, bottom: 20.0, right: 300.0, top: 400.0 } ); assert_eq!( kinds[7], DestinationKind::Xyz { left: Some(72.0), top: Some(720.0), zoom: Some(1.5) } ); // A null coordinate means "leave it unchanged", not zero. assert_eq!( kinds[8], DestinationKind::Xyz { left: None, top: Some(500.0), zoom: None } ); // Zoom 0 means the same as null; the coordinates 0 are real. assert_eq!( kinds[9], DestinationKind::Xyz { left: Some(0.0), top: Some(0.0), zoom: None } ); // The last link writes /Dest on the annotation rather than an action. assert_eq!(kinds[10], DestinationKind::Fit); } #[test] fn every_explicit_link_resolves_to_the_same_target_page() { let actions = link_actions(&load("destinations/explicit.pdf")); assert_eq!(actions.len(), 11); for (i, action) in actions.iter().enumerate() { assert_eq!( action, &Some(AnnotationAction::GoToPage { page_index: 1 }), "link {i} should point at page 1" ); } } #[test] fn a_destination_written_on_the_annotation_works_like_an_action() { // `/Dest [4 0 R /Fit]` and `/A << /S /GoTo /D [4 0 R /Fit] >>` are two // spellings of one thing; only the action form was ever read. let actions = link_actions(&load("destinations/explicit.pdf")); assert_eq!( actions.last().unwrap(), &Some(AnnotationAction::GoToPage { page_index: 1 }) ); } // ------------------------------------------------------------ name trees #[test] fn a_named_destination_is_found_in_a_nested_name_tree() { // The target sits in the *second* leaf under /Kids, so a reader that // only checks the root node finds nothing. let actions = link_actions(&load("destinations/name_tree.pdf")); assert_eq!( actions, vec![ Some(AnnotationAction::GoToPage { page_index: 1 }), Some(AnnotationAction::GoToPage { page_index: 1 }), ], "both the action form and the bare /Dest name must resolve" ); } #[test] fn a_name_tree_entry_may_wrap_its_destination_in_a_d_dictionary() { let data = load("destinations/name_tree.pdf"); let mut doc = PdfDocument::parse(&data).expect("parses"); let dest = doc .lookup_named_destination("chapter2") .expect("chapter2 is defined"); assert_eq!( dest.kind, DestinationKind::Xyz { left: Some(72.0), top: Some(500.0), zoom: None }, "the /D wrapper must be unwrapped, keeping the coordinates" ); } #[test] fn the_legacy_dests_dictionary_is_read_too() { // Pre-1.2 files put destinations in /Root /Dests rather than a name // tree. Supporting only the modern spelling loses every link in them. let actions = link_actions(&load("destinations/legacy_dests_dict.pdf")); assert_eq!( actions, vec![Some(AnnotationAction::GoToPage { page_index: 1 })] ); let data = load("destinations/legacy_dests_dict.pdf"); let mut doc = PdfDocument::parse(&data).expect("parses"); let dest = doc.lookup_named_destination("summary").expect("defined"); assert_eq!(dest.kind, DestinationKind::FitH { top: Some(700.0) }); } #[test] fn named_destinations_are_enumerated_from_both_spellings() { let data = load("destinations/name_tree.pdf"); let mut doc = PdfDocument::parse(&data).expect("parses"); let names: Vec = doc .named_destinations() .into_iter() .map(|(n, _)| n) .collect(); assert_eq!(names, vec!["appendix".to_string(), "chapter2".to_string()]); let data = load("destinations/legacy_dests_dict.pdf"); let mut doc = PdfDocument::parse(&data).expect("parses"); let names: Vec = doc .named_destinations() .into_iter() .map(|(n, _)| n) .collect(); assert_eq!(names, vec!["summary".to_string()]); } // ------------------------------------------------------- refusal by verb #[test] fn a_remote_or_executable_action_is_never_a_local_page_jump() { // /GoToR, /Launch and /JavaScript all carry a /D in this fixture. A // reader that takes /D without checking /S navigates for all three, // which is both wrong and a way to make a dangerous action look like // an innocuous one. let actions = link_actions(&load("destinations/dangerous_actions.pdf")); assert_eq!(actions.len(), 4); for (i, action) in actions.iter().take(3).enumerate() { assert_eq!( action, &None, "annotation {i} carries a non-GoTo verb and must yield no action" ); } } #[test] fn a_link_to_an_undefined_name_resolves_to_nothing_not_to_page_zero() { // Silently landing on page 0 is the worst outcome: it looks like the // link worked. let actions = link_actions(&load("destinations/dangerous_actions.pdf")); assert_eq!( actions[3], Some(AnnotationAction::GoToNamed("nowhere".to_string())), "an unresolvable name stays unresolved rather than becoming page 0" ); } #[test] fn an_unknown_name_lookup_returns_none() { let data = load("destinations/name_tree.pdf"); let mut doc = PdfDocument::parse(&data).expect("parses"); assert!(doc.lookup_named_destination("does-not-exist").is_none()); } // ---------------------------------------------------------- malformed #[test] fn malformed_destinations_do_not_panic_or_mislead() { let data = load("destinations/malformed.pdf"); let mut doc = PdfDocument::parse(&data).expect("parses"); let annots = doc.page_annotations(0).expect("annotations"); assert_eq!(annots.len(), 4); // An empty /D array: no destination at all. assert_eq!(annots[0].action(), None); // A destination pointing at an object that is not a page: parsed, but // not resolvable, so it must not claim a page. match annots[1].action() { None => {} Some(AnnotationAction::GoToDestination(d)) => { assert!( !matches!(d.page, DestinationTarget::PageIndex(_)), "a non-page target must not resolve to an index" ); } other => panic!("unexpected action for a non-page target: {other:?}"), } // An unknown fit keeps the page and degrades the framing. assert_eq!( annots[2].action(), Some(AnnotationAction::GoToPage { page_index: 0 }), "an unknown fit must not cost us the page" ); // A GoTo with no /D. assert_eq!(annots[3].action(), None); } #[test] fn a_cyclic_name_tree_terminates() { // /Kids points back at the root. The assertion is that this returns. let data = load("destinations/malformed.pdf"); let mut doc = PdfDocument::parse(&data).expect("parses"); assert!(doc.lookup_named_destination("anything").is_none()); assert!(doc.named_destinations().is_empty()); } // ------------------------------------------------- corpus-wide invariant #[test] fn no_link_in_the_corpus_resolves_to_a_page_that_does_not_exist() { // The failure this guards against is a destination resolving to an // index past the end of the document, which panics a viewer that // trusts it. let mut stack = vec![corpus_dir()]; let mut checked = 0usize; while let Some(dir) = stack.pop() { let Ok(entries) = std::fs::read_dir(&dir) else { continue; }; for entry in entries.flatten() { let path = entry.path(); if path.is_dir() { stack.push(path); continue; } if path.extension().is_none_or(|e| e != "pdf") { continue; } let Ok(data) = std::fs::read(&path) else { continue; }; let Ok(mut doc) = PdfDocument::parse(&data) else { continue; }; let page_count = doc.page_count(); for index in 0..page_count { let Ok(annots) = doc.page_annotations(index) else { continue; }; for annot in annots { if let Some(AnnotationAction::GoToPage { page_index }) = annot.action() { assert!( page_index < page_count, "{}: a link resolves to page {page_index} of {page_count}", path.display() ); checked += 1; } } } } } assert!( checked >= 14, "expected resolved internal links across the corpus, found {checked}" ); }