use pdfrum_common::{DiagKind, Diagnostics, Limits, PageIndex, Severity};
use pdfrum_object::{Array, Dict, Object, Resolve};
use crate::nav::name_tree;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ZoomMode {
#[default]
Unknown,
Xyz,
Fit,
FitH,
FitV,
FitR,
FitB,
FitBH,
FitBV,
}
const MODES: [(ZoomMode, &[u8], usize); 8] = [
(ZoomMode::Xyz, b"XYZ", 3),
(ZoomMode::Fit, b"Fit", 0),
(ZoomMode::FitH, b"FitH", 1),
(ZoomMode::FitV, b"FitV", 1),
(ZoomMode::FitR, b"FitR", 4),
(ZoomMode::FitB, b"FitB", 0),
(ZoomMode::FitBH, b"FitBH", 1),
(ZoomMode::FitBV, b"FitBV", 1),
];
#[derive(Debug, Clone, Copy, PartialEq, Default)]
pub struct Xyz {
pub x: Option<f32>,
pub y: Option<f32>,
pub zoom: Option<f32>,
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct Dest {
pub array: Option<Array>,
}
impl Dest {
#[must_use]
pub fn create<R: Resolve>(
catalog: &Dict,
object: Option<&Object>,
r: &R,
limits: &Limits,
diags: &mut Diagnostics,
) -> Dest {
let array = match object {
Some(Object::Array(array)) => Some(array.clone()),
Some(named @ (Object::Str(_) | Object::Name(_))) => {
let key = named.to_byte_string();
name_tree::lookup_named_dest(catalog, &key, r, limits, diags)
}
_ => None,
};
Dest { array }
}
#[must_use]
pub fn zoom_mode<R: Resolve>(&self, r: &R) -> ZoomMode {
let Some(array) = &self.array else {
return ZoomMode::Unknown;
};
let Some(spelling) = array.get(1, r).map(|v| v.get().to_byte_string()) else {
return ZoomMode::Unknown;
};
MODES
.iter()
.find(|(_, name, _)| *name == spelling.as_slice())
.map_or(ZoomMode::Unknown, |(mode, _, _)| *mode)
}
#[must_use]
pub fn num_params<R: Resolve>(&self, r: &R) -> usize {
let Some(array) = &self.array else {
return 0;
};
if array.len() < 2 {
return 0;
}
let mode = self.zoom_mode(r);
let max = MODES
.iter()
.find(|(m, _, _)| *m == mode)
.map_or(0, |(_, _, max)| *max);
max.min(array.len() - 2)
}
#[must_use]
pub fn param(&self, index: usize) -> f32 {
self.array
.as_ref()
.map_or(0.0, |array| array.number_at_or_zero(index + 2))
}
#[must_use]
pub fn params_all(&self) -> Vec<f32> {
let Some(array) = &self.array else {
return Vec::new();
};
(2..array.len())
.map(|index| array.number_at_or_zero(index))
.collect()
}
#[must_use]
pub fn xyz<R: Resolve>(&self, r: &R) -> Option<Xyz> {
let array = self.array.as_ref()?;
if array.len() < 5 {
return None;
}
let name = array.get(1, r)?.get().as_name()?.as_bytes().to_vec();
if name != b"XYZ" {
return None;
}
let number = |index: usize| {
array
.get(index, r)
.and_then(|v| v.get().as_number().and_then(Object::number))
};
Some(Xyz {
x: number(2),
y: number(3),
zoom: number(4).filter(|z| *z != 0.0),
})
}
#[must_use]
pub fn page_index<R: Resolve>(
&self,
r: &R,
page_index_of: impl Fn(u32) -> Option<PageIndex>,
diags: &mut Diagnostics,
) -> Option<PageIndex> {
let entry = self.array.as_ref()?.get(0, r)?;
if let Some(number) = entry.get().as_number() {
return u32::try_from(number.as_int()?).ok().map(PageIndex::from);
}
if entry.get().as_dict().is_none() {
diags.record(Severity::Suspicious, DiagKind::DestPageUnresolved, None);
return None;
}
let index = self
.array
.as_ref()
.and_then(|array| array.reference_at(0))
.and_then(|r| page_index_of(r.num));
if index.is_none() {
diags.record(Severity::Suspicious, DiagKind::DestPageUnresolved, None);
}
index
}
}
#[cfg(test)]
mod tests {
use super::{Dest, ZoomMode};
use pdfrum_common::{Diagnostics, PageIndex};
use pdfrum_object::{Array, Name, NoResolve, Object, PdfString};
fn dest(values: Vec<Object>) -> Dest {
Dest {
array: Some(Array::of(values)),
}
}
fn xyz_dest(x: Object, y: Object, z: Object) -> Dest {
dest(vec![
Object::Int(0),
Object::Name(Name::from("XYZ")),
x,
y,
z,
])
}
#[test]
fn xyz_needs_five_elements() {
let short = dest(vec![
Object::Int(0),
Object::Name(Name::from("XYZ")),
Object::Int(4),
]);
assert_eq!(short.xyz(&NoResolve), None);
let full = xyz_dest(Object::Int(4), Object::Int(5), Object::Int(6));
let got = full.xyz(&NoResolve).expect("five elements");
assert_eq!((got.x, got.y, got.zoom), (Some(4.0), Some(5.0), Some(6.0)));
}
#[test]
fn a_zero_zoom_reads_as_absent_but_a_zero_coordinate_does_not() {
let got = xyz_dest(Object::Int(0), Object::Int(0), Object::Int(0))
.xyz(&NoResolve)
.expect("well formed");
assert_eq!((got.x, got.y, got.zoom), (Some(0.0), Some(0.0), None));
}
#[test]
fn a_negative_zoom_is_kept() {
let got = xyz_dest(Object::Int(0), Object::Int(0), Object::from(-200.0_f32))
.xyz(&NoResolve)
.expect("well formed");
assert_eq!(got.zoom, Some(-200.0));
}
#[test]
fn a_null_coordinate_is_absent_while_the_destination_still_reads() {
let got = xyz_dest(Object::Null, Object::Null, Object::Null)
.xyz(&NoResolve)
.expect("still well formed");
assert_eq!((got.x, got.y, got.zoom), (None, None, None));
}
#[test]
fn xyz_type_filters_the_mode_name_where_zoom_mode_does_not() {
let stringly = dest(vec![
Object::Int(0),
Object::Str(PdfString::literal(b"XYZ")),
Object::Int(1),
Object::Int(2),
Object::Int(3),
]);
assert_eq!(stringly.zoom_mode(&NoResolve), ZoomMode::Xyz);
assert_eq!(stringly.xyz(&NoResolve), None);
}
#[test]
fn a_literal_unknown_mode_name_matches_nothing() {
let d = dest(vec![Object::Int(0), Object::Name(Name::from("Unknown"))]);
assert_eq!(d.zoom_mode(&NoResolve), ZoomMode::Unknown);
assert_eq!(d.num_params(&NoResolve), 0);
}
#[test]
fn the_parameter_count_is_the_modes_maximum_capped_by_the_array() {
let full = dest(vec![
Object::Int(0),
Object::Name(Name::from("FitR")),
Object::Int(1),
Object::Int(2),
]);
assert_eq!(full.num_params(&NoResolve), 2);
assert!((full.param(0) - 1.0).abs() < f32::EPSILON);
assert!(full.param(99).abs() < f32::EPSILON);
}
#[test]
fn a_page_number_is_returned_verbatim_with_no_bounds_check() {
let d = dest(vec![Object::Int(11), Object::Name(Name::from("Fit"))]);
let mut diags = Diagnostics::default();
assert_eq!(
d.page_index(&NoResolve, |_| Some(PageIndex::FIRST), &mut diags),
Some(PageIndex::new(11))
);
}
#[test]
fn a_null_destination_answers_none() {
let mut diags = Diagnostics::default();
assert_eq!(
Dest::default().page_index(&NoResolve, |_| Some(PageIndex::new(3)), &mut diags),
None
);
}
}