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        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    /// 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!(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    /// The `/Rotate` fixtures: display size swaps for 90/270 and the content
303    /// frame stays the unrotated box.
304    #[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    /// arXiv papers carry hyperref `/Link` annotations: 2206's first page has
326    /// three `/URI` actions (the DOI and the DocLayNet data links) and `/GoTo`
327    /// ones, which are not hyperlinks for the Markdown and are left out like
328    /// pdfium's `extract_links` leaves them out; 2305-pg9's links are all
329    /// `/GoTo`.
330    #[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}