Skip to main content

docling_pdf/
pdf_meta.rs

1//! Page metadata straight from the PDF object model (lopdf): page count, the
2//! display geometry pdfium reports, `/Rotate`, and the URI link annotations.
3//!
4//! The first step of retiring pdfium (#478's follow-up): everything the
5//! pipeline used to ask pdfium for *besides* rasterizing — `FPDF_GetPageCount`,
6//! `FPDF_GetPageWidthF/HeightF`, `FPDFPage_GetRotation`, `FPDFLink_Enumerate`
7//! — is answered here, so a checkout with the docling-parse renderer plugin and
8//! no `libpdfium` converts a PDF end to end, and pdfium is left with two
9//! fallback roles: the text layer of a file the pure-Rust parser cannot read,
10//! and the raster when no renderer plugin is present.
11//!
12//! Every answer matches pdfium's on the corpus (`pdfium_backend::tests` checks
13//! geometry, rotation and links against the library when it is installed):
14//! the page box is `textparse::page_box` (CropBox ∩ MediaBox with pdfium's
15//! fallbacks), the rotation is pdfium's `(rotate / 90) % 4` normalization of
16//! the inherited `/Rotate`, and a link is a `/Link` annotation whose `/A`
17//! action is a `/URI` one.
18//!
19//! Pure Rust (lopdf), no feature gate — it compiles for the `pdf-text` (wasm)
20//! build too, where it is what `convert_text_layer` could grow into.
21
22use lopdf::{Document, Object, ObjectId};
23
24use crate::pdfium_backend::LinkAnnot;
25
26/// A page's display geometry, the way pdfium's `FPDF_GetPageWidthF/HeightF`
27/// and `FPDFPage_GetRotation` report it.
28#[derive(Debug, Clone, Copy, PartialEq)]
29pub struct PageGeom {
30    /// Display-frame width in points (`/Rotate` applied: swapped with the
31    /// height for 90° and 270°).
32    pub width: f32,
33    /// Display-frame height in points.
34    pub height: f32,
35    /// `/Rotate`, normalized to 0 / 90 / 180 / 270.
36    pub rotation: u16,
37}
38
39impl PageGeom {
40    /// The unrotated (content-frame) box size, `(width, height)`.
41    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
50/// The parsed document with its pages in document order.
51pub struct PdfMeta {
52    doc: Document,
53    /// Page object ids, page 1 first.
54    pages: Vec<ObjectId>,
55}
56
57impl PdfMeta {
58    /// Load `bytes` (with the parser's xref/stream repairs); `None` when lopdf
59    /// cannot read the file at all, or when it is encrypted beyond the empty
60    /// user password.
61    pub fn open(bytes: &[u8]) -> Option<Self> {
62        Self::open_with_password(bytes, None).ok().flatten()
63    }
64
65    /// [`open`](Self::open) with the document's password. `Ok(None)`: lopdf
66    /// cannot read the file (pdfium's turn, when compiled in); `Err`: the file
67    /// is encrypted and the password is missing or wrong — the error docling
68    /// raises too, instead of a document whose every stream decodes to nothing.
69    pub fn open_with_password(
70        bytes: &[u8],
71        password: Option<&str>,
72    ) -> Result<Option<Self>, crate::PdfError> {
73        use crate::textparse::OpenError;
74        use crate::EncryptionError;
75        match crate::textparse::open_document(bytes, password) {
76            Ok(doc) => {
77                let mut pages: Vec<_> = doc.get_pages().into_iter().collect();
78                pages.sort_by_key(|(n, _)| *n);
79                Ok(Some(Self {
80                    doc,
81                    pages: pages.into_iter().map(|(_, pid)| pid).collect(),
82                }))
83            }
84            Err(OpenError::Unreadable) => Ok(None),
85            // Typed (#636): a caller tells "ask for a password" from "the
86            // file is damaged" without reading the message.
87            Err(OpenError::Password) => Err(crate::PdfError::Encrypted(if password.is_some() {
88                EncryptionError::WrongPassword
89            } else {
90                EncryptionError::NeedPassword
91            })),
92        }
93    }
94
95    /// Number of pages (`FPDF_GetPageCount`).
96    pub fn page_count(&self) -> usize {
97        self.pages.len()
98    }
99
100    /// The parsed document, for the other pure-Rust readers (`raster`).
101    pub(crate) fn doc(&self) -> &Document {
102        &self.doc
103    }
104
105    /// The page object id of the 0-based `index`.
106    pub(crate) fn page_id(&self, index: usize) -> Option<ObjectId> {
107        self.pages.get(index).copied()
108    }
109
110    /// Display geometry of the 0-based `index`; `None` past the last page.
111    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    /// The URI link annotations of the 0-based `index`, as top-left-origin
128    /// rects in the page's *unrotated* content frame — the frame every text
129    /// coordinate lives in — counted from the display box's corner like the
130    /// glyphs are, restricted to the web/mail/tel schemes the Markdown export
131    /// renders (`extract_links`'s rule). Empty past the last page.
132    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    /// pdfium's `CPDF_Page::GetPageRotation`: the inherited `/Rotate`
213    /// integer, `(rotate / 90) % 4` with a negative result wrapped, in degrees.
214    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    /// Follow one level of reference (a `/Rect` or `/A` is often indirect).
241    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    /// A password-protected fixture: no password is the error docling raises,
271    /// the right one opens the object model and the text layer.
272    #[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_eq!(
280            err.to_string(),
281            "pdf: the PDF is encrypted: a password is required"
282        );
283        assert!(
284            matches!(
285                err,
286                crate::PdfError::Encrypted(crate::EncryptionError::NeedPassword)
287            ),
288            "{err:?}"
289        );
290        let err = PdfMeta::open_with_password(&bytes, Some("nope"))
291            .err()
292            .expect("wrong password");
293        assert_eq!(
294            err.to_string(),
295            "pdf: the PDF is encrypted and the password is wrong"
296        );
297        assert!(
298            matches!(
299                err,
300                crate::PdfError::Encrypted(crate::EncryptionError::WrongPassword)
301            ),
302            "{err:?}"
303        );
304        // The typed value is on the source chain for callers that only see
305        // `dyn Error` (#636).
306        let chained = std::error::Error::source(&err)
307            .and_then(|s| s.downcast_ref::<crate::EncryptionError>())
308            .expect("source");
309        assert_eq!(*chained, crate::EncryptionError::WrongPassword);
310        let meta = PdfMeta::open_with_password(&bytes, Some("1234"))
311            .unwrap()
312            .expect("readable");
313        assert_eq!(meta.page_count(), 1);
314        let mut parser =
315            crate::textparse::PageTextParser::open_with_password(&bytes, Some("1234")).unwrap();
316        assert!(!parser.cells(0).prose.is_empty());
317    }
318
319    #[test]
320    fn page_count_and_geometry_of_a_plain_paper() {
321        let meta = PdfMeta::open(&fixture("tests/data/pdf/sources/2206.01062.pdf")).unwrap();
322        assert_eq!(meta.page_count(), 9);
323        let g = meta.geometry(0).unwrap();
324        assert_eq!((g.width, g.height, g.rotation), (612.0, 792.0, 0));
325        assert!(meta.geometry(9).is_none());
326    }
327
328    /// The `/Rotate` fixtures: display size swaps for 90/270 and the content
329    /// frame stays the unrotated box.
330    #[test]
331    fn rotated_pages_report_the_display_frame() {
332        for (rel, rot) in [
333            ("tests/data/pdf/sources/base14_fonts_rot90.pdf", 90u16),
334            ("tests/data/pdf/sources/base14_fonts_rot180.pdf", 180),
335            ("tests/data/pdf/sources/base14_fonts_rot270.pdf", 270),
336        ] {
337            let meta = PdfMeta::open(&fixture(rel)).unwrap();
338            let g = meta.geometry(0).unwrap();
339            assert_eq!(g.rotation, rot, "{rel}");
340            let plain = PdfMeta::open(&fixture("tests/data/pdf/sources/base14_fonts.pdf"))
341                .unwrap()
342                .geometry(0)
343                .unwrap();
344            assert_eq!(g.unrotated(), (plain.width, plain.height), "{rel}");
345            if rot == 90 || rot == 270 {
346                assert_eq!((g.width, g.height), (plain.height, plain.width), "{rel}");
347            }
348        }
349    }
350
351    /// arXiv papers carry hyperref `/Link` annotations: 2206's first page has
352    /// three `/URI` actions (the DOI and the DocLayNet data links) and `/GoTo`
353    /// ones, which are not hyperlinks for the Markdown and are left out like
354    /// pdfium's `extract_links` leaves them out; 2305-pg9's links are all
355    /// `/GoTo`.
356    #[test]
357    fn uri_links_are_read_from_the_annotations() {
358        let meta = PdfMeta::open(&fixture("tests/data/pdf/sources/2206.01062.pdf")).unwrap();
359        let links = meta.links(0);
360        assert_eq!(links.len(), 3, "{links:?}");
361        for l in &links {
362            assert!(l.uri.starts_with("https://"), "{}", l.uri);
363            assert!(l.l < l.r && l.t < l.b, "{l:?}");
364            assert!(l.b <= meta.geometry(0).unwrap().height + 1.0, "{l:?}");
365        }
366        let pg9 = PdfMeta::open(&fixture("tests/data/pdf/sources/2305.03393v1-pg9.pdf")).unwrap();
367        assert!(pg9.links(0).is_empty());
368        assert!(meta.links(99).is_empty());
369    }
370}