use rpdfium_core::Name;
use rpdfium_core::PdfSource;
use rpdfium_core::error::ObjectId;
use rpdfium_parser::{Object, ObjectStore};
use crate::error::{DocError, DocResult};
use crate::name_tree::NameTree;
#[derive(Debug, Clone)]
pub enum Destination {
Page {
page_index: u32,
page_ref: Option<ObjectId>,
fit: PageFit,
},
Named(String),
}
impl Destination {
pub fn resolve_page_index(&self, page_ids: &[ObjectId]) -> Option<usize> {
match self {
Destination::Page {
page_ref: Some(id), ..
} => page_ids.iter().position(|pid| pid == id),
Destination::Page {
page_ref: None,
page_index,
..
} => Some(*page_index as usize),
Destination::Named(_) => None,
}
}
pub fn dest_page_index(&self) -> Option<u32> {
match self {
Destination::Page { page_index, .. } => Some(*page_index),
Destination::Named(_) => None,
}
}
#[inline]
pub fn dest_get_dest_page_index(&self) -> Option<u32> {
self.dest_page_index()
}
#[deprecated(
note = "use `dest_get_dest_page_index()` — matches upstream `FPDFDest_GetDestPageIndex`"
)]
#[inline]
pub fn get_dest_page_index(&self) -> Option<u32> {
self.dest_page_index()
}
#[deprecated(since = "0.1.0", note = "use dest_page_index() instead")]
#[inline]
pub fn page_index(&self) -> Option<u32> {
self.dest_page_index()
}
pub fn view(&self) -> Option<&PageFit> {
match self {
Destination::Page { fit, .. } => Some(fit),
Destination::Named(_) => None,
}
}
#[inline]
pub fn dest_get_view(&self) -> Option<&PageFit> {
self.view()
}
#[deprecated(note = "use `dest_get_view()` — matches upstream `FPDFDest_GetView`")]
#[inline]
pub fn get_view(&self) -> Option<&PageFit> {
self.view()
}
pub fn location_in_page(&self) -> Option<(Option<f32>, Option<f32>, Option<f32>)> {
match self {
Destination::Page {
fit: PageFit::XYZ { left, top, zoom },
..
} => Some((*left, *top, *zoom)),
_ => None,
}
}
#[inline]
pub fn dest_get_location_in_page(&self) -> Option<(Option<f32>, Option<f32>, Option<f32>)> {
self.location_in_page()
}
#[deprecated(
note = "use `dest_get_location_in_page()` — matches upstream `FPDFDest_GetLocationInPage`"
)]
#[inline]
pub fn get_location_in_page(&self) -> Option<(Option<f32>, Option<f32>, Option<f32>)> {
self.location_in_page()
}
pub fn zoom_mode(&self) -> u32 {
match self {
Destination::Page { fit, .. } => match fit {
PageFit::XYZ { .. } => 1,
PageFit::Fit => 2,
PageFit::FitH { .. } => 3,
PageFit::FitV { .. } => 4,
PageFit::FitR { .. } => 5,
PageFit::FitB => 6,
PageFit::FitBH { .. } => 7,
PageFit::FitBV { .. } => 8,
},
Destination::Named(_) => 0,
}
}
#[deprecated(note = "use `zoom_mode()` — there is no public `FPDFDest_GetZoomMode` API")]
#[inline]
pub fn get_zoom_mode(&self) -> u32 {
self.zoom_mode()
}
pub fn param_count(&self) -> usize {
match self {
Destination::Page { fit, .. } => match fit {
PageFit::XYZ { .. } => 3,
PageFit::FitH { .. } | PageFit::FitBH { .. } => 1,
PageFit::FitV { .. } | PageFit::FitBV { .. } => 1,
PageFit::FitR { .. } => 4,
PageFit::Fit | PageFit::FitB => 0,
},
Destination::Named(_) => 0,
}
}
#[inline]
pub fn get_num_params(&self) -> usize {
self.param_count()
}
pub fn param(&self, index: usize) -> Option<f32> {
match self {
Destination::Page { fit, .. } => match fit {
PageFit::XYZ { left, top, zoom } => match index {
0 => *left,
1 => *top,
2 => *zoom,
_ => None,
},
PageFit::FitH { top } | PageFit::FitBH { top } => {
if index == 0 {
*top
} else {
None
}
}
PageFit::FitV { left } | PageFit::FitBV { left } => {
if index == 0 {
*left
} else {
None
}
}
PageFit::FitR {
left,
bottom,
right,
top,
} => match index {
0 => Some(*left),
1 => Some(*bottom),
2 => Some(*right),
3 => Some(*top),
_ => None,
},
PageFit::Fit | PageFit::FitB => None,
},
Destination::Named(_) => None,
}
}
#[inline]
pub fn get_param(&self, index: usize) -> Option<f32> {
self.param(index)
}
pub fn scroll_position_array(&self) -> Vec<f32> {
let count = self.param_count();
(0..count).filter_map(|i| self.param(i)).collect()
}
#[inline]
pub fn get_scroll_position_array(&self) -> Vec<f32> {
self.scroll_position_array()
}
pub fn xyz(&self) -> (Option<f32>, Option<f32>, Option<f32>) {
match self.location_in_page() {
Some((x, y, zoom)) => (x, y, zoom),
None => (None, None, None),
}
}
#[inline]
pub fn get_xyz(&self) -> (Option<f32>, Option<f32>, Option<f32>) {
self.xyz()
}
}
#[derive(Debug, Clone)]
pub enum PageFit {
Fit,
FitH { top: Option<f32> },
FitV { left: Option<f32> },
FitR {
left: f32,
bottom: f32,
right: f32,
top: f32,
},
FitB,
FitBH { top: Option<f32> },
FitBV { left: Option<f32> },
XYZ {
left: Option<f32>,
top: Option<f32>,
zoom: Option<f32>,
},
}
pub fn parse_destination<S: PdfSource>(
obj: &Object,
store: &ObjectStore<S>,
) -> DocResult<Destination> {
let resolved = store
.deep_resolve(obj)
.map_err(|e| DocError::Parser(e.to_string()))?;
if let Some(s) = resolved.as_string() {
return Ok(Destination::Named(s.to_string_lossy()));
}
if let Some(n) = resolved.as_name() {
return Ok(Destination::Named(n.as_str().into_owned()));
}
let arr = resolved.as_array().ok_or(DocError::UnexpectedType)?;
if arr.is_empty() {
return Err(DocError::MissingKey("destination array is empty".into()));
}
let (page_index, page_ref) = match &arr[0] {
Object::Reference(id) => (id.number.saturating_sub(1), Some(*id)),
Object::Integer(n) => ((*n).max(0) as u32, None),
_ => (0, None),
};
let fit = if arr.len() < 2 {
PageFit::Fit
} else {
let fit_name = store
.deep_resolve(&arr[1])
.map_err(|e| DocError::Parser(e.to_string()))?;
let fit_str = fit_name
.as_name()
.map(|n| n.as_str().into_owned())
.unwrap_or_default();
match fit_str.as_str() {
"Fit" => PageFit::Fit,
"FitB" => PageFit::FitB,
"FitH" => PageFit::FitH {
top: get_optional_f32(arr, 2),
},
"FitV" => PageFit::FitV {
left: get_optional_f32(arr, 2),
},
"FitBH" => PageFit::FitBH {
top: get_optional_f32(arr, 2),
},
"FitBV" => PageFit::FitBV {
left: get_optional_f32(arr, 2),
},
"FitR" => PageFit::FitR {
left: get_f32(arr, 2),
bottom: get_f32(arr, 3),
right: get_f32(arr, 4),
top: get_f32(arr, 5),
},
"XYZ" => PageFit::XYZ {
left: get_optional_f32(arr, 2),
top: get_optional_f32(arr, 3),
zoom: get_optional_f32(arr, 4),
},
_ => PageFit::Fit,
}
};
Ok(Destination::Page {
page_index,
page_ref,
fit,
})
}
const MAX_NAMED_DESTS: usize = 10_000;
pub fn collect_named_destinations<S: PdfSource>(
catalog: &Object,
store: &ObjectStore<S>,
) -> DocResult<Vec<(String, Destination)>> {
let catalog_dict = catalog.as_dict().ok_or(DocError::UnexpectedType)?;
if let Some(names_obj) = catalog_dict.get(&Name::names()) {
if let Ok(names_resolved) = store.deep_resolve(names_obj) {
if let Some(names_dict) = names_resolved.as_dict() {
if let Some(dests_obj) = names_dict.get(&Name::dests()) {
let tree = NameTree::<Object>::parse(dests_obj, store, |obj| Ok(obj.clone()))?;
let mut results = Vec::new();
for (name, obj) in tree.entries() {
if results.len() >= MAX_NAMED_DESTS {
break;
}
if let Ok(dest) = parse_destination(obj, store) {
results.push((name.clone(), dest));
}
}
return Ok(results);
}
}
}
}
if let Some(dests_obj) = catalog_dict.get(&Name::dests()) {
if let Ok(dests_resolved) = store.deep_resolve(dests_obj) {
if let Some(dests_dict) = dests_resolved.as_dict() {
let mut results = Vec::new();
for (name, val) in dests_dict {
if results.len() >= MAX_NAMED_DESTS {
break;
}
if let Ok(dest) = parse_destination(val, store) {
results.push((name.as_str().into_owned(), dest));
}
}
return Ok(results);
}
}
}
Ok(Vec::new())
}
fn get_f32(arr: &[Object], idx: usize) -> f32 {
arr.get(idx)
.and_then(|o| o.as_f64())
.map(|f| f as f32)
.unwrap_or(0.0)
}
fn get_optional_f32(arr: &[Object], idx: usize) -> Option<f32> {
arr.get(idx).and_then(|o| {
if o.is_null() {
None
} else {
o.as_f64().map(|f| f as f32)
}
})
}
#[cfg(test)]
mod tests {
use super::*;
fn build_store() -> ObjectStore<Vec<u8>> {
let pdf = build_minimal_pdf();
ObjectStore::open(pdf, rpdfium_core::ParsingMode::Lenient).unwrap()
}
fn build_minimal_pdf() -> Vec<u8> {
let mut pdf = Vec::new();
pdf.extend_from_slice(b"%PDF-1.4\n");
let obj1_offset = pdf.len();
pdf.extend_from_slice(b"1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n");
let obj2_offset = pdf.len();
pdf.extend_from_slice(b"2 0 obj\n<< /Type /Pages /Kids [] /Count 0 >>\nendobj\n");
let xref_offset = pdf.len();
pdf.extend_from_slice(b"xref\n0 3\n");
pdf.extend_from_slice(b"0000000000 65535 f \r\n");
pdf.extend_from_slice(format!("{:010} 00000 n \r\n", obj1_offset).as_bytes());
pdf.extend_from_slice(format!("{:010} 00000 n \r\n", obj2_offset).as_bytes());
pdf.extend_from_slice(b"trailer\n<< /Size 3 /Root 1 0 R >>\n");
pdf.extend_from_slice(format!("startxref\n{}\n%%EOF", xref_offset).as_bytes());
pdf
}
#[test]
fn test_fit_destination() {
let store = build_store();
use rpdfium_core::error::ObjectId;
let dest = Object::Array(vec![
Object::Reference(ObjectId::new(1, 0)),
Object::Name(Name::from("Fit")),
]);
let result = parse_destination(&dest, &store).unwrap();
match result {
Destination::Page { fit, .. } => assert!(matches!(fit, PageFit::Fit)),
_ => panic!("expected Page destination"),
}
}
#[test]
fn test_xyz_destination() {
let store = build_store();
use rpdfium_core::error::ObjectId;
let dest = Object::Array(vec![
Object::Reference(ObjectId::new(1, 0)),
Object::Name(Name::from("XYZ")),
Object::Real(72.0),
Object::Real(720.0),
Object::Real(1.5),
]);
let result = parse_destination(&dest, &store).unwrap();
match result {
Destination::Page { fit, .. } => match fit {
PageFit::XYZ { left, top, zoom } => {
assert_eq!(left, Some(72.0));
assert_eq!(top, Some(720.0));
assert_eq!(zoom, Some(1.5));
}
_ => panic!("expected XYZ"),
},
_ => panic!("expected Page destination"),
}
}
#[test]
fn test_fit_h_with_null_top() {
let store = build_store();
use rpdfium_core::error::ObjectId;
let dest = Object::Array(vec![
Object::Reference(ObjectId::new(1, 0)),
Object::Name(Name::from("FitH")),
Object::Null,
]);
let result = parse_destination(&dest, &store).unwrap();
match result {
Destination::Page { fit, .. } => match fit {
PageFit::FitH { top } => assert_eq!(top, None),
_ => panic!("expected FitH"),
},
_ => panic!("expected Page destination"),
}
}
#[test]
fn test_named_destination_string() {
let store = build_store();
let dest = Object::String(rpdfium_core::PdfString::from_bytes(b"chapter1".to_vec()));
let result = parse_destination(&dest, &store).unwrap();
match result {
Destination::Named(name) => assert_eq!(name, "chapter1"),
_ => panic!("expected Named destination"),
}
}
#[test]
fn test_named_destination_name() {
let store = build_store();
let dest = Object::Name(Name::from("section2"));
let result = parse_destination(&dest, &store).unwrap();
match result {
Destination::Named(name) => assert_eq!(name, "section2"),
_ => panic!("expected Named destination"),
}
}
#[test]
fn test_resolve_page_index_found() {
let store = build_store();
use rpdfium_core::error::ObjectId;
let dest = Object::Array(vec![
Object::Reference(ObjectId::new(5, 0)),
Object::Name(Name::from("Fit")),
]);
let result = parse_destination(&dest, &store).unwrap();
let page_ids = vec![
ObjectId::new(3, 0),
ObjectId::new(5, 0),
ObjectId::new(7, 0),
];
assert_eq!(result.resolve_page_index(&page_ids), Some(1));
}
#[test]
fn test_resolve_page_index_not_found() {
let store = build_store();
use rpdfium_core::error::ObjectId;
let dest = Object::Array(vec![
Object::Reference(ObjectId::new(99, 0)),
Object::Name(Name::from("Fit")),
]);
let result = parse_destination(&dest, &store).unwrap();
let page_ids = vec![ObjectId::new(3, 0), ObjectId::new(5, 0)];
assert_eq!(result.resolve_page_index(&page_ids), None);
}
#[test]
fn test_resolve_page_index_named_returns_none() {
let store = build_store();
let dest = Object::Name(Name::from("chapter1"));
let result = parse_destination(&dest, &store).unwrap();
let page_ids = vec![ObjectId::new(1, 0)];
assert_eq!(result.resolve_page_index(&page_ids), None);
}
#[test]
fn test_page_index_deprecation_accessor() {
let store = build_store();
use rpdfium_core::error::ObjectId;
let dest = Object::Array(vec![
Object::Reference(ObjectId::new(5, 0)),
Object::Name(Name::from("Fit")),
]);
let result = parse_destination(&dest, &store).unwrap();
assert_eq!(result.dest_page_index(), Some(4));
}
#[test]
fn test_page_ref_stored_for_reference() {
let store = build_store();
use rpdfium_core::error::ObjectId;
let dest = Object::Array(vec![
Object::Reference(ObjectId::new(10, 0)),
Object::Name(Name::from("XYZ")),
Object::Null,
Object::Null,
Object::Null,
]);
let result = parse_destination(&dest, &store).unwrap();
match result {
Destination::Page { page_ref, .. } => {
assert_eq!(page_ref, Some(ObjectId::new(10, 0)));
}
_ => panic!("expected Page destination"),
}
}
#[test]
fn test_collect_named_destinations_from_names_tree() {
use rpdfium_core::PdfString;
let store = build_store();
let mut dests_leaf = std::collections::HashMap::new();
dests_leaf.insert(
Name::names(),
Object::Array(vec![
Object::String(PdfString::from_bytes(b"chapter1".to_vec())),
Object::Array(vec![Object::Integer(0), Object::Name(Name::from("Fit"))]),
Object::String(PdfString::from_bytes(b"chapter2".to_vec())),
Object::Array(vec![Object::Integer(1), Object::Name(Name::from("FitH"))]),
]),
);
let mut names_dict = std::collections::HashMap::new();
names_dict.insert(Name::dests(), Object::Dictionary(dests_leaf));
let mut catalog = std::collections::HashMap::new();
catalog.insert(Name::names(), Object::Dictionary(names_dict));
let catalog_obj = Object::Dictionary(catalog);
let results = collect_named_destinations(&catalog_obj, &store).unwrap();
assert_eq!(results.len(), 2);
assert_eq!(results[0].0, "chapter1");
assert_eq!(results[1].0, "chapter2");
}
#[test]
fn test_collect_named_destinations_old_style_dests_dict() {
let store = build_store();
let mut dests_dict = std::collections::HashMap::new();
dests_dict.insert(
Name::from("intro"),
Object::Array(vec![Object::Integer(0), Object::Name(Name::from("Fit"))]),
);
let mut catalog = std::collections::HashMap::new();
catalog.insert(Name::dests(), Object::Dictionary(dests_dict));
let catalog_obj = Object::Dictionary(catalog);
let results = collect_named_destinations(&catalog_obj, &store).unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0].0, "intro");
}
#[test]
fn test_collect_named_destinations_empty_catalog() {
let store = build_store();
let catalog_obj = Object::Dictionary(std::collections::HashMap::new());
let results = collect_named_destinations(&catalog_obj, &store).unwrap();
assert!(results.is_empty());
}
#[test]
fn test_integer_page_has_no_page_ref() {
let store = build_store();
let dest = Object::Array(vec![Object::Integer(3), Object::Name(Name::from("Fit"))]);
let result = parse_destination(&dest, &store).unwrap();
match result {
Destination::Page {
page_index,
page_ref,
..
} => {
assert_eq!(page_index, 3);
assert!(page_ref.is_none());
}
_ => panic!("expected Page destination"),
}
}
fn lookup_named_dest(
catalog: &Object,
store: &ObjectStore<Vec<u8>>,
name: &str,
) -> Option<Destination> {
collect_named_destinations(catalog, store)
.ok()?
.into_iter()
.find(|(n, _)| n == name)
.map(|(_, d)| d)
}
#[test]
fn test_named_dest_by_name_found_in_names_tree() {
use rpdfium_core::PdfString;
let store = build_store();
let mut dests_leaf = std::collections::HashMap::new();
dests_leaf.insert(
Name::names(),
Object::Array(vec![
Object::String(PdfString::from_bytes(b"chapter1".to_vec())),
Object::Array(vec![Object::Integer(0), Object::Name(Name::from("Fit"))]),
Object::String(PdfString::from_bytes(b"chapter2".to_vec())),
Object::Array(vec![Object::Integer(1), Object::Name(Name::from("FitH"))]),
]),
);
let mut names_dict = std::collections::HashMap::new();
names_dict.insert(Name::dests(), Object::Dictionary(dests_leaf));
let mut catalog = std::collections::HashMap::new();
catalog.insert(Name::names(), Object::Dictionary(names_dict));
let catalog_obj = Object::Dictionary(catalog);
let dest = lookup_named_dest(&catalog_obj, &store, "chapter1");
assert!(dest.is_some(), "expected to find chapter1");
match dest.unwrap() {
Destination::Page { fit, .. } => assert!(matches!(fit, PageFit::Fit)),
_ => panic!("expected Page destination"),
}
}
#[test]
fn test_named_dest_by_name_not_found_returns_none() {
use rpdfium_core::PdfString;
let store = build_store();
let mut dests_leaf = std::collections::HashMap::new();
dests_leaf.insert(
Name::names(),
Object::Array(vec![
Object::String(PdfString::from_bytes(b"intro".to_vec())),
Object::Array(vec![Object::Integer(0), Object::Name(Name::from("Fit"))]),
]),
);
let mut names_dict = std::collections::HashMap::new();
names_dict.insert(Name::dests(), Object::Dictionary(dests_leaf));
let mut catalog = std::collections::HashMap::new();
catalog.insert(Name::names(), Object::Dictionary(names_dict));
let catalog_obj = Object::Dictionary(catalog);
let dest = lookup_named_dest(&catalog_obj, &store, "nonexistent");
assert!(dest.is_none(), "expected None for unknown name");
}
#[test]
fn test_named_dest_by_name_found_in_old_style_dests_dict() {
let store = build_store();
let mut dests_dict = std::collections::HashMap::new();
dests_dict.insert(
Name::from("section3"),
Object::Array(vec![Object::Integer(2), Object::Name(Name::from("XYZ"))]),
);
let mut catalog = std::collections::HashMap::new();
catalog.insert(Name::dests(), Object::Dictionary(dests_dict));
let catalog_obj = Object::Dictionary(catalog);
let dest = lookup_named_dest(&catalog_obj, &store, "section3");
assert!(dest.is_some(), "expected to find section3");
match dest.unwrap() {
Destination::Page { fit, .. } => assert!(matches!(fit, PageFit::XYZ { .. })),
_ => panic!("expected Page destination"),
}
}
#[test]
fn test_pdf_doc_get_location_in_page() {
let dest = Destination::Page {
page_index: 0,
page_ref: None,
fit: PageFit::XYZ {
left: Some(4.0),
top: Some(5.0),
zoom: Some(6.0),
},
};
let loc = dest.location_in_page();
assert!(loc.is_some());
let (x, y, zoom) = loc.unwrap();
assert_eq!(x, Some(4.0));
assert_eq!(y, Some(5.0));
assert_eq!(zoom, Some(6.0));
let dest_nulls = Destination::Page {
page_index: 0,
page_ref: None,
fit: PageFit::XYZ {
left: None,
top: None,
zoom: None,
},
};
let loc2 = dest_nulls.location_in_page();
assert!(loc2.is_some());
let (x2, y2, zoom2) = loc2.unwrap();
assert_eq!(x2, None);
assert_eq!(y2, None);
assert_eq!(zoom2, None);
let dest_fit = Destination::Page {
page_index: 0,
page_ref: None,
fit: PageFit::Fit,
};
assert!(dest_fit.location_in_page().is_none());
let dest_named = Destination::Named("test".into());
assert!(dest_named.location_in_page().is_none());
}
}