use lopdf::{Document, Object, ObjectId};
use crate::pdfium_backend::LinkAnnot;
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct PageGeom {
pub width: f32,
pub height: f32,
pub rotation: u16,
}
impl PageGeom {
pub fn unrotated(&self) -> (f32, f32) {
if self.rotation == 90 || self.rotation == 270 {
(self.height, self.width)
} else {
(self.width, self.height)
}
}
}
pub struct PdfMeta {
doc: Document,
pages: Vec<ObjectId>,
}
impl PdfMeta {
pub fn open(bytes: &[u8]) -> Option<Self> {
Self::open_with_password(bytes, None).ok().flatten()
}
pub fn open_with_password(
bytes: &[u8],
password: Option<&str>,
) -> Result<Option<Self>, crate::PdfError> {
use crate::textparse::OpenError;
match crate::textparse::open_document(bytes, password) {
Ok(doc) => {
let mut pages: Vec<_> = doc.get_pages().into_iter().collect();
pages.sort_by_key(|(n, _)| *n);
Ok(Some(Self {
doc,
pages: pages.into_iter().map(|(_, pid)| pid).collect(),
}))
}
Err(OpenError::Unreadable) => Ok(None),
Err(OpenError::Password) => Err(crate::PdfError::Document(
if password.is_some() {
"the PDF is encrypted and the password is wrong"
} else {
"the PDF is encrypted: a password is required"
}
.into(),
)),
}
}
pub fn page_count(&self) -> usize {
self.pages.len()
}
pub(crate) fn doc(&self) -> &Document {
&self.doc
}
pub(crate) fn page_id(&self, index: usize) -> Option<ObjectId> {
self.pages.get(index).copied()
}
pub fn geometry(&self, index: usize) -> Option<PageGeom> {
let pid = *self.pages.get(index)?;
let pb = crate::textparse::page_box(&self.doc, pid);
let rotation = self.rotation(pid);
let (width, height) = if rotation == 90 || rotation == 270 {
(pb.h, pb.w)
} else {
(pb.w, pb.h)
};
Some(PageGeom {
width,
height,
rotation,
})
}
pub fn links(&self, index: usize) -> Vec<LinkAnnot> {
let Some(&pid) = self.pages.get(index) else {
return Vec::new();
};
let pb = crate::textparse::page_box(&self.doc, pid);
let top = pb.top();
let mut out = Vec::new();
let Some(annots) = self
.page_dict(pid)
.and_then(|d| d.get(b"Annots").ok())
.and_then(|o| self.deref(o))
.and_then(|o| o.as_array().ok())
else {
return out;
};
for annot in annots {
let Some(dict) = self.deref(annot).and_then(|o| o.as_dict().ok()) else {
continue;
};
if !name_is(dict.get(b"Subtype").ok(), b"Link") {
continue;
}
let Some(action) = dict
.get(b"A")
.ok()
.and_then(|o| self.deref(o))
.and_then(|o| o.as_dict().ok())
else {
continue;
};
if !name_is(action.get(b"S").ok(), b"URI") {
continue;
}
let Some(uri) = action
.get(b"URI")
.ok()
.and_then(|o| self.deref(o))
.and_then(|o| o.as_str().ok())
.map(|b| String::from_utf8_lossy(b).into_owned())
else {
continue;
};
if !["http://", "https://", "mailto:", "tel:"]
.iter()
.any(|s| uri.starts_with(s))
{
continue;
}
let Some(rect) = dict
.get(b"Rect")
.ok()
.and_then(|o| self.deref(o))
.and_then(|o| o.as_array().ok())
else {
continue;
};
let v: Vec<f32> = rect
.iter()
.filter_map(|o| self.deref(o).and_then(number).map(|x| x as f32))
.collect();
if v.len() != 4 || v.iter().any(|x| !x.is_finite()) {
continue;
}
let (x0, y0, x1, y1) = (
v[0].min(v[2]),
v[1].min(v[3]),
v[0].max(v[2]),
v[1].max(v[3]),
);
out.push(LinkAnnot {
l: x0 - pb.l,
t: top - y1,
r: x1 - pb.l,
b: top - y0,
uri,
});
}
out
}
fn rotation(&self, pid: ObjectId) -> u16 {
let mut id = pid;
for _ in 0..32 {
let Some(dict) = self.page_dict(id) else {
return 0;
};
if let Some(obj) = dict.get(b"Rotate").ok().and_then(|o| self.deref(o)) {
let rotate = number(obj).unwrap_or(0.0) as i64;
let mut quarter = (rotate / 90) % 4;
if quarter < 0 {
quarter += 4;
}
return (quarter * 90) as u16;
}
match dict.get(b"Parent").ok().and_then(|o| o.as_reference().ok()) {
Some(parent) => id = parent,
None => return 0,
}
}
0
}
fn page_dict(&self, id: ObjectId) -> Option<&lopdf::Dictionary> {
self.doc.get_object(id).ok()?.as_dict().ok()
}
fn deref<'a>(&'a self, obj: &'a Object) -> Option<&'a Object> {
match obj {
Object::Reference(id) => self.doc.get_object(*id).ok(),
other => Some(other),
}
}
}
fn name_is(obj: Option<&Object>, name: &[u8]) -> bool {
matches!(obj, Some(Object::Name(n)) if n == name)
}
fn number(obj: &Object) -> Option<f64> {
match obj {
Object::Integer(i) => Some(*i as f64),
Object::Real(r) => Some(f64::from(*r)),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn fixture(rel: &str) -> Vec<u8> {
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
std::fs::read(root.join(rel)).unwrap_or_else(|e| panic!("{rel}: {e}"))
}
#[test]
fn password_protected_files_need_their_password() {
let bytes = fixture("tests/data/pdf_password/sources/2206.01062_pg3.pdf");
assert!(PdfMeta::open(&bytes).is_none());
let err = PdfMeta::open_with_password(&bytes, None)
.err()
.expect("no password");
assert!(err.to_string().contains("password is required"), "{err}");
let err = PdfMeta::open_with_password(&bytes, Some("nope"))
.err()
.expect("wrong password");
assert!(err.to_string().contains("password is wrong"), "{err}");
let meta = PdfMeta::open_with_password(&bytes, Some("1234"))
.unwrap()
.expect("readable");
assert_eq!(meta.page_count(), 1);
let mut parser =
crate::textparse::PageTextParser::open_with_password(&bytes, Some("1234")).unwrap();
assert!(!parser.cells(0).prose.is_empty());
}
#[test]
fn page_count_and_geometry_of_a_plain_paper() {
let meta = PdfMeta::open(&fixture("tests/data/pdf/sources/2206.01062.pdf")).unwrap();
assert_eq!(meta.page_count(), 9);
let g = meta.geometry(0).unwrap();
assert_eq!((g.width, g.height, g.rotation), (612.0, 792.0, 0));
assert!(meta.geometry(9).is_none());
}
#[test]
fn rotated_pages_report_the_display_frame() {
for (rel, rot) in [
("tests/data/pdf/sources/base14_fonts_rot90.pdf", 90u16),
("tests/data/pdf/sources/base14_fonts_rot180.pdf", 180),
("tests/data/pdf/sources/base14_fonts_rot270.pdf", 270),
] {
let meta = PdfMeta::open(&fixture(rel)).unwrap();
let g = meta.geometry(0).unwrap();
assert_eq!(g.rotation, rot, "{rel}");
let plain = PdfMeta::open(&fixture("tests/data/pdf/sources/base14_fonts.pdf"))
.unwrap()
.geometry(0)
.unwrap();
assert_eq!(g.unrotated(), (plain.width, plain.height), "{rel}");
if rot == 90 || rot == 270 {
assert_eq!((g.width, g.height), (plain.height, plain.width), "{rel}");
}
}
}
#[test]
fn uri_links_are_read_from_the_annotations() {
let meta = PdfMeta::open(&fixture("tests/data/pdf/sources/2206.01062.pdf")).unwrap();
let links = meta.links(0);
assert_eq!(links.len(), 3, "{links:?}");
for l in &links {
assert!(l.uri.starts_with("https://"), "{}", l.uri);
assert!(l.l < l.r && l.t < l.b, "{l:?}");
assert!(l.b <= meta.geometry(0).unwrap().height + 1.0, "{l:?}");
}
let pg9 = PdfMeta::open(&fixture("tests/data/pdf/sources/2305.03393v1-pg9.pdf")).unwrap();
assert!(pg9.links(0).is_empty());
assert!(meta.links(99).is_empty());
}
}