#[cfg(test)]
use kurbo::Point;
use pdfrum_common::{Diagnostics, Limits};
use pdfrum_object::{Dict, Resolve, names as obj_names};
#[cfg(test)]
use crate::geom;
use crate::names;
use crate::nav::action::Action;
use crate::nav::dest::Dest;
#[derive(Debug, Clone, PartialEq)]
pub struct Link {
pub dict: Dict,
}
impl Link {
#[must_use]
pub fn new(dict: Dict) -> Link {
Link { dict }
}
#[must_use]
pub fn rect<R: Resolve>(&self, r: &R) -> kurbo::Rect {
self.dict.rect(obj_names::RECT, r)
}
#[must_use]
pub fn action<R: Resolve>(&self, r: &R) -> Option<Action> {
self.dict.dict(names::A, r).map(Action::new)
}
#[must_use]
pub fn dest<R: Resolve>(
&self,
catalog: &Dict,
r: &R,
limits: &Limits,
diags: &mut Diagnostics,
) -> Dest {
let own = self.dict.get(names::DEST, r).map(|d| d.get().clone());
let dest = Dest::create(catalog, own.as_ref(), r, limits, diags);
if dest.array.is_some() {
return dest;
}
self.action(r)
.map(|action| action.dest(catalog, r, limits, diags))
.unwrap_or_default()
}
}
#[must_use]
pub fn page_links<R: Resolve>(page: &Dict, r: &R) -> Vec<Option<Link>> {
let Some(array) = page.array(obj_names::ANNOTS, r) else {
return Vec::new();
};
(0..array.len())
.map(|index| {
let dict = array.dict_at(index, r)?;
let subtype = dict.byte_string(obj_names::SUBTYPE, r)?;
(subtype == b"Link").then(|| Link::new(dict))
})
.collect()
}
#[must_use]
#[cfg(test)]
pub(crate) fn link_at_point<R: Resolve>(
links: &[Option<Link>],
point: Point,
r: &R,
) -> Option<(usize, Link)> {
for (index, slot) in links.iter().enumerate().rev() {
let Some(link) = slot else { continue };
if geom::contains(link.rect(r), point) {
return Some((index, link.clone()));
}
}
None
}
#[cfg(test)]
mod tests {
use super::{link_at_point, page_links};
use kurbo::Point;
use pdfrum_object::{Array, Dict, Name, NoResolve, Object, PdfString};
fn dict(pairs: &[(&str, Object)]) -> Dict {
Dict::from_pairs(
pairs
.iter()
.map(|(k, v)| (Name::from(*k), v.clone()))
.collect::<Vec<_>>(),
)
}
fn annot(subtype: Object, rect: [f32; 4]) -> Object {
Object::Dict(dict(&[
("Subtype", subtype),
("Rect", Object::Array(Array::of(rect.map(Object::from)))),
]))
}
#[test]
fn non_links_leave_gaps_so_the_z_order_stays_the_annots_index() {
let page = dict(&[(
"Annots",
Object::Array(Array::of([
annot(Object::Name(Name::from("Square")), [0.0, 0.0, 10.0, 10.0]),
annot(Object::Name(Name::from("Link")), [0.0, 0.0, 10.0, 10.0]),
])),
)]);
let links = page_links(&page, &NoResolve);
assert_eq!(links.len(), 2);
assert!(links.first().is_some_and(Option::is_none));
assert!(links.get(1).is_some_and(Option::is_some));
assert_eq!(
link_at_point(&links, Point::new(5.0, 5.0), &NoResolve).map(|(i, _)| i),
Some(1)
);
}
#[test]
fn a_string_subtype_still_names_a_link() {
let page = dict(&[(
"Annots",
Object::Array(Array::of([annot(
Object::Str(PdfString::literal(b"Link")),
[0.0, 0.0, 1.0, 1.0],
)])),
)]);
assert!(
page_links(&page, &NoResolve)
.first()
.is_some_and(Option::is_some)
);
}
#[test]
fn the_topmost_link_wins_an_overlap() {
let page = dict(&[(
"Annots",
Object::Array(Array::of([
annot(Object::Name(Name::from("Link")), [0.0, 0.0, 20.0, 20.0]),
annot(Object::Name(Name::from("Link")), [0.0, 0.0, 20.0, 20.0]),
])),
)]);
let links = page_links(&page, &NoResolve);
assert_eq!(
link_at_point(&links, Point::new(1.0, 1.0), &NoResolve).map(|(i, _)| i),
Some(1)
);
}
#[test]
fn containment_is_inclusive_on_the_edges() {
let page = dict(&[(
"Annots",
Object::Array(Array::of([annot(
Object::Name(Name::from("Link")),
[10.0, 10.0, 20.0, 20.0],
)])),
)]);
let links = page_links(&page, &NoResolve);
assert!(link_at_point(&links, Point::new(10.0, 10.0), &NoResolve).is_some());
assert!(link_at_point(&links, Point::new(20.0, 20.0), &NoResolve).is_some());
assert!(link_at_point(&links, Point::new(9.9, 15.0), &NoResolve).is_none());
}
#[test]
fn a_page_with_no_annots_has_no_links() {
assert!(page_links(&Dict::new(), &NoResolve).is_empty());
}
}