Skip to main content

rightkit_pdf/
scan.rs

1//! Scanned-page detection and the shared page content walker (also used by `raster`).
2//!
3//! A scanned page paints (almost) one big image and shows no text. [`classify_page`] reads a page's
4//! content under the same byte and token budget as inspection and reports what it found;
5//! [`PageProfile::is_scanned`] is the "route this page to OCR" decision. No renderer is involved.
6
7use lopdf::{Dictionary, Document, Object, ObjectId};
8
9use crate::inspect::{
10    deref_obj, load_pdf, object_number, page_content_stream_ids, resolve_inherited, resolve_rect,
11    ContentBudget,
12};
13
14/// Errors of page classification and rasterization.
15#[derive(Debug, thiserror::Error)]
16pub enum RasterError {
17    #[error("PDF is malformed, or the page does not exist")]
18    Malformed,
19    #[error("encrypted PDF cannot be rasterized")]
20    Encrypted,
21    #[error("dpi {0} is outside 1..=1200")]
22    InvalidDpi(f32),
23    #[error("page would be {width}x{height} pixels, over the limit of {max_pixels}")]
24    TooLarge {
25        width: u64,
26        height: u64,
27        max_pixels: u64,
28    },
29    #[error("unsupported image: {0}")]
30    UnsupportedImage(String),
31    #[error("image is too large to decode")]
32    ImageTooLarge,
33    #[error("image could not be decoded: {0}")]
34    Decode(String),
35    #[error("page content exceeds the walk limits")]
36    TooMuchWork,
37    #[error("PNG encoding failed: {0}")]
38    Png(String),
39}
40
41/// PDF affine matrix `[a b c d e f]` (row-vector convention).
42pub(crate) type Matrix6 = [f64; 6];
43
44pub(crate) const IDENTITY: Matrix6 = [1.0, 0.0, 0.0, 1.0, 0.0, 0.0];
45
46/// `a` applied first, then `b`.
47pub(crate) fn mul(a: &Matrix6, b: &Matrix6) -> Matrix6 {
48    [
49        a[0] * b[0] + a[1] * b[2],
50        a[0] * b[1] + a[1] * b[3],
51        a[2] * b[0] + a[3] * b[2],
52        a[2] * b[1] + a[3] * b[3],
53        a[4] * b[0] + a[5] * b[2] + b[4],
54        a[4] * b[1] + a[5] * b[3] + b[5],
55    ]
56}
57
58const MAX_FORM_DEPTH: usize = 8;
59const MAX_XOBJECT_INVOCATIONS: usize = 4096;
60const MAX_Q_DEPTH: usize = 1024;
61
62/// What the walker found, in paint order.
63pub(crate) enum Event<'a> {
64    /// A show-text operator with a non-empty string. `visible` is false for render mode 3 / 7
65    /// (the invisible OCR text layer of an already-recognised scan).
66    Text { visible: bool },
67    /// A path painting operator or a shading.
68    Vector,
69    /// `BI` inline image (not decoded).
70    InlineImage,
71    /// An image XObject painted with `ctm` (user space of the page, before the page matrix).
72    // The fields are consumed by the raster walker only; without the `raster` feature the
73    // classifier reads `ctm` alone, so rustc reports the rest as dead there (false positive).
74    #[cfg_attr(not(feature = "raster"), allow(dead_code))]
75    Image {
76        id: ObjectId,
77        ctm: Matrix6,
78        fill: [u8; 3],
79        resources: Option<&'a Dictionary>,
80    },
81}
82
83#[derive(Clone)]
84struct GState {
85    ctm: Matrix6,
86    fill: [u8; 3],
87    tr: i64,
88}
89
90struct Walker<'a, 'f> {
91    doc: &'a Document,
92    budget: ContentBudget,
93    invocations: usize,
94    sink: &'f mut dyn FnMut(Event<'a>) -> Result<(), RasterError>,
95}
96
97/// Walks page `page_id`'s content (and the forms it paints), reporting events to `sink`.
98pub(crate) fn walk_page<'a>(
99    doc: &'a Document,
100    page_id: ObjectId,
101    base: Matrix6,
102    sink: &mut dyn FnMut(Event<'a>) -> Result<(), RasterError>,
103) -> Result<(), RasterError> {
104    let page_dict = doc
105        .get_object(page_id)
106        .ok()
107        .and_then(|o| o.as_dict().ok())
108        .ok_or(RasterError::Malformed)?;
109    if page_dict.get(b"Contents").is_err() {
110        return Ok(());
111    }
112    let resources = resolve_inherited(doc, page_dict, b"Resources")
113        .and_then(|o| deref_obj(doc, o))
114        .and_then(|o| o.as_dict().ok());
115    let ids = page_content_stream_ids(doc, page_id).map_err(|_| RasterError::Malformed)?;
116    let mut walker = Walker {
117        doc,
118        budget: ContentBudget::new(),
119        invocations: 0,
120        sink,
121    };
122    // One logical stream: graphics state carries from entry to entry.
123    let mut gs = GState {
124        ctm: base,
125        fill: [0, 0, 0],
126        tr: 0,
127    };
128    let mut stack: Vec<GState> = Vec::new();
129    for id in ids {
130        let content = walker
131            .budget
132            .content_to_read(doc, id)
133            .map_err(|_| RasterError::Malformed)?;
134        walker.run(&content.operations, resources, &mut gs, &mut stack, 0)?;
135    }
136    Ok(())
137}
138
139impl<'a, 'f> Walker<'a, 'f> {
140    fn run(
141        &mut self,
142        ops: &[lopdf::content::Operation],
143        resources: Option<&'a Dictionary>,
144        gs: &mut GState,
145        stack: &mut Vec<GState>,
146        depth: usize,
147    ) -> Result<(), RasterError> {
148        let nums = |operands: &[Object]| -> Vec<f64> {
149            operands.iter().filter_map(object_number).collect()
150        };
151        for op in ops {
152            match op.operator.as_str() {
153                "q" => {
154                    if stack.len() >= MAX_Q_DEPTH {
155                        return Err(RasterError::TooMuchWork);
156                    }
157                    stack.push(gs.clone());
158                }
159                "Q" => {
160                    if let Some(prev) = stack.pop() {
161                        *gs = prev;
162                    }
163                }
164                "cm" => {
165                    let n = nums(&op.operands);
166                    if n.len() == 6 && n.iter().all(|v| v.is_finite()) {
167                        gs.ctm = mul(&[n[0], n[1], n[2], n[3], n[4], n[5]], &gs.ctm);
168                    }
169                }
170                "g" => {
171                    let n = nums(&op.operands);
172                    if n.len() == 1 {
173                        let v = unit_to_u8(n[0]);
174                        gs.fill = [v, v, v];
175                    }
176                }
177                "rg" => {
178                    let n = nums(&op.operands);
179                    if n.len() == 3 {
180                        gs.fill = [unit_to_u8(n[0]), unit_to_u8(n[1]), unit_to_u8(n[2])];
181                    }
182                }
183                "k" => {
184                    let n = nums(&op.operands);
185                    if n.len() == 4 {
186                        gs.fill = cmyk_to_rgb(n[0], n[1], n[2], n[3]);
187                    }
188                }
189                "Tr" => {
190                    if let Some(v) = op.operands.first().and_then(|o| o.as_i64().ok()) {
191                        gs.tr = v;
192                    }
193                }
194                "Tj" | "'" => {
195                    if op.operands.first().is_some_and(non_empty_string) {
196                        self.text(gs)?;
197                    }
198                }
199                "\"" => {
200                    if op.operands.get(2).is_some_and(non_empty_string) {
201                        self.text(gs)?;
202                    }
203                }
204                "TJ" => {
205                    let any = op
206                        .operands
207                        .first()
208                        .and_then(|o| o.as_array().ok())
209                        .is_some_and(|a| a.iter().any(non_empty_string));
210                    if any {
211                        self.text(gs)?;
212                    }
213                }
214                "S" | "s" | "f" | "F" | "f*" | "B" | "B*" | "b" | "b*" | "sh" => {
215                    (self.sink)(Event::Vector)?;
216                }
217                "BI" => (self.sink)(Event::InlineImage)?,
218                "Do" => {
219                    self.invocations += 1;
220                    if self.invocations > MAX_XOBJECT_INVOCATIONS {
221                        return Err(RasterError::TooMuchWork);
222                    }
223                    self.do_xobject(op, resources, gs, depth)?;
224                }
225                _ => {}
226            }
227        }
228        Ok(())
229    }
230
231    fn text(&mut self, gs: &GState) -> Result<(), RasterError> {
232        let visible = gs.tr != 3 && gs.tr != 7;
233        (self.sink)(Event::Text { visible })
234    }
235
236    fn do_xobject(
237        &mut self,
238        op: &lopdf::content::Operation,
239        resources: Option<&'a Dictionary>,
240        gs: &GState,
241        depth: usize,
242    ) -> Result<(), RasterError> {
243        let doc = self.doc;
244        let Some(name) = op.operands.first().and_then(|o| o.as_name().ok()) else {
245            return Ok(());
246        };
247        let Some(entry) = resources
248            .and_then(|r| r.get(b"XObject").ok())
249            .and_then(|o| deref_obj(doc, o))
250            .and_then(|o| o.as_dict().ok())
251            .and_then(|d| d.get(name).ok())
252        else {
253            return Ok(());
254        };
255        let Some(id) = (match entry {
256            Object::Reference(id) => Some(*id),
257            _ => None,
258        }) else {
259            return Ok(());
260        };
261        let Some(stream) = doc.get_object(id).ok().and_then(|o| o.as_stream().ok()) else {
262            return Ok(());
263        };
264        let subtype = stream
265            .dict
266            .get(b"Subtype")
267            .ok()
268            .and_then(|o| o.as_name().ok())
269            .unwrap_or(&b""[..]);
270        match subtype {
271            b"Image" => (self.sink)(Event::Image {
272                id,
273                ctm: gs.ctm,
274                fill: gs.fill,
275                resources,
276            }),
277            b"Form" => {
278                if depth >= MAX_FORM_DEPTH {
279                    return Err(RasterError::TooMuchWork);
280                }
281                let matrix = stream
282                    .dict
283                    .get(b"Matrix")
284                    .ok()
285                    .and_then(|o| deref_obj(doc, o))
286                    .and_then(|o| o.as_array().ok())
287                    .map(|a| a.iter().filter_map(object_number).collect::<Vec<_>>())
288                    .filter(|n| n.len() == 6 && n.iter().all(|v| v.is_finite()))
289                    .map(|n| [n[0], n[1], n[2], n[3], n[4], n[5]])
290                    .unwrap_or(IDENTITY);
291                let form_resources = stream
292                    .dict
293                    .get(b"Resources")
294                    .ok()
295                    .and_then(|o| deref_obj(doc, o))
296                    .and_then(|o| o.as_dict().ok())
297                    .or(resources);
298                let content = self
299                    .budget
300                    .content_to_read(doc, id)
301                    .map_err(|_| RasterError::Malformed)?;
302                let mut inner = gs.clone();
303                inner.ctm = mul(&matrix, &gs.ctm);
304                let mut inner_stack = Vec::new();
305                self.run(
306                    &content.operations,
307                    form_resources,
308                    &mut inner,
309                    &mut inner_stack,
310                    depth + 1,
311                )
312            }
313            _ => Ok(()),
314        }
315    }
316}
317
318fn non_empty_string(o: &Object) -> bool {
319    matches!(o, Object::String(b, _) if !b.is_empty())
320}
321
322pub(crate) fn unit_to_u8(v: f64) -> u8 {
323    (v.clamp(0.0, 1.0) * 255.0).round() as u8
324}
325
326pub(crate) fn cmyk_to_rgb(c: f64, m: f64, y: f64, k: f64) -> [u8; 3] {
327    let k = k.clamp(0.0, 1.0);
328    let f = |v: f64| unit_to_u8((1.0 - v.clamp(0.0, 1.0)) * (1.0 - k));
329    [f(c), f(m), f(y)]
330}
331
332/// What one page paints.
333#[derive(Clone, Debug, PartialEq)]
334pub struct PageProfile {
335    /// 1-based page number.
336    pub page: u32,
337    /// The page shows at least one non-empty text string, visible or not (an already recognised
338    /// scan carries an invisible text layer and counts as having text).
339    pub has_text: bool,
340    /// Text is shown in a visible render mode.
341    pub has_visible_text: bool,
342    /// Image XObjects painted (forms followed), inline images counted separately.
343    pub image_count: u32,
344    pub inline_image_count: u32,
345    /// Path painting or shading operators were seen.
346    pub has_vector_graphics: bool,
347    /// Largest painted image area as a fraction of the visible page area, capped at 1.0.
348    pub largest_image_coverage: f64,
349}
350
351impl PageProfile {
352    /// Minimum [`PageProfile::largest_image_coverage`] for a page to count as a scan.
353    pub const SCAN_COVERAGE: f64 = 0.5;
354
355    /// True when the page has no text layer and an image covers at least half of it: the page
356    /// needs OCR.
357    pub fn is_scanned(&self) -> bool {
358        !self.has_text && self.largest_image_coverage >= Self::SCAN_COVERAGE
359    }
360}
361
362/// Visible page box (CropBox, else MediaBox, else Letter) width and height in points.
363pub(crate) fn page_box(doc: &Document, page_id: ObjectId) -> [f64; 4] {
364    let dict = doc.get_object(page_id).ok().and_then(|o| o.as_dict().ok());
365    let Some(dict) = dict else {
366        return [0.0, 0.0, 612.0, 792.0];
367    };
368    let m = resolve_rect(doc, dict, b"MediaBox").unwrap_or([0.0, 0.0, 612.0, 792.0]);
369    let r = resolve_rect(doc, dict, b"CropBox").unwrap_or(m);
370    [
371        r[0].min(r[2]),
372        r[1].min(r[3]),
373        r[0].max(r[2]),
374        r[1].max(r[3]),
375    ]
376}
377
378pub(crate) fn page_id_of(doc: &Document, page: u32) -> Result<ObjectId, RasterError> {
379    if doc.is_encrypted() {
380        return Err(RasterError::Encrypted);
381    }
382    doc.get_pages()
383        .get(&page)
384        .copied()
385        .ok_or(RasterError::Malformed)
386}
387
388/// Profiles page `page` (1-based) of an already loaded document.
389pub fn classify_page_doc(doc: &Document, page: u32) -> Result<PageProfile, RasterError> {
390    let page_id = page_id_of(doc, page)?;
391    let b = page_box(doc, page_id);
392    let area = ((b[2] - b[0]) * (b[3] - b[1])).abs().max(1e-9);
393    let mut profile = PageProfile {
394        page,
395        has_text: false,
396        has_visible_text: false,
397        image_count: 0,
398        inline_image_count: 0,
399        has_vector_graphics: false,
400        largest_image_coverage: 0.0,
401    };
402    walk_page(doc, page_id, IDENTITY, &mut |event| {
403        match event {
404            Event::Text { visible } => {
405                profile.has_text = true;
406                profile.has_visible_text |= visible;
407            }
408            Event::Vector => profile.has_vector_graphics = true,
409            Event::InlineImage => profile.inline_image_count += 1,
410            Event::Image { ctm, .. } => {
411                profile.image_count += 1;
412                let det = (ctm[0] * ctm[3] - ctm[1] * ctm[2]).abs();
413                if det.is_finite() {
414                    profile.largest_image_coverage =
415                        profile.largest_image_coverage.max((det / area).min(1.0));
416                }
417            }
418        }
419        Ok(())
420    })?;
421    Ok(profile)
422}
423
424/// Profiles page `page` (1-based) of PDF `bytes`.
425pub fn classify_page(bytes: &[u8], page: u32) -> Result<PageProfile, RasterError> {
426    let doc = load_pdf(bytes).map_err(|_| RasterError::Malformed)?;
427    classify_page_doc(&doc, page)
428}
429
430/// 1-based numbers of the pages that [`PageProfile::is_scanned`]. A page whose content cannot be
431/// read is skipped.
432pub fn scanned_pages(bytes: &[u8]) -> Result<Vec<u32>, RasterError> {
433    let doc = load_pdf(bytes).map_err(|_| RasterError::Malformed)?;
434    if doc.is_encrypted() {
435        return Err(RasterError::Encrypted);
436    }
437    let numbers: Vec<u32> = doc.get_pages().keys().copied().collect();
438    Ok(numbers
439        .into_iter()
440        .filter(|n| {
441            classify_page_doc(&doc, *n)
442                .map(|p| p.is_scanned())
443                .unwrap_or(false)
444        })
445        .collect())
446}
447
448#[cfg(test)]
449pub(crate) mod tests {
450    use super::*;
451    use crate::test_fixtures::{one_page_pdf, PageFixture};
452    use lopdf::{dictionary, Stream};
453
454    /// One 72x72 pt page painting one image XObject over the whole page.
455    pub(crate) fn scan_doc(
456        content: &[u8],
457        image: Stream,
458        extras: Vec<(&'static str, Object)>,
459    ) -> Document {
460        let mut doc = Document::with_version("1.7");
461        let pages_id = doc.new_object_id();
462        let image_id = doc.add_object(image);
463        let content_id = doc.add_object(Stream::new(dictionary! {}, content.to_vec()));
464        let mut page = dictionary! {
465            "Type" => "Page",
466            "Parent" => pages_id,
467            "Contents" => content_id,
468            "MediaBox" => vec![0.into(), 0.into(), 72.into(), 72.into()],
469            "Resources" => dictionary! { "XObject" => dictionary! { "Im0" => image_id } },
470        };
471        for (k, v) in extras {
472            page.set(k, v);
473        }
474        let page_id = doc.add_object(page);
475        doc.objects.insert(
476            pages_id,
477            Object::Dictionary(dictionary! {
478                "Type" => "Pages",
479                "Kids" => vec![page_id.into()],
480                "Count" => 1,
481            }),
482        );
483        let catalog = doc.add_object(dictionary! { "Type" => "Catalog", "Pages" => pages_id });
484        doc.trailer.set("Root", catalog);
485        doc
486    }
487
488    pub(crate) fn gray_2x2() -> Stream {
489        Stream::new(
490            dictionary! {
491                "Type" => "XObject",
492                "Subtype" => "Image",
493                "Width" => 2,
494                "Height" => 2,
495                "ColorSpace" => "DeviceGray",
496                "BitsPerComponent" => 8,
497            },
498            vec![0, 255, 255, 0],
499        )
500    }
501
502    #[test]
503    fn an_image_only_page_is_scanned() {
504        let doc = scan_doc(b"q 72 0 0 72 0 0 cm /Im0 Do Q", gray_2x2(), vec![]);
505        let p = classify_page_doc(&doc, 1).unwrap();
506        assert_eq!(p.image_count, 1);
507        assert!(!p.has_text);
508        assert!(p.is_scanned());
509    }
510
511    #[test]
512    fn a_small_image_does_not_make_a_scan() {
513        let doc = scan_doc(b"q 10 0 0 10 0 0 cm /Im0 Do Q", gray_2x2(), vec![]);
514        assert!(!classify_page_doc(&doc, 1).unwrap().is_scanned());
515    }
516
517    #[test]
518    fn a_text_page_is_not_scanned() {
519        let doc = one_page_pdf(PageFixture::for_content(
520            b"BT /F1 12 Tf 72 700 Td (Hello) Tj ET",
521        ));
522        let p = classify_page_doc(&doc, 1).unwrap();
523        assert!(p.has_text && p.has_visible_text);
524        assert!(!p.is_scanned());
525    }
526
527    #[test]
528    fn invisible_text_over_an_image_counts_as_a_text_layer() {
529        let doc = scan_doc(
530            b"q 72 0 0 72 0 0 cm /Im0 Do Q BT 3 Tr 0 0 Td (hi) Tj ET",
531            gray_2x2(),
532            vec![],
533        );
534        let p = classify_page_doc(&doc, 1).unwrap();
535        assert!(p.has_text && !p.has_visible_text);
536        assert!(!p.is_scanned());
537    }
538
539    #[test]
540    fn a_blank_page_is_not_scanned() {
541        let doc = one_page_pdf(PageFixture::for_content(b""));
542        let p = classify_page_doc(&doc, 1).unwrap();
543        assert_eq!(p.image_count, 0);
544        assert!(!p.is_scanned());
545    }
546
547    #[test]
548    fn scanned_pages_reads_bytes() {
549        let mut doc = scan_doc(b"q 72 0 0 72 0 0 cm /Im0 Do Q", gray_2x2(), vec![]);
550        let mut bytes = Vec::new();
551        doc.save_to(&mut bytes).unwrap();
552        assert_eq!(scanned_pages(&bytes).unwrap(), vec![1]);
553        assert!(classify_page(&bytes, 2).is_err());
554    }
555}