1use lopdf::{Document, Object, ObjectId};
23
24use crate::pdfium_backend::LinkAnnot;
25
26#[derive(Debug, Clone, Copy, PartialEq)]
29pub struct PageGeom {
30 pub width: f32,
33 pub height: f32,
35 pub rotation: u16,
37}
38
39impl PageGeom {
40 pub fn unrotated(&self) -> (f32, f32) {
42 if self.rotation == 90 || self.rotation == 270 {
43 (self.height, self.width)
44 } else {
45 (self.width, self.height)
46 }
47 }
48}
49
50pub struct PdfMeta {
52 doc: Document,
53 pages: Vec<ObjectId>,
55}
56
57impl PdfMeta {
58 pub fn open(bytes: &[u8]) -> Option<Self> {
62 Self::open_with_password(bytes, None).ok().flatten()
63 }
64
65 pub fn open_with_password(
70 bytes: &[u8],
71 password: Option<&str>,
72 ) -> Result<Option<Self>, crate::PdfError> {
73 use crate::textparse::OpenError;
74 match crate::textparse::open_document(bytes, password) {
75 Ok(doc) => {
76 let mut pages: Vec<_> = doc.get_pages().into_iter().collect();
77 pages.sort_by_key(|(n, _)| *n);
78 Ok(Some(Self {
79 doc,
80 pages: pages.into_iter().map(|(_, pid)| pid).collect(),
81 }))
82 }
83 Err(OpenError::Unreadable) => Ok(None),
84 Err(OpenError::Password) => Err(crate::PdfError::Document(
85 if password.is_some() {
86 "the PDF is encrypted and the password is wrong"
87 } else {
88 "the PDF is encrypted: a password is required"
89 }
90 .into(),
91 )),
92 }
93 }
94
95 pub fn page_count(&self) -> usize {
97 self.pages.len()
98 }
99
100 pub(crate) fn doc(&self) -> &Document {
102 &self.doc
103 }
104
105 pub(crate) fn page_id(&self, index: usize) -> Option<ObjectId> {
107 self.pages.get(index).copied()
108 }
109
110 pub fn geometry(&self, index: usize) -> Option<PageGeom> {
112 let pid = *self.pages.get(index)?;
113 let pb = crate::textparse::page_box(&self.doc, pid);
114 let rotation = self.rotation(pid);
115 let (width, height) = if rotation == 90 || rotation == 270 {
116 (pb.h, pb.w)
117 } else {
118 (pb.w, pb.h)
119 };
120 Some(PageGeom {
121 width,
122 height,
123 rotation,
124 })
125 }
126
127 pub fn links(&self, index: usize) -> Vec<LinkAnnot> {
133 let Some(&pid) = self.pages.get(index) else {
134 return Vec::new();
135 };
136 let pb = crate::textparse::page_box(&self.doc, pid);
137 let top = pb.top();
138 let mut out = Vec::new();
139 let Some(annots) = self
140 .page_dict(pid)
141 .and_then(|d| d.get(b"Annots").ok())
142 .and_then(|o| self.deref(o))
143 .and_then(|o| o.as_array().ok())
144 else {
145 return out;
146 };
147 for annot in annots {
148 let Some(dict) = self.deref(annot).and_then(|o| o.as_dict().ok()) else {
149 continue;
150 };
151 if !name_is(dict.get(b"Subtype").ok(), b"Link") {
152 continue;
153 }
154 let Some(action) = dict
155 .get(b"A")
156 .ok()
157 .and_then(|o| self.deref(o))
158 .and_then(|o| o.as_dict().ok())
159 else {
160 continue;
161 };
162 if !name_is(action.get(b"S").ok(), b"URI") {
163 continue;
164 }
165 let Some(uri) = action
166 .get(b"URI")
167 .ok()
168 .and_then(|o| self.deref(o))
169 .and_then(|o| o.as_str().ok())
170 .map(|b| String::from_utf8_lossy(b).into_owned())
171 else {
172 continue;
173 };
174 if !["http://", "https://", "mailto:", "tel:"]
175 .iter()
176 .any(|s| uri.starts_with(s))
177 {
178 continue;
179 }
180 let Some(rect) = dict
181 .get(b"Rect")
182 .ok()
183 .and_then(|o| self.deref(o))
184 .and_then(|o| o.as_array().ok())
185 else {
186 continue;
187 };
188 let v: Vec<f32> = rect
189 .iter()
190 .filter_map(|o| self.deref(o).and_then(number).map(|x| x as f32))
191 .collect();
192 if v.len() != 4 || v.iter().any(|x| !x.is_finite()) {
193 continue;
194 }
195 let (x0, y0, x1, y1) = (
196 v[0].min(v[2]),
197 v[1].min(v[3]),
198 v[0].max(v[2]),
199 v[1].max(v[3]),
200 );
201 out.push(LinkAnnot {
202 l: x0 - pb.l,
203 t: top - y1,
204 r: x1 - pb.l,
205 b: top - y0,
206 uri,
207 });
208 }
209 out
210 }
211
212 fn rotation(&self, pid: ObjectId) -> u16 {
215 let mut id = pid;
216 for _ in 0..32 {
217 let Some(dict) = self.page_dict(id) else {
218 return 0;
219 };
220 if let Some(obj) = dict.get(b"Rotate").ok().and_then(|o| self.deref(o)) {
221 let rotate = number(obj).unwrap_or(0.0) as i64;
222 let mut quarter = (rotate / 90) % 4;
223 if quarter < 0 {
224 quarter += 4;
225 }
226 return (quarter * 90) as u16;
227 }
228 match dict.get(b"Parent").ok().and_then(|o| o.as_reference().ok()) {
229 Some(parent) => id = parent,
230 None => return 0,
231 }
232 }
233 0
234 }
235
236 fn page_dict(&self, id: ObjectId) -> Option<&lopdf::Dictionary> {
237 self.doc.get_object(id).ok()?.as_dict().ok()
238 }
239
240 fn deref<'a>(&'a self, obj: &'a Object) -> Option<&'a Object> {
242 match obj {
243 Object::Reference(id) => self.doc.get_object(*id).ok(),
244 other => Some(other),
245 }
246 }
247}
248
249fn name_is(obj: Option<&Object>, name: &[u8]) -> bool {
250 matches!(obj, Some(Object::Name(n)) if n == name)
251}
252
253fn number(obj: &Object) -> Option<f64> {
254 match obj {
255 Object::Integer(i) => Some(*i as f64),
256 Object::Real(r) => Some(f64::from(*r)),
257 _ => None,
258 }
259}
260
261#[cfg(test)]
262mod tests {
263 use super::*;
264
265 fn fixture(rel: &str) -> Vec<u8> {
266 let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
267 std::fs::read(root.join(rel)).unwrap_or_else(|e| panic!("{rel}: {e}"))
268 }
269
270 #[test]
273 fn password_protected_files_need_their_password() {
274 let bytes = fixture("tests/data/pdf_password/sources/2206.01062_pg3.pdf");
275 assert!(PdfMeta::open(&bytes).is_none());
276 let err = PdfMeta::open_with_password(&bytes, None)
277 .err()
278 .expect("no password");
279 assert!(err.to_string().contains("password is required"), "{err}");
280 let err = PdfMeta::open_with_password(&bytes, Some("nope"))
281 .err()
282 .expect("wrong password");
283 assert!(err.to_string().contains("password is wrong"), "{err}");
284 let meta = PdfMeta::open_with_password(&bytes, Some("1234"))
285 .unwrap()
286 .expect("readable");
287 assert_eq!(meta.page_count(), 1);
288 let mut parser =
289 crate::textparse::PageTextParser::open_with_password(&bytes, Some("1234")).unwrap();
290 assert!(!parser.cells(0).prose.is_empty());
291 }
292
293 #[test]
294 fn page_count_and_geometry_of_a_plain_paper() {
295 let meta = PdfMeta::open(&fixture("tests/data/pdf/sources/2206.01062.pdf")).unwrap();
296 assert_eq!(meta.page_count(), 9);
297 let g = meta.geometry(0).unwrap();
298 assert_eq!((g.width, g.height, g.rotation), (612.0, 792.0, 0));
299 assert!(meta.geometry(9).is_none());
300 }
301
302 #[test]
305 fn rotated_pages_report_the_display_frame() {
306 for (rel, rot) in [
307 ("tests/data/pdf/sources/base14_fonts_rot90.pdf", 90u16),
308 ("tests/data/pdf/sources/base14_fonts_rot180.pdf", 180),
309 ("tests/data/pdf/sources/base14_fonts_rot270.pdf", 270),
310 ] {
311 let meta = PdfMeta::open(&fixture(rel)).unwrap();
312 let g = meta.geometry(0).unwrap();
313 assert_eq!(g.rotation, rot, "{rel}");
314 let plain = PdfMeta::open(&fixture("tests/data/pdf/sources/base14_fonts.pdf"))
315 .unwrap()
316 .geometry(0)
317 .unwrap();
318 assert_eq!(g.unrotated(), (plain.width, plain.height), "{rel}");
319 if rot == 90 || rot == 270 {
320 assert_eq!((g.width, g.height), (plain.height, plain.width), "{rel}");
321 }
322 }
323 }
324
325 #[test]
331 fn uri_links_are_read_from_the_annotations() {
332 let meta = PdfMeta::open(&fixture("tests/data/pdf/sources/2206.01062.pdf")).unwrap();
333 let links = meta.links(0);
334 assert_eq!(links.len(), 3, "{links:?}");
335 for l in &links {
336 assert!(l.uri.starts_with("https://"), "{}", l.uri);
337 assert!(l.l < l.r && l.t < l.b, "{l:?}");
338 assert!(l.b <= meta.geometry(0).unwrap().height + 1.0, "{l:?}");
339 }
340 let pg9 = PdfMeta::open(&fixture("tests/data/pdf/sources/2305.03393v1-pg9.pdf")).unwrap();
341 assert!(pg9.links(0).is_empty());
342 assert!(meta.links(99).is_empty());
343 }
344}