use std::collections::{HashMap, HashSet};
use zpdf_core::{ObjectId, PdfDict, PdfObject};
use zpdf_parser::PdfFile;
use crate::forms::pdf_string_to_unicode;
use crate::obj_util::{
catalog_dict, resolve_array, resolve_dict, resolve_name, resolve_number, text,
};
use crate::Catalog;
const MAX_NAME_TREE_DEPTH: usize = 64;
const MAX_NAME_TREE_NODES: usize = 100_000;
const MAX_DEST_INDIRECTION: usize = 8;
const MAX_NAMED_DEST_ENTRIES: usize = 200_000;
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum DestView {
Xyz {
left: Option<f32>,
top: Option<f32>,
zoom: Option<f32>,
},
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> },
Unknown,
}
#[derive(Debug, Clone, PartialEq)]
pub struct Destination {
pub page: Option<usize>,
pub page_ref: Option<ObjectId>,
pub view: DestView,
}
pub fn resolve_named(file: &PdfFile, catalog: &Catalog, name: &[u8]) -> Option<Destination> {
let value = lookup_named_value(file, name)?;
resolve_dest_value(file, catalog, &value, 0, None)
}
pub fn resolve_explicit(file: &PdfFile, catalog: &Catalog, obj: &PdfObject) -> Option<Destination> {
resolve_dest_value(file, catalog, obj, 0, None)
}
pub(crate) fn resolve_link_target(
file: &PdfFile,
catalog: &Catalog,
dict: &PdfDict,
named: Option<&HashMap<Vec<u8>, PdfObject>>,
) -> (Option<Destination>, Option<String>) {
if let Some(dest_obj) = dict.get("Dest") {
if let Some(d) = resolve_dest_value(file, catalog, dest_obj, 0, named) {
return (Some(d), None);
}
}
if let Some(action) = resolve_dict(file, dict.get("A")) {
match resolve_name(file, action.get("S")).as_deref() {
Some("GoTo") => {
if let Some(d) = action
.get("D")
.and_then(|d| resolve_dest_value(file, catalog, d, 0, named))
{
return (Some(d), None);
}
}
Some("URI") => {
if let Some(uri) = uri_string(file, &action) {
return (None, Some(uri));
}
}
Some("GoToR") => {
if let Some(name) = remote_file_name(file, &action) {
return (None, Some(name));
}
}
_ => {}
}
}
(None, None)
}
fn uri_string(file: &PdfFile, action: &PdfDict) -> Option<String> {
let value = match action.get("URI")? {
PdfObject::String(s) => return Some(decode_ascii(s.as_bytes())),
PdfObject::Ref(r) => file.resolve(*r).ok()?,
_ => return None,
};
match value {
PdfObject::String(s) => Some(decode_ascii(s.as_bytes())),
_ => None,
}
}
fn remote_file_name(file: &PdfFile, action: &PdfDict) -> Option<String> {
match action.get("F")? {
PdfObject::String(s) => Some(pdf_string_to_unicode(s.as_bytes())),
PdfObject::Dict(d) => filespec_name(file, d),
PdfObject::Ref(r) => match file.resolve(*r).ok()? {
PdfObject::String(s) => Some(pdf_string_to_unicode(s.as_bytes())),
PdfObject::Dict(d) => filespec_name(file, &d),
_ => None,
},
_ => None,
}
}
fn filespec_name(file: &PdfFile, dict: &PdfDict) -> Option<String> {
text(file, dict, "UF").or_else(|| text(file, dict, "F"))
}
fn decode_ascii(bytes: &[u8]) -> String {
bytes.iter().map(|&b| b as char).collect()
}
fn resolve_dest_value(
file: &PdfFile,
catalog: &Catalog,
obj: &PdfObject,
depth: usize,
named: Option<&HashMap<Vec<u8>, PdfObject>>,
) -> Option<Destination> {
if depth > MAX_DEST_INDIRECTION {
return None;
}
match obj {
PdfObject::Array(arr) => parse_explicit_array(file, catalog, arr),
PdfObject::Ref(r) => {
let resolved = file.resolve(*r).ok()?;
resolve_dest_value(file, catalog, &resolved, depth + 1, named)
}
PdfObject::Name(n) => {
let v = lookup_in(file, named, n.as_str().as_bytes())?;
resolve_dest_value(file, catalog, &v, depth + 1, named)
}
PdfObject::String(s) => {
let v = lookup_in(file, named, s.as_bytes())?;
resolve_dest_value(file, catalog, &v, depth + 1, named)
}
PdfObject::Dict(d) => {
let inner = d.get("D")?;
resolve_dest_value(file, catalog, inner, depth + 1, named)
}
_ => None,
}
}
fn lookup_in(
file: &PdfFile,
named: Option<&HashMap<Vec<u8>, PdfObject>>,
name: &[u8],
) -> Option<PdfObject> {
match named {
Some(map) => map.get(name).cloned(),
None => lookup_named_value(file, name),
}
}
fn parse_explicit_array(
file: &PdfFile,
catalog: &Catalog,
arr: &[PdfObject],
) -> Option<Destination> {
if arr.is_empty() {
return None;
}
let (page, page_ref) = match &arr[0] {
PdfObject::Ref(r) => match catalog.page_index_of(*r) {
Some(idx) => (Some(idx), Some(*r)),
None => match file.resolve(*r).ok().and_then(|o| page_number(&o, catalog)) {
Some(idx) => (Some(idx), None),
None => (None, Some(*r)),
},
},
other => (page_number(other, catalog), None),
};
let view = parse_view(file, arr);
Some(Destination {
page,
page_ref,
view,
})
}
fn page_number(obj: &PdfObject, catalog: &Catalog) -> Option<usize> {
let n = match obj {
PdfObject::Integer(n) if *n >= 0 => *n as usize,
PdfObject::Real(f) if f.is_finite() && *f >= 0.0 && f.fract() == 0.0 => *f as usize,
_ => return None,
};
(n < catalog.page_count).then_some(n)
}
fn parse_view(file: &PdfFile, arr: &[PdfObject]) -> DestView {
let num = |i: usize| resolve_number(file, arr.get(i));
match resolve_name(file, arr.get(1)).as_deref() {
Some("XYZ") => DestView::Xyz {
left: num(2),
top: num(3),
zoom: num(4).filter(|&z| z != 0.0),
},
Some("Fit") => DestView::Fit,
Some("FitH") => DestView::FitH { top: num(2) },
Some("FitV") => DestView::FitV { left: num(2) },
Some("FitR") => DestView::FitR {
left: num(2).unwrap_or(0.0),
bottom: num(3).unwrap_or(0.0),
right: num(4).unwrap_or(0.0),
top: num(5).unwrap_or(0.0),
},
Some("FitB") => DestView::FitB,
Some("FitBH") => DestView::FitBH { top: num(2) },
Some("FitBV") => DestView::FitBV { left: num(2) },
_ => DestView::Unknown,
}
}
fn lookup_named_value(file: &PdfFile, name: &[u8]) -> Option<PdfObject> {
let root = catalog_dict(file)?;
if let Some(names) = resolve_dict(file, root.get("Names")) {
if let Some(tree) = resolve_dict(file, names.get("Dests")) {
let mut visited = HashSet::new();
if let Some(PdfObject::Ref(id)) = names.get("Dests") {
visited.insert(*id);
}
let mut budget = MAX_NAME_TREE_NODES;
if let Some(v) = name_tree_lookup(file, &tree, name, 0, &mut visited, &mut budget) {
return Some(v);
}
}
}
if let Some(dests) = resolve_dict(file, root.get("Dests")) {
if let Ok(key) = std::str::from_utf8(name) {
if let Some(v) = dests.get(key) {
return Some(v.clone());
}
}
}
None
}
fn name_tree_lookup(
file: &PdfFile,
node: &PdfDict,
key: &[u8],
depth: usize,
visited: &mut HashSet<ObjectId>,
budget: &mut usize,
) -> Option<PdfObject> {
if depth > MAX_NAME_TREE_DEPTH || *budget == 0 {
return None;
}
*budget -= 1;
if let Some(names) = resolve_array(file, node.get("Names")) {
let mut i = 0;
while i + 1 < names.len() {
if let PdfObject::String(s) = &names[i] {
if s.as_bytes() == key {
return Some(names[i + 1].clone());
}
}
i += 2;
}
}
if let Some(kids) = resolve_array(file, node.get("Kids")) {
for kid in &kids {
if *budget == 0 {
return None;
}
let kid_dict = match kid {
PdfObject::Ref(r) => {
if !visited.insert(*r) {
continue;
}
resolve_dict(file, Some(kid))
}
PdfObject::Dict(_) => resolve_dict(file, Some(kid)),
_ => None,
};
let Some(d) = kid_dict else { continue };
if !limits_may_contain(file, &d, key) {
continue;
}
if let Some(v) = name_tree_lookup(file, &d, key, depth + 1, visited, budget) {
return Some(v);
}
}
}
None
}
fn limits_may_contain(file: &PdfFile, node: &PdfDict, key: &[u8]) -> bool {
let Some(limits) = resolve_array(file, node.get("Limits")) else {
return true;
};
let (Some(PdfObject::String(lo)), Some(PdfObject::String(hi))) =
(limits.first(), limits.get(1))
else {
return true;
};
key >= lo.as_bytes() && key <= hi.as_bytes()
}
pub(crate) fn collect_named_dests(file: &PdfFile) -> HashMap<Vec<u8>, PdfObject> {
let mut map = HashMap::new();
let Some(root) = catalog_dict(file) else {
return map;
};
let mut budget = MAX_NAMED_DEST_ENTRIES;
if let Some(names) = resolve_dict(file, root.get("Names")) {
if let Some(tree) = resolve_dict(file, names.get("Dests")) {
let mut visited = HashSet::new();
if let Some(PdfObject::Ref(id)) = names.get("Dests") {
visited.insert(*id);
}
collect_name_tree(file, &tree, 0, &mut visited, &mut budget, &mut map);
}
}
if let Some(dests) = resolve_dict(file, root.get("Dests")) {
for (k, v) in &dests.0 {
if budget == 0 {
break;
}
budget -= 1;
map.entry(k.as_str().as_bytes().to_vec())
.or_insert_with(|| v.clone());
}
}
map
}
fn collect_name_tree(
file: &PdfFile,
node: &PdfDict,
depth: usize,
visited: &mut HashSet<ObjectId>,
budget: &mut usize,
map: &mut HashMap<Vec<u8>, PdfObject>,
) {
if depth > MAX_NAME_TREE_DEPTH || *budget == 0 {
return;
}
*budget -= 1;
if let Some(names) = resolve_array(file, node.get("Names")) {
let mut i = 0;
while i + 1 < names.len() {
if *budget == 0 {
return;
}
if let PdfObject::String(s) = &names[i] {
map.entry(s.as_bytes().to_vec())
.or_insert_with(|| names[i + 1].clone());
*budget -= 1;
}
i += 2;
}
}
if let Some(kids) = resolve_array(file, node.get("Kids")) {
for kid in &kids {
if *budget == 0 {
return;
}
let kid_dict = match kid {
PdfObject::Ref(r) => {
if !visited.insert(*r) {
continue;
}
resolve_dict(file, Some(kid))
}
PdfObject::Dict(_) => resolve_dict(file, Some(kid)),
_ => None,
};
let Some(d) = kid_dict else { continue };
collect_name_tree(file, &d, depth + 1, visited, budget, map);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_util::build_pdf;
use crate::PdfDocument;
fn open(objects: &[&str]) -> PdfDocument {
PdfDocument::open(build_pdf(objects)).expect("open pdf")
}
const PAGES2: &str = "<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 >>";
const PAGE_A: &str = "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] >>";
const PAGE_B: &str = "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] >>";
#[test]
fn named_dest_via_name_tree_xyz() {
let doc = open(&[
"<< /Type /Catalog /Pages 2 0 R /Names << /Dests 5 0 R >> >>",
PAGES2,
PAGE_A,
PAGE_B,
"<< /Names [ (chap2) << /D [4 0 R /XYZ 0 800 0] >> ] >>",
]);
let d = doc.named_destination(b"chap2").expect("resolve");
assert_eq!(d.page, Some(1)); assert_eq!(d.page_ref, Some(zpdf_core::ObjectId(4, 0)));
assert_eq!(
d.view,
DestView::Xyz {
left: Some(0.0),
top: Some(800.0),
zoom: None, }
);
}
#[test]
fn named_dest_via_legacy_root_dests_dict() {
let doc = open(&[
"<< /Type /Catalog /Pages 2 0 R /Dests 5 0 R >>",
PAGES2,
PAGE_A,
PAGE_B,
"<< /intro [3 0 R /Fit] >>",
]);
let d = doc.named_destination(b"intro").expect("resolve");
assert_eq!(d.page, Some(0));
assert_eq!(d.view, DestView::Fit);
}
#[test]
fn named_dest_bare_array_value() {
let doc = open(&[
"<< /Type /Catalog /Pages 2 0 R /Names << /Dests 5 0 R >> >>",
PAGES2,
PAGE_A,
PAGE_B,
"<< /Names [ (x) [4 0 R /FitH 750] ] >>",
]);
let d = doc.named_destination(b"x").expect("resolve");
assert_eq!(d.page, Some(1));
assert_eq!(d.view, DestView::FitH { top: Some(750.0) });
}
#[test]
fn name_tree_interior_kids_with_limits() {
let doc = open(&[
"<< /Type /Catalog /Pages 2 0 R /Names << /Dests 5 0 R >> >>",
PAGES2,
PAGE_A,
PAGE_B,
"<< /Kids [6 0 R 7 0 R] >>",
"<< /Limits [(a) (m)] /Names [ (b) [3 0 R /Fit] ] >>",
"<< /Limits [(n) (z)] /Names [ (y) [4 0 R /Fit] ] >>",
]);
let d = doc.named_destination(b"y").expect("resolve");
assert_eq!(d.page, Some(1));
}
#[test]
fn explicit_fitr_all_coords() {
let doc = open(&["<< /Type /Catalog /Pages 2 0 R >>", PAGES2, PAGE_A, PAGE_B]);
let arr = PdfObject::Array(vec![
PdfObject::Ref(zpdf_core::ObjectId(3, 0)),
PdfObject::Name(zpdf_core::PdfName("FitR".into())),
PdfObject::Integer(10),
PdfObject::Integer(20),
PdfObject::Integer(30),
PdfObject::Integer(40),
]);
let d = doc.resolve_destination(&arr).expect("resolve");
assert_eq!(d.page, Some(0));
assert_eq!(
d.view,
DestView::FitR {
left: 10.0,
bottom: 20.0,
right: 30.0,
top: 40.0,
}
);
}
#[test]
fn explicit_page_number_for_remote_dest() {
let doc = open(&["<< /Type /Catalog /Pages 2 0 R >>", PAGES2, PAGE_A, PAGE_B]);
let arr = PdfObject::Array(vec![
PdfObject::Integer(1),
PdfObject::Name(zpdf_core::PdfName("Fit".into())),
]);
let d = doc.resolve_destination(&arr).expect("resolve");
assert_eq!(d.page, Some(1));
assert_eq!(d.page_ref, None);
}
#[test]
fn out_of_range_page_number_is_none() {
let doc = open(&["<< /Type /Catalog /Pages 2 0 R >>", PAGES2, PAGE_A, PAGE_B]);
let arr = PdfObject::Array(vec![
PdfObject::Integer(500),
PdfObject::Name(zpdf_core::PdfName("Fit".into())),
]);
assert_eq!(doc.resolve_destination(&arr).unwrap().page, None);
let big = PdfObject::Array(vec![
PdfObject::Real(1e20),
PdfObject::Name(zpdf_core::PdfName("Fit".into())),
]);
assert_eq!(doc.resolve_destination(&big).unwrap().page, None);
}
#[test]
fn indirectly_encoded_page_number_resolves() {
let doc = open(&[
"<< /Type /Catalog /Pages 2 0 R >>",
PAGES2,
PAGE_A,
PAGE_B,
"1", ]);
let arr = PdfObject::Array(vec![
PdfObject::Ref(zpdf_core::ObjectId(5, 0)),
PdfObject::Name(zpdf_core::PdfName("Fit".into())),
]);
let d = doc.resolve_destination(&arr).expect("resolve");
assert_eq!(d.page, Some(1));
assert_eq!(d.page_ref, None);
}
#[test]
fn page_ref_not_in_tree_yields_none_page_but_keeps_view() {
let doc = open(&["<< /Type /Catalog /Pages 2 0 R >>", PAGES2, PAGE_A, PAGE_B]);
let arr = PdfObject::Array(vec![
PdfObject::Ref(zpdf_core::ObjectId(999, 0)), PdfObject::Name(zpdf_core::PdfName("Fit".into())),
]);
let d = doc.resolve_destination(&arr).expect("resolve");
assert_eq!(d.page, None);
assert_eq!(d.page_ref, Some(zpdf_core::ObjectId(999, 0)));
assert_eq!(d.view, DestView::Fit);
}
#[test]
fn unknown_name_resolves_to_none() {
let doc = open(&[
"<< /Type /Catalog /Pages 2 0 R /Names << /Dests 5 0 R >> >>",
PAGES2,
PAGE_A,
PAGE_B,
"<< /Names [ (real) [3 0 R /Fit] ] >>",
]);
assert!(doc.named_destination(b"missing").is_none());
}
#[test]
fn self_referential_named_dest_terminates() {
let doc = open(&[
"<< /Type /Catalog /Pages 2 0 R /Names << /Dests 5 0 R >> >>",
PAGES2,
PAGE_A,
PAGE_B,
"<< /Names [ (loop) (loop) ] >>",
]);
assert!(doc.named_destination(b"loop").is_none());
}
}