use crate::metadata::pdf_string_to_rust_pub;
use crate::objects::{PdfDict, PdfObj};
use crate::page_tree::PageInfo;
use crate::resolver::Resolver;
#[derive(Debug, Clone, PartialEq)]
#[non_exhaustive]
pub enum Destination {
PageView {
page: Option<usize>,
view: ViewSpec,
},
NamedDest(String),
}
#[derive(Debug, Clone, Copy, PartialEq)]
#[non_exhaustive]
pub enum ViewSpec {
Xyz {
x: Option<f64>,
y: Option<f64>,
zoom: Option<f64>,
},
Fit,
FitH { y: Option<f64> },
FitV { x: Option<f64> },
FitR {
left: f64,
bottom: f64,
right: f64,
top: f64,
},
FitB,
FitBH { y: Option<f64> },
FitBV { x: Option<f64> },
}
impl Default for ViewSpec {
fn default() -> Self {
ViewSpec::Xyz {
x: None,
y: None,
zoom: None,
}
}
}
#[derive(Debug, Clone, PartialEq)]
#[non_exhaustive]
pub enum Action {
GoTo(Destination),
GoToR {
filename: String,
dest: Destination,
new_window: Option<bool>,
},
GoToE {
target: String,
dest: Destination,
new_window: Option<bool>,
},
Launch {
filename: String,
new_window: Option<bool>,
},
Uri { uri: String, is_map: bool },
Named(String),
JavaScript(String),
SubmitForm {
url: String,
fields: Vec<String>,
flags: u32,
},
ResetForm { fields: Vec<String>, flags: u32 },
Hide { targets: Vec<String>, hide: bool },
Sound,
Movie,
Thread { target: Option<String> },
Other { subtype: String },
}
pub fn parse_destination(
resolver: &Resolver,
pages: &[PageInfo],
obj: &PdfObj,
) -> Option<Destination> {
let resolved = resolver.deref(obj).ok()?;
if let Some(dict) = resolved.as_dict()
&& let Some(d) = dict.get(b"D")
{
return parse_destination(resolver, pages, d);
}
if let Some(name) = resolved.as_name() {
return Some(Destination::NamedDest(
String::from_utf8_lossy(name).into_owned(),
));
}
if let Some(s) = resolved.as_str() {
return Some(Destination::NamedDest(
String::from_utf8_lossy(s).into_owned(),
));
}
if let Some(arr) = resolved.as_array() {
return Some(parse_explicit_destination(pages, arr));
}
None
}
pub fn parse_explicit_destination(pages: &[PageInfo], arr: &[PdfObj]) -> Destination {
let page = arr.first().and_then(|p| {
if let Some((num, _)) = p.as_ref() {
pages.iter().position(|info| info.obj_num == num)
} else {
p.as_int().map(|i| i as usize)
}
});
let mode = arr.get(1).and_then(|m| m.as_name()).unwrap_or(b"XYZ");
let view = parse_view_spec(mode, &arr[2.min(arr.len())..]);
Destination::PageView { page, view }
}
pub fn parse_view_spec(mode: &[u8], args: &[PdfObj]) -> ViewSpec {
let num = |i: usize| args.get(i).and_then(|o| o.as_f64());
match mode {
b"XYZ" => ViewSpec::Xyz {
x: num(0),
y: num(1),
zoom: num(2).filter(|&z| z > 0.0),
},
b"Fit" => ViewSpec::Fit,
b"FitH" => ViewSpec::FitH { y: num(0) },
b"FitV" => ViewSpec::FitV { x: num(0) },
b"FitR" => ViewSpec::FitR {
left: num(0).unwrap_or(0.0),
bottom: num(1).unwrap_or(0.0),
right: num(2).unwrap_or(0.0),
top: num(3).unwrap_or(0.0),
},
b"FitB" => ViewSpec::FitB,
b"FitBH" => ViewSpec::FitBH { y: num(0) },
b"FitBV" => ViewSpec::FitBV { x: num(0) },
_ => ViewSpec::default(),
}
}
pub fn parse_action(resolver: &Resolver, pages: &[PageInfo], obj: &PdfObj) -> Option<Action> {
let resolved = resolver.deref(obj).ok()?;
let dict = resolved.as_dict()?;
let subtype = dict.get_name(b"S").unwrap_or(b"");
Some(match subtype {
b"GoTo" => {
let dest = dict
.get(b"D")
.and_then(|d| parse_destination(resolver, pages, d))
.unwrap_or(Destination::NamedDest(String::new()));
Action::GoTo(dest)
}
b"GoToR" => Action::GoToR {
filename: parse_file_spec(resolver, dict.get(b"F")).unwrap_or_default(),
dest: dict
.get(b"D")
.and_then(|d| parse_destination(resolver, &[], d))
.unwrap_or(Destination::NamedDest(String::new())),
new_window: dict.get(b"NewWindow").and_then(as_bool),
},
b"GoToE" => Action::GoToE {
target: dict
.get(b"T")
.and_then(pdf_string_to_rust_pub)
.unwrap_or_default(),
dest: dict
.get(b"D")
.and_then(|d| parse_destination(resolver, &[], d))
.unwrap_or(Destination::NamedDest(String::new())),
new_window: dict.get(b"NewWindow").and_then(as_bool),
},
b"Launch" => Action::Launch {
filename: parse_file_spec(resolver, dict.get(b"F")).unwrap_or_default(),
new_window: dict.get(b"NewWindow").and_then(as_bool),
},
b"URI" => Action::Uri {
uri: dict
.get(b"URI")
.and_then(pdf_string_to_rust_pub)
.unwrap_or_default(),
is_map: dict.get(b"IsMap").and_then(as_bool).unwrap_or(false),
},
b"Named" => Action::Named(
dict.get_name(b"N")
.map(|n| String::from_utf8_lossy(n).into_owned())
.unwrap_or_default(),
),
b"JavaScript" => {
Action::JavaScript(dict.get(b"JS").and_then(read_js_source).unwrap_or_default())
}
b"SubmitForm" => Action::SubmitForm {
url: parse_file_spec(resolver, dict.get(b"F")).unwrap_or_default(),
fields: parse_field_name_list(resolver, dict.get(b"Fields")),
flags: dict.get_int(b"Flags").unwrap_or(0) as u32,
},
b"ResetForm" => Action::ResetForm {
fields: parse_field_name_list(resolver, dict.get(b"Fields")),
flags: dict.get_int(b"Flags").unwrap_or(0) as u32,
},
b"Hide" => Action::Hide {
targets: parse_field_name_list(resolver, dict.get(b"T")),
hide: dict.get(b"H").and_then(as_bool).unwrap_or(true),
},
b"Sound" => Action::Sound,
b"Movie" => Action::Movie,
b"Thread" => Action::Thread {
target: dict.get(b"T").and_then(pdf_string_to_rust_pub),
},
other => Action::Other {
subtype: String::from_utf8_lossy(other).into_owned(),
},
})
}
fn as_bool(obj: &PdfObj) -> Option<bool> {
match obj {
PdfObj::Bool(b) => Some(*b),
_ => None,
}
}
fn read_js_source(obj: &PdfObj) -> Option<String> {
if let Some(s) = obj.as_str() {
return Some(crate::metadata::decode_pdf_text_string_pub(s));
}
None
}
fn parse_file_spec(resolver: &Resolver, obj: Option<&PdfObj>) -> Option<String> {
let obj = obj?;
let resolved = resolver.deref(obj).ok()?;
if let Some(s) = resolved.as_str() {
return Some(crate::metadata::decode_pdf_text_string_pub(s));
}
if let Some(d) = resolved.as_dict() {
if let Some(uf) = d.get(b"UF").and_then(pdf_string_to_rust_pub) {
return Some(uf);
}
if let Some(f) = d.get(b"F").and_then(pdf_string_to_rust_pub) {
return Some(f);
}
}
None
}
fn parse_field_name_list(resolver: &Resolver, obj: Option<&PdfObj>) -> Vec<String> {
let Some(obj) = obj else {
return Vec::new();
};
let Ok(resolved) = resolver.deref(obj) else {
return Vec::new();
};
let Some(arr) = resolved.as_array() else {
return Vec::new();
};
arr.iter()
.filter_map(|o| o.as_str().map(crate::metadata::decode_pdf_text_string_pub))
.collect()
}
pub fn parse_named_destinations(
resolver: &Resolver,
pages: &[PageInfo],
) -> std::collections::HashMap<String, Destination> {
use std::collections::HashMap;
let mut map: HashMap<String, Destination> = HashMap::new();
let catalog = match catalog_dict_for_dests(resolver) {
Some(c) => c,
None => return map,
};
if let Some(names_obj) = catalog.get(b"Names")
&& let Ok(names_dict_obj) = resolver.deref(names_obj)
&& let Some(names_dict) = names_dict_obj.as_dict()
&& let Some(dests_root) = names_dict.get(b"Dests")
{
let tree = crate::name_tree::walk_name_tree(resolver, dests_root, |r, val| {
parse_destination(r, pages, val)
});
map.extend(tree);
}
if let Some(dests_obj) = catalog.get(b"Dests")
&& let Ok(dests) = resolver.deref(dests_obj)
&& let Some(dests_dict) = dests.as_dict()
{
for (key, val) in dests_dict.entries() {
if let Some(d) = parse_destination(resolver, pages, val) {
let k = String::from_utf8_lossy(key).into_owned();
map.insert(k, d);
}
}
}
map
}
fn catalog_dict_for_dests(resolver: &Resolver) -> Option<PdfDict> {
if let Some((num, gen_num)) = resolver.trailer().get_ref(b"Root")
&& let Ok(obj) = resolver.resolve(num, gen_num)
&& let Some(dict) = obj.as_dict()
{
return Some(dict.clone());
}
crate::find_catalog(resolver).and_then(|obj| obj.as_dict().cloned())
}
#[cfg(test)]
mod tests {
use super::*;
fn page_info(obj_num: u32) -> PageInfo {
PageInfo {
obj_num,
media_box: [0.0, 0.0, 612.0, 792.0],
crop_box: [0.0, 0.0, 612.0, 792.0],
rotate: 0,
resources: PdfDict::new(),
contents: vec![],
annots: vec![],
}
}
#[test]
fn view_spec_xyz_with_some_nones() {
let v = parse_view_spec(
b"XYZ",
&[PdfObj::Real(72.0), PdfObj::Null, PdfObj::Real(1.5)],
);
match v {
ViewSpec::Xyz { x, y, zoom } => {
assert_eq!(x, Some(72.0));
assert_eq!(y, None);
assert_eq!(zoom, Some(1.5));
}
_ => panic!("expected XYZ"),
}
}
#[test]
fn view_spec_xyz_zero_zoom_becomes_none() {
let v = parse_view_spec(
b"XYZ",
&[PdfObj::Real(0.0), PdfObj::Real(0.0), PdfObj::Real(0.0)],
);
match v {
ViewSpec::Xyz { zoom, .. } => assert_eq!(zoom, None),
_ => panic!("expected XYZ"),
}
}
#[test]
fn view_spec_fit_variants() {
assert_eq!(parse_view_spec(b"Fit", &[]), ViewSpec::Fit);
assert_eq!(parse_view_spec(b"FitB", &[]), ViewSpec::FitB);
assert_eq!(
parse_view_spec(b"FitH", &[PdfObj::Real(700.0)]),
ViewSpec::FitH { y: Some(700.0) }
);
assert_eq!(
parse_view_spec(
b"FitR",
&[
PdfObj::Real(0.0),
PdfObj::Real(0.0),
PdfObj::Real(100.0),
PdfObj::Real(200.0),
]
),
ViewSpec::FitR {
left: 0.0,
bottom: 0.0,
right: 100.0,
top: 200.0,
}
);
}
#[test]
fn view_spec_unknown_falls_back_to_xyz_default() {
let v = parse_view_spec(b"Bogus", &[]);
assert_eq!(v, ViewSpec::default());
}
#[test]
fn explicit_destination_with_page_ref_resolves_index() {
let pages = vec![page_info(10), page_info(20), page_info(30)];
let arr = vec![PdfObj::Ref(20, 0), PdfObj::Name(b"Fit".to_vec())];
let d = parse_explicit_destination(&pages, &arr);
match d {
Destination::PageView { page, view } => {
assert_eq!(page, Some(1));
assert_eq!(view, ViewSpec::Fit);
}
_ => panic!("expected PageView"),
}
}
#[test]
fn explicit_destination_with_unknown_page_ref() {
let pages = vec![page_info(10)];
let arr = vec![PdfObj::Ref(99, 0), PdfObj::Name(b"Fit".to_vec())];
let d = parse_explicit_destination(&pages, &arr);
assert!(matches!(d, Destination::PageView { page: None, .. }));
}
}